Skip to main content
Stackbastion

Draft, not yet published

Deploying a Next.js app to Hetzner the right way

7 July 2026· 5 min read · by Stackbastion

You’ve got a Next.js app and a Hetzner bill that’s a fraction of what a platform host wanted. Good instinct. But “just put it on a VPS” hides a dozen steps, and getting them wrong means a box that’s slow, insecure, or down at 3am. Here’s the setup we actually use, start to finish.

Why Hetzner, and why this setup

Hetzner sells raw compute cheaply. A CX22 (2 vCPU, 4GB RAM) runs about 4 to 5 euros a month and will comfortably serve a Next.js app with real traffic. The catch is that a platform host gave you builds, HTTPS, restarts, and logs for free, and on a bare server you own all of that yourself.

The setup below hands most of that ownership to two tools. Docker packages your app so it runs the same everywhere and restarts itself if it crashes. Caddy sits in front and gives you automatic HTTPS with zero certificate wrangling. This mirrors the stack in our Claude Code to Hetzner guide, applied to Next.js specifically.

How to fix it

Step 1: provision the server

Create a Hetzner Cloud project, then a server: Ubuntu 24.04, CX22, and add your SSH key during creation so you can log in without a password. Once it boots, connect:

ssh root@YOUR_SERVER_IP

First thing, create a non-root user and a basic firewall. Running everything as root is how small mistakes become total ones.

adduser deploy
usermod -aG sudo deploy
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable

Step 2: install Docker

Log in as your new user, then install Docker from the official script:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy
# log out and back in so the group change takes effect

Step 3: containerize the Next.js app

Next.js has a standalone output mode that produces a tiny self-contained server, which makes for a small, fast image. Turn it on in next.config.js:

// next.config.js
const nextConfig = {
  output: 'standalone',
};
module.exports = nextConfig;

Then add a Dockerfile at the project root. This multi-stage build keeps the final image lean:

# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# Run stage
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Step 4: wire it up with Docker Compose

Compose lets you run the app and Caddy together and describe both in one file. On the server, create a project folder and a docker-compose.yml:

services:
  web:
    build: .
    restart: unless-stopped
    env_file: .env
    expose:
      - "3000"

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data

volumes:
  caddy_data:

Note the app uses expose, not ports. That keeps port 3000 private to the Docker network so the outside world can only reach your app through Caddy.

Step 5: Caddy for automatic HTTPS

Create a Caddyfile next to the compose file. This is the entire config for HTTPS on a real domain:

app.yourdomain.com {
    reverse_proxy web:3000
}

Point your domain’s DNS A record at the server’s IP first. When Caddy starts, it fetches a Let’s Encrypt certificate on its own and renews it forever. No certbot, no cron job, no manual renewal.

Step 6: ship it

Put your secrets in a .env file next to the compose file (never commit this), then bring the stack up:

docker compose up -d --build
docker compose logs -f web

Visit https://app.yourdomain.com. You should have a live, HTTPS-secured Next.js app that restarts itself on crash or reboot.

To deploy an update later: pull the new code, then rebuild.

git pull
docker compose up -d --build

That last command rebuilds and swaps the container. For true zero-downtime swaps rather than a one-second blip, see zero-downtime deploys explained.

Or, we do it for you

We run this exact stack for customers on managed Hetzner boxes, with backups, monitoring, and a named human when it breaks. If you’d rather own the app and not the pager, see our pricing.

FAQ

Do I need Kubernetes for this?

No. For a single Next.js app, Kubernetes is a large amount of complexity for zero benefit. Docker Compose on one server handles everything until you’re running several services across multiple machines, which most apps never reach.

What size Hetzner server should I pick?

Start with a CX22 (about 5 euros a month). Next.js builds can be memory-hungry, so if pnpm build gets killed mid-build, either bump to a CX32 or build the image locally and push it to a registry instead of building on the server.

How do I add a database?

Add a Postgres service to the same docker-compose.yml, give it a named volume for its data, and connect over the private Docker network. For anything holding real user data, put PgBouncer in front and set up tested backups, covered in our PgBouncer and backup posts.

Will Caddy really handle certificate renewal by itself?

Yes. Caddy requests and renews Let’s Encrypt certificates automatically as long as ports 80 and 443 are open and your DNS points at the box. This is the single biggest reason we reach for Caddy over hand-configured Nginx on small setups.