Archive of

SEO for lazy people

Although I have been dabbling in web development for a while I have never gotten into SEO. I guess I am more of a developer than I am an entrepreneur because once the technical challenge is solved, I tend to lose interest. If you want to earn money however people need to know about your solution in addition to it being useful, fairly priced and excel in many other aspects. It is part of the game.

In our new venture we are performing employee surveys in local government organisations and other state institutions in Middle-Germany. Although this kind of work is usually offered through public tenders it is still helpful to be found by decision makers. The first thing you need is a web page. Since I am not much of a UX designer and have a Gemini subscription I decided to use Google Stitch project which is basically an AI driven Figma. The designs it generates still have that undesirable ai-generated look, but they are a great basis to start from. The individual pages can be exported into html using tailwind classes for styling.

Next step I created an Astro project, added Tailwind and copied the styled pages from Stitch. Astro is a great choice because it generates static pages. With SPAs like client components in React or Angular applications Google can't crawl the pages contents because during initial loading of the page only a bare-bones HTML page is loaded together with the JavaScript bundle. Only after that JavaScript generates all the DOM elements but by then the Google crawlers have already moved on.

I refactored that stitch code into components (header, footer, etc.) then used the Gemini CLI to "make the page mobile ready" which worked ok-ish. In many cases I had to intervene, for example make sure that the large German headings are broken up properly on small viewports. I also added a markdown-based blog section for which there already is a collection in Astro. This was then deployed to a Hetzner webspace. Usually for PHP applications webspaces are great for deploying a static page. You just run npm run build locally and use an FTP client to copy the contents of the _dist folder to the public folder of the webspace. They are privacy friendly hosted in Germany - an important aspect if you are trying to sell to the German government - and super cheap. The smallest one on Hetzner is around 2€ per month. They also come with integrated Email, a database and many other services.

