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/secrets
to 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-next
and 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