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