This first version I ran through pagespeed and the results were not great. Especially on mobile which is apparently weighed higher in Googles algorithm. I basically copied and pasted the findings into Gemini CLI and told it to optimize. The biggest culprit where heavy font files (can't reference Google fonts on a privacy focused web page) and it configured the Apache server running on the webspace such that it would cache them which improved the score. There were also a couple of AI generated images from Stitch that slowed things down, so it optimized the settings in the Astro image tag. However, the results were still not that great. One of the heaviest font files was the one containing symbols - like check mark icons for example. So, I decided to replace them with SVG icons which are more lightweight. I also removed some of the AI generated images which sucked anyway.

Another aspect of SEO optimization is a sitemap. It is a small xml file that lists the contents on your page. It is said that it will make it easier for Google to crawl. Fortunately, there is a handy plugin for that in Astro that automatically generates the xml file. It is then part of the _dist folder along with the other assets generated by the build.

As a final optimization step I signed in to Google Search Console that gives you more information how your site is found. To connect the URL to your Google Account you need to add their key to the domains' DNS entries.

And voíla - we are on the top spot on Google.

Deploy a complex Next.js app with Kamal - BetterAuth, Drizzle and Postgres

Last change: 2025-10-06

  • Next.js version: 15.4.7
  • Ruby version 3.4.5
  • Kamal version: 2.7.0

Introduction

This builds on my previous post which describes how to deploy a relatively simple Next.js application with Kamal to a VPS. Simple meaning that there weren't any environment variables and not database. This post describes how to deploy a more complete Next.js application using BetterAuth for authentication, Drizzle ORM and a Postgres database. We are deploying to a single beefier VPS on Hetzner (ARM 4 vCPU, 8GB RAM, 10GB Volume). In contrast to the other post we are also using Docker Hub instead of the Scaleway Docker repository as I found it unreliable on my mobile connection.

The upsides to deploy all of this to a single VPS compared to more elaborate setups with a load balancers, multiple application servers and a separate database server.

  • Lower costs since only one server
  • Less complex setup

The downsides are

  • No failover in case the server looses connection
  • Limited scalability

Server Setup

SSH into the server using the private key you used during provisioning.

ssh -i your_private_key_file [email protected]

Then update and reboot

apt update && apt upgrade -y && reboot

SSH into the server again after it rebooted. Then we need to configure the firewall as kamal does not do this for us.

# SSH (required for Kamal to connect)
ufw allow 22/tcp

# HTTP (for web traffic)
ufw allow 80/tcp

# HTTPS (for SSL/TLS traffic)
ufw allow 443/tcp

# For Scaleway Transactional Email Service
# !!! Port 465 is blocked by default on Hetzner !!!
ufw allow 587/tcp

# Enable the firewall
ufw enable

Containerisation and Secrets

This describes how to containerize our Next.js application. The main difference to the previous post is that we now have to pass a lot of environment variables to the application. They are initially setup in config/secretsto be read from the environment:

KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD

BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
BETTER_AUTH_URL=$BETTER_AUTH_URL
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
DATABASE_URL=$DATABASE_URL
BETTER_AUTH_TELEMETRY=$BETTER_AUTH_TELEMETRY
NEXT_TELEMETRY_DISABLED=$NEXT_TELEMETRY_DISABLED
JWT_SECRET=$JWT_SECRET

SCW_ACCESS_KEY=$SCW_ACCESS_KEY
SCW_SECRET_KEY=$SCW_SECRET_KEY
SCW_DEFAULT_ORGANIZATION_ID=$SCW_DEFAULT_ORGANIZATION_ID
SCW_DEFAULT_PROJECT_ID=$SCW_DEFAULT_PROJECT_ID

SENDER_EMAIL=$SENDER_EMAIL
SENDER_NAME=$SENDER_NAME

The SCW_* secrets are related to Scaleways transactional email service that I use to send email confirmations and password resets from BetterAuth. If you are wondering how to set BetterAuth up with Next there is a wonderful videos series by Orcdev.

This means we will need these secrets in our terminal environment when we run kamal setup or kamal deploy. I prefer to keep my secrets in an .env file. However reading them with source .env.prod is insufficient at this point since kamals secret file is evaluated in a sub-shell. What worked for me is

set -a
source .env.prod
set +a
kamal deploy # or kamal setup

Coming back to our Dockerfile. Next bakes in the environment variables during build. That is why

  • you need to pass the secrets in deploy.yml in the build step

    builder:
      arch: arm64 # because we are using an arm-based VPS
      secrets:
        - BETTER_AUTH_SECRET
        - BETTER_AUTH_URL
        - DATABASE_URL
        - BETTER_AUTH_TELEMETRY
        - NEXT_TELEMETRY_DISABLED
        - JWT_SECRET
        - SCW_ACCESS_KEY
        - SCW_SECRET_KEY
        - SCW_DEFAULT_ORGANIZATION_ID
        - SCW_DEFAULT_PROJECT_ID
        - SENDER_EMAIL
        - SENDER_NAME
    
  • you need to read them into the docker containers' environment

  • you need to make sure that you don't push your image to a public repository (by default the free repository on Dockerhub is public!) otherwise you will leak credentials

This is my Dockerfile

# syntax=docker.io/docker/dockerfile:1

FROM node:22-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV BETTER_AUTH_TELEMETRY=0
ENV NEXT_TELEMETRY_DISABLED=1

# Different to the previous post
RUN --mount=type=secret,id=BETTER_AUTH_SECRET \
    --mount=type=secret,id=BETTER_AUTH_URL \
    --mount=type=secret,id=DATABASE_URL \
    --mount=type=secret,id=BETTER_AUTH_TELEMETRY \
    --mount=type=secret,id=NEXT_TELEMETRY_DISABLED \
    --mount=type=secret,id=JWT_SECRET \
    --mount=type=secret,id=SCW_ACCESS_KEY \
    --mount=type=secret,id=SCW_SECRET_KEY \
    --mount=type=secret,id=SCW_DEFAULT_ORGANIZATION_ID \
    --mount=type=secret,id=SCW_DEFAULT_PROJECT_ID \
    --mount=type=secret,id=SENDER_EMAIL \
    --mount=type=secret,id=SENDER_NAME \
    export BETTER_AUTH_SECRET=$(cat /run/secrets/BETTER_AUTH_SECRET) && \
    export BETTER_AUTH_URL=$(cat /run/secrets/BETTER_AUTH_URL) && \
    export DATABASE_URL=$(cat /run/secrets/DATABASE_URL) && \
    export JWT_SECRET=$(cat /run/secrets/JWT_SECRET) && \
    export SCW_ACCESS_KEY=$(cat /run/secrets/SCW_ACCESS_KEY) && \
    export SCW_SECRET_KEY=$(cat /run/secrets/SCW_SECRET_KEY) && \
    export SCW_DEFAULT_ORGANIZATION_ID=$(cat /run/secrets/SCW_DEFAULT_ORGANIZATION_ID) && \
    export SCW_DEFAULT_PROJECT_ID=$(cat /run/secrets/SCW_DEFAULT_PROJECT_ID) && \
    export SENDER_EMAIL=$(cat /run/secrets/SENDER_EMAIL) && \
    export SENDER_NAME=$(cat /run/secrets/SENDER_NAME) && \
    npm run build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV BETTER_AUTH_TELEMETRY=0
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 80

ENV PORT=80

ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

Database

We are going to deploy a postgres database along with the Next.js application. This is done through a kamal functionality named accessory. It allows you to deploy databases like postgres, mysql or redis for caching as well.

This is the setup in my deploy.yaml:

accessories:
  db:
    image: postgres:17
    host: 123.45.678.999
    port: 127.0.0.1:5432:5432 # db only available from localhost, not open to the world
    env:
      clear:
        POSTGRES_USER: "postgres"
        POSTGRES_DB: "postgres"
      secret:
        - POSTGRES_PASSWORD
    directories:
      - /mnt/volume-nbg1-1/postgres:/var/lib/postgresql/data

Two important points here

  • The port configuration such that the database is not open to the world
  • The directories key maps the postgres data to my mounted volume. The idea is that the database can later run on a different server that just gets mounted the same volume. I am not entirely sure if that works, though. Anyway one could still pg_dump and then migrate manually.

The database url

The container running the Next.js application needs to connect to the container running the postgres database which is not localhost but rather the container name of the database container. For example if your application name is kamal-nextand you accessory database is called db(as in the yaml above) your database container name would be kamal-next-db. This make the DATABASE_URL out to be something like postgres://postgres_user:postgres_pw@kamal-next-db:5432/postgres_db.

How to run push or migrations

The next big question is once everything is deployed how to run the drizzle migrations or (as I prefer) the push. My solution is crude but it works. I ssh into the server, install Node.js using fnm, configure a basic .env file and then run npx drizzle-kit push. I prefer this to an automated solution because the push might fail or there might be options to choose from. The downside is that there is a gap in time between the database change being deployed and the application change which might lead to errors for the user.

DNS configuration and Kamal Proxy

Suppose I am using this to deploy a side-project I am working on called Easymiet. I want it to be reachable at easymiet.eu as well as www.easymiet.eu. This means I am making one DNS entry routing to my server instance

@   A     3600  123.45.678.999

and another

www CNAME 3600  easymiet.eu.

To configure the Kamal Proxy accordingly one needs to add both as hosts

proxy:
  ssl: true
  hosts:
    - easymiet.eu
    - www.easymiet.eu
  healthcheck:
    interval: 3
    path: /api/ok
    timeout: 3

Deploy a Next.js application to a VPS with kamal

Last Change: 2025-09-19

  • Next.js version: 15.4.7
  • Ruby version 3.4.5
  • Kamal version: 2.7.0

Why

Vercel is a wonderful service. There is no simpler way to deploy a web application based on Next. There is just one problem you face as a European: It is a service run by a US company. That means it falls under US law regarding data sharing with the government and more often than not these days European customers are not willing to accept that. They want their data stored on servers located in Europe, run by European companies.

That means we have to leave the comfort of deploying with Vercel and all the awesome features it provides. There is no European version of it - not yet anyway. So we turn to the good old VPS and deploy there using Kamal.

I will describe how to deploy a simple Next.js app on a single VPS.

Setup

You need a Next.js project and kamal installed which requires Ruby. I recommend using rbenv.

After setting up Ruby run

gem install kamal

You also need Docker - I used the regular docker desktop application.

You will need a container repository somewhere as well. Since the goal of this exercise is to free oneself from US services I used the container registry available at the French cloud provider Scaleway.

Ideally you have a domain available to point to the VPS's IP address.

You will also have your VPS's private SSH key in your local keychain: ssh-add ~/.ssh/my_custom_key

The kamal proxy which runs in front of you application in this single-server setup requires your application to return OK on a path you can configure in config/deploy.yaml (more on that later). I solved this by adding a route at /api/ok

export async function GET() {
    return Response.json({ status: 'OK' }, { status: 200 });
}

Kamal expects your application to run on port 80, Next however runs on 3000 by default. Thus we have to change the package.json to start the application on port 80.

...
"scripts": {
    "dev": "next dev --turbopack",
    "build": "next build --turbopack",
    "start": "next start -p 80",
    "lint": "eslint"
  },
...

You need to change the next.config.tsas well to generate a standalone production build that runs efficiently in a container.

const nextConfig: NextConfig = {
  output: "standalone"
};

Containerization

Kamal essentially sets up your server to run an application in a docker container. This means that you need to make you application run in a docker container first. For Next this is fairly straightforward.

# syntax=docker.io/docker/dockerfile:1

FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
RUN npm ci


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1

RUN  npm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 80

ENV PORT=80

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

As you can see in this case I am using regular npm. If you would like to use another package manager you need to install it and modify this dockerfile accordingly. Please also remember to put in a .dockerignore file to avoid bundling things into the container that don't need to (or should) be bundled. This uses the small alpine node image. There are node images based on other distributions if you are so inclined.

This docker image is build by copying the application code, installing the dependencies and building the Next application. It then creates a non-root user to run the Next application we have build and then procedes to do so on port 80.

Kamal Configuration

On the top level of your Next project run kamal init. This will create all the necessary files you will need. Inside the folder config you will now find deploy.yaml.

service: kamal-next

image: kamal-test-namespace/kamal-next

servers:
  web:
    - 127.168.0.1

proxy:
  ssl: true
  host: kamal-next.yourdomain.eu
  healthcheck:
    interval: 3
    path: /api/ok
    timeout: 3

registry:
  server: rg.fr-par.scw.cloud
  username: nologin

  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

On the top you define a general service name. The image key references the namespace you configured in your docker repository and then the image name. The servers key contains the actual IP address of the VPS you provisioned. So prior to running this you need to spin up a VPS with the provider you would like to use. I used the second smallest VPS on Scaleway with 2 vCPU cores and 4 GB of RAM.

The proxy becomes necessary since this is a single server deployment. It runs in front of your application container, provides SSL encryption that is automatically set up as well as deployments free of interruptions. The healthcheck is configured to hit the endpoint we created above. It will only send requests there during deployment.

Under the registry key I put the URL of the image repository at Scaleway. The username is always nologin, the password is your secret key. To set it during build export it as a environment variable export KAMAL_REGISTRY_PASSWORD=my_scaleway_secret_key.

The builder key determines for which processor architecture the container is build. If you provisioned a VPC running on a ARM processor you need to change this to arm64.

Setup and Deployment

Kamal uses git to identify changes in your project. So git should be initialized and the changes committed.

For the initial setup run

kamal setup

Kamal will then proceed to build your image, install docker on your VPS, set the proxy up and then deploy your container. Make sure you have a stable connection. The first time I tried this was on a weak cellular connection and the push of the image to the registry failed a couple of times.

That's it. You now have your application running on a VPS. Congratulations!

If you want to deploy changes just commit and then run

kamal deploy