A complete guide on how to deploy Next.js (TypeScript) with a permanent SQLite database (Prisma) using Docker, Kamal 2 and GitHub Actions to Hetzner behind Cloudflare.
When I was building my tiny marketing experiment for ScreenshotOne—Blast Banners, I failed to find a decent and working example how to deploy Next.js with a permanent SQLite database with Kamal 2—neither Google, nor ChatGPT had answers.
If you want to skip reading and just want to see the code right away, you can find it at the Blast Banners GitHub repository. It works and deploys to production!
Keep reading if you are curious about the nuances and some decisions I made when automating that deployment.
Next.js and SQLite (Prisma)
I picked up Next.js, just because I know it. I don’t have time to try a new framework. But the guide will work also for Remix and any other framework.
SQLite is a stable and performant database. It is a perfect choice for Blast Banners. I also set up backups on Hetzner to make sure that I don’t lose any data.
By the way, if you want to ensure backups on the per change level, you can use Litestream, or you can use SQLite’s remote-copy tool.
Prisma is just a simple ORM that allows to use typed queries—I am just familiar with it. My prisma/schema.prisma
file:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model GameSession {
id String @id @default(cuid())
sessionKey String @unique @map("session_key")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
score Int? @map("score")
name String? @map("name")
countryCode String? @map("country_code")
fingerprint String? @map("fingerprint")
ipHash String? @map("ip_hash")
}
model Screenshot {
id String @id @default(cuid())
websiteUrl String @map("website_url")
path String @map("path")
name String @map("name")
}
I also would consider to try Drizzle ORM, but I didn’t have time.
The important part is to add commands to generate Prisma client, seed the database, and change the db schema (package.json
):
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"db:seed": "tsx ./src/scripts/seed.mts",
"db:push": "prisma db push --skip-generate"
}
Important! The way I handle updating the database schema is not the best one. If I had more time, I would try to use migrations, and not just push the schema to the database. And I would probably set up a different workflow for running migrations.
Do not use this approach for mission-critical production applications! But it perfectly fits for a side project or a marketing experiment like Blast Banners.
Prepare Next.js Dockerfile
I adapted the Next.js Docker file to my needs:
- I wrote a
start.sh
script to run migrations, seed the database, and start the application. - I added
dumb-init
. - I prefer Node.js 22 over 20. It is the latest stable release.
- I added a script to run migrations and seed the database.
- I added a directory for any persistent data generated by the application. That will be later mounted as a volume with the help of Kamal 2.
The resulting Dockerfile
is:
FROM node:22-alpine AS base
RUN apk add --no-cache dumb-init
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* ./
RUN npm install
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
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
COPY --from=builder /app/prisma ./prisma
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/start.sh ./start.sh
RUN chmod +x start.sh
RUN chown nextjs:nodejs start.sh
# for migrations and seed
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src/scripts ./src/scripts
COPY --from=builder /app/src/lib ./src/lib
COPY --from=builder /app/package.json ./
COPY --from=builder /app/tsconfig.json ./
# for any data generated by the application
# ./data should be persistent across deployments
RUN mkdir data
RUN chown nextjs:nodejs data
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0" NODE_ENV=production
CMD ["/usr/bin/dumb-init", "--", "/bin/sh", "/app/start.sh"]
The most important part is:
# for migrations and seed
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src/scripts ./src/scripts
COPY --from=builder /app/src/lib ./src/lib
COPY --from=builder /app/package.json ./
COPY --from=builder /app/tsconfig.json ./
# for any data generated by the application
# ./data should be persistent across deployments
RUN mkdir data
RUN chown nextjs:nodejs data
If you want to have persistent data available across deployments, you need to create a directory and make it writable for the user that will run the application, and mount it as a volume.
The start.sh
script is simple and as I mentioned before, it runs migrations, seeds the database,
and starts the application:
#!/bin/bash -e
npm run db:push
npm run db:seed
node server.js
To make it work, don’t forget to update your package.json
:
"scripts": {
// ...
"build": "prisma generate && next build",
// ...
"db:seed": "tsx ./src/scripts/seed.mts",
"db:push": "prisma db push --skip-generate"
}
Once you make sure that Docker image is building correctly, and you can run it locally, you can proceed to the next step.
Configure Kamal 2
If you was a user of Kamal 1, check out the upgrade guide. Configuration for Kamal 2 is a bit different.
The most important point in the configuration is the volumes
section. It allows you to mount a directory from the host machine to the container. In my case, I want to mount the data
directory from the host machine to the /app/data
directory in the container. It will allow to have persistent data across deployments—my SQLite database in this case.
The config/deploy.yml
file:
service: blastbanners
image: screenshotone/blastbanners
builder:
context: .
arch: amd64
servers:
- <%= ENV["TARGET_HOST"] %>
logging:
driver: loki
options:
loki-url: <%= ENV["LOKI_URL"] %>
loki-retries: 5
loki-batch-size: 400
loki-external-labels: app=screenshotone-blastbanners,container_name={{.Name}}
volumes:
- data:/app/data:rw
proxy:
host: blastbanners.com
ssl: true
forward_headers: false
healthcheck:
interval: 3
path: /health
timeout: 3
app_port: 3000
registry:
server: <%= ENV["DOCKER_REGISTRY_SERVER"] %>
username:
- DOCKER_REGISTRY_USERNAME
password:
- DOCKER_REGISTRY_PASSWORD
You will need to provide environment variables for the registry, Loki, and the target host. You might omit the Loki if you don’t want to use it.
To provide registry credentials, you need to create a secrets
file in the .kamal
directory.
DOCKER_REGISTRY_USERNAME=$DOCKER_REGISTRY_USERNAME
DOCKER_REGISTRY_PASSWORD=$DOCKER_REGISTRY_PASSWORD
And these environment variables must be available when we will be running GitHub Actions.
One more thing to mention is the builder context set to the current directory:
# ...
builder:
context: .
# ...
It is needed, because there will be an artificial .env
file created for Next.js when running GitHub Actions. And if you don’t set the builder context, Kamal will not be able to use the file, because it will be building the image from the GitHub commit excluding any changes.
Deploy to Hetzner with Kamal
Before I built the GitHub Actions pipeline, I created a server on Hetzner, and made sure that I can deploy my application to it with Kamal.
kamal setup
In my case, I also needed to set up Loki drivers for Docker:
docker plugin install grafana/loki-docker-driver:<specify latest stable version> --alias loki --grant-all-permissions
And I bought a floating IP address for the server to make sure it will be static and I can reverse proxy to it with Cloudflare.
GitHub Actions
Once I made sure that my Docker image is building correctly, and I can deploy it to the server with Kamal, I created a simple GitHub Actions workflow (.github/workflows/deploy.yml
):
name: Deploy
on:
push:
branches:
- main
jobs:
Deploy:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
DOCKER_BUILDKIT: 1
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2.2
bundler-cache: true
- name: Install Kamal
run: |
gem install kamal -v 2.2.2
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Deploy with Kamal
env:
DOCKER_REGISTRY_USERNAME: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
DOCKER_REGISTRY_SERVER: ${{ secrets.DOCKER_REGISTRY_SERVER }}
LOKI_URL: ${{ secrets.LOKI_URL }}
TARGET_HOST: ${{ secrets.TARGET_HOST }}
run: |
echo "NEXT_PUBLIC_BASE_URL=https://blastbanners.com" >> .env
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
echo "SCREENSHOTONE_API_ACCESS_KEY=${{ secrets.SCREENSHOTONE_API_ACCESS_KEY }}" >> .env
echo "SCREENSHOTONE_API_SECRET_KEY=${{ secrets.SCREENSHOTONE_API_SECRET_KEY }}" >> .env
echo "NEXT_PUBLIC_PIRSCH_CODE=${{ secrets.NEXT_PUBLIC_PIRSCH_CODE }}" >> .env
kamal deploy
Make sure that all secrets are available in the repository:
Commit and push your changes to the main branch and check the workflow run:
Once it works the only thing left is to set up Cloudflare as a reverse proxy and make your application available via a custom domain to the world!
Set up Cloudflare as a reverse proxy
There are many reasons to use Cloudflare as a reverse proxy:
- For security—to hide your server IP address from the public.
- To cache static assets.
- To have a DoS protection if needed.
- And many more.
For example, it is not much of caching but enough for Blast Banners to be performant:
To use Cloudflare as a reverse proxy, you just create point your domain name to Cloudflare servers. E.g. on Namecheap it looked like that:
And then configure the DNS record with the IP address of your server:
Make sure to enable the proxy mode for the DNS record, and that your IP address is static. Hetzner call it a “floating IP”, buy one and attach it to your server.
By the way, there is no problem for Cloudflare to proxy IPv4 traffic to your IPv6 address. IPv6 addresses are much cheaper often.
Don’t forget to enable full encryption for the domain in Cloudflare:
It is required to set ssl: true
in the proxy
section in the config/deploy.yml
file to make that work:
# ...
proxy:
host: blastbanners.com
ssl: true
# ...
Recommendations
- Of course, check out the official documentation of Kamal.
- If you want to learn behind the scenes, check out Kamal Handbook.
- And a guide by the founder of LogSnag on how to self-host Next.js with Hetzner and Kamal.