November 2, 2024

Deploying Next.js with Kamal 2 and GitHub Actions to Hetzner

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.

Blast Banners

When I was building my tiny marketing experiment for ScreenshotOneBlast 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:

  1. I wrote a start.sh script to run migrations, seed the database, and start the application.
  2. I added dumb-init.
  3. I prefer Node.js 22 over 20. It is the latest stable release.
  4. I added a script to run migrations and seed the database.
  5. 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:

GitHub Actions secrets

Commit and push your changes to the main branch and check the workflow run:

GitHub Actions 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:

  1. For security—to hide your server IP address from the public.
  2. To cache static assets.
  3. To have a DoS protection if needed.
  4. And many more.

For example, it is not much of caching but enough for Blast Banners to be performant:

Cloudflare cache metrics

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:

Namecheap DNS records

And then configure the DNS record with the IP address of your server:

Cloudflare DNS records

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:

Cloudflare full encryption

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

  1. Of course, check out the official documentation of Kamal.
  2. If you want to learn behind the scenes, check out Kamal Handbook.
  3. And a guide by the founder of LogSnag on how to self-host Next.js with Hetzner and Kamal.