Deploy Next.js to Hetzner
Follow this on your own server. Start free, no card needed
You’ve got a Next.js app and a Hetzner account, and you want the app answering at your own domain over HTTPS, without handing the whole thing to a platform that bills per request. This is the fully manual version: a real server, a Dockerfile that produces a small production image, and Caddy in front of it for certificates. By the end you’ll have next build running in standalone mode inside a container, served behind HTTPS, with a repeatable way to rebuild and restart it.
What you’ll need
- A Hetzner Cloud account and API token.
- A server: a CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) runs a small-to-medium Next.js app fine at runtime; if
next builditself runs out of memory on a large app, resize up to a CX33 (4 vCPU, 8 GB RAM, €8.99/month) for the build, then back down afterward, or build the image on a separate machine and just run it on the CX23. - A domain or subdomain with an A record you can point at the server.
- A Next.js app with a
package.jsonbuildandstartscript, on Node.js 20.9 or newer, which is the minimum Next.js itself currently requires. - SSH access to the server.
Step 1: Create the server
From the Hetzner Cloud console, create a server using the cx23 type (or your resized choice) on Ubuntu 24.04, add your SSH key at creation time, and note the public IP once it’s running. Install Docker and the Compose plugin over SSH:
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Step 2: Point DNS at the server
Create an A record for your domain or subdomain pointing at the server’s IP, for example app.yourdomain.com. Give it a few minutes to propagate before Step 6, since Caddy needs it resolving correctly to request a certificate.
Step 3: Enable standalone output
Next.js’s own build only includes the files a page needs if you opt into output file tracing. Add this to next.config.js (or next.config.ts):
module.exports = {
output: "standalone",
}
This makes next build produce a .next/standalone folder containing a minimal server.js and only the node_modules your app actually uses at runtime, rather than needing the entire node_modules tree copied into the production image. The standalone server doesn’t copy your public/ folder or .next/static automatically, both get copied into the standalone output explicitly in the Dockerfile below.
Step 4: Write the Dockerfile
This follows the pattern in Next.js’s own official Docker example, trimmed down:
ARG NODE_VERSION=22-slim
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
else echo "No lockfile found" && exit 1; fi
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
node:22-slim is a current Node.js LTS release, comfortably above the 20.9 minimum Next.js documents today. The three-stage layout keeps dependency installation, the actual build, and the runtime image separate, so the final image only contains what output: "standalone" traced as necessary plus the two folders it doesn’t copy on its own. USER node runs the process as the non-root node user that’s already present in the official Node images, rather than root, which costs nothing and is worth doing by default.
Step 5: Write the Compose file and Caddyfile
services:
web:
build: .
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "3000"
expose:
- "3000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- web
volumes:
caddy-data:
caddy-config:
app.yourdomain.com {
reverse_proxy web:3000
}
PORT and HOSTNAME are the two environment variables Next.js’s standalone server.js reads directly, HOSTNAME=0.0.0.0 (set in the Dockerfile) is what makes the server listen on all interfaces inside the container rather than only localhost, which would otherwise make it unreachable from the caddy service next to it. Any variable your app reads at runtime with process.env, as opposed to one inlined at build time, goes in this environment: block too; only variables prefixed NEXT_PUBLIC_ get baked into the client bundle during next build, everything else stays server-only and can change without a rebuild.
Step 6: Deploy
docker compose up -d --build
The first run builds the image, which is the slowest part, then starts both containers and Caddy requests its certificate. Tail the logs to confirm the build and the certificate issue both succeeded:
docker compose logs -f
You should see the Next.js server log a line like Ready in <n>ms, and Caddy’s log include certificate obtained successfully for your domain. Visit https://app.yourdomain.com and confirm the app loads over HTTPS with a valid certificate.
For a later code change, the same command rebuilds just the image that changed and recreates that one container:
docker compose up -d --build web
This isn’t truly zero-downtime, Compose stops the old web container before starting the new one, so there’s a gap measured in however long your app takes to boot and pass its first request. If that gap matters for your traffic, the standard fix is running two app containers behind Caddy’s own load balancing and replacing them one at a time, which is more infrastructure than this guide covers for a single-server setup.
The shortcut: do it with Railyard
Everything in Steps 1 and 3 through 6 is what Railyard’s own Node detection does automatically when you deploy a repository instead of hand-rolling the Dockerfile. For a repo without its own Dockerfile, it reads package.json, picks your package manager from whichever lockfile is present (npm, pnpm, yarn or bun), and runs your start script as the web process once the build completes, all without you writing a Dockerfile at all. If the repo does ship its own Dockerfile, that gets used directly instead of the automatic path.
The container listens on port 3000 by default for a Next.js repo specifically, matching the port this guide’s Dockerfile exposes. On Hetzner, the server itself can be created directly from Railyard with the region and size you pick at creation time, rather than through the separate Cloud console step in Step 1.
Deploy directly: /deploy/nextjs-to-hetzner. See Deploy from a repository for how the detection and build step works end to end.
Troubleshooting
The build fails with the process getting killed partway through, no clear error. This is almost always out-of-memory during next build on a small server; either resize up temporarily as mentioned in “What you’ll need,” or add swap as a cheaper stopgap (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
HTTPS never issues, Caddy keeps logging a timeout against Let’s Encrypt. Confirm the DNS A record from Step 2 is actually resolving to this server’s IP from outside your network, and that ports 80 and 443 are open in Hetzner’s firewall as well as any ufw rules on the box itself, both have to allow inbound traffic for the ACME challenge to complete.
The app builds and starts, but the page never loads, Caddy times out. Check that HOSTNAME=0.0.0.0 actually made it into the running container (docker compose exec web env); a server listening only on localhost inside its own container network namespace is unreachable from the caddy service even though both containers are up.
NEXT_PUBLIC_ variables show the old value after you changed them. These get inlined into the JavaScript bundle at build time, so changing one requires docker compose up -d --build web again, restarting the container alone without rebuilding does nothing for them.
Port 3000 (or 80) is already in use on the host. Something else is bound to it already, commonly a previous reverse proxy or a leftover container from an earlier attempt. sudo ss -tlnp | grep ':80\|:3000' shows what’s holding it; this guide’s Compose file doesn’t publish 3000 to the host at all (it’s only exposed to the caddy service), so a conflict here usually points at port 80 or 443 instead.
FAQ
Do I need output: "standalone", or does a regular next start work in Docker too? next start works fine in a container, but it needs your full node_modules copied in, which makes a noticeably larger image and a slower deploy; standalone mode is the officially recommended path specifically for containerized deployments.
Can I use pnpm or yarn instead of npm? Yes, the Dockerfile in Step 4 already branches on whichever lockfile is present, no changes needed beyond having that lockfile committed.
What if my app uses next/image optimization? It works with zero configuration on next start, including in standalone mode, as long as the container has the memory to do the on-the-fly image processing; this is a different concern from the build-time memory issue in Troubleshooting above.
Is this safe to run with multiple instances behind a load balancer later? Mostly, but Next.js’s own docs flag a few things to set up first if you do: a shared cache handler instead of the default on-disk one, and a consistent NEXT_SERVER_ACTIONS_ENCRYPTION_KEY across instances if you use Server Actions, otherwise one instance can’t decrypt what another encrypted.
How is this different from deploying to Vercel? Functionally your app runs the same code either way; what you’re trading is Vercel’s managed build and edge network for owning the server, the Dockerfile, and the certificate yourself, which is cheaper at steady traffic and more work at the edges (scaling, image optimization infrastructure, preview environments).
The Dockerfile and Compose file here don’t change much between Next.js projects, the part that actually varies is whatever your app reads from the environment at runtime, worth double-checking against this list before the first real deploy.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.