Next.js auf Hetzner deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Du hast eine Next.js-App und einen Hetzner-Account, und willst, dass die App unter deiner eigenen Domain über HTTPS antwortet, ohne das Ganze einer Plattform zu überlassen, die pro Request abrechnet. Das hier ist die vollständig manuelle Version: ein echter Server, ein Dockerfile, das ein kleines Produktions-Image erzeugt, und Caddy davor für Zertifikate. Am Ende läuft next build im Standalone-Modus in einem Container, ausgeliefert hinter HTTPS, mit einem wiederholbaren Weg zum Neubauen und Neustarten.
Was du brauchst
- Einen Hetzner-Cloud-Account und API-Token.
- Einen Server: Ein CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, 5,99 €/Monat exkl. MwSt.) betreibt eine kleine bis mittlere Next.js-App zur Laufzeit problemlos; falls
next buildselbst bei einer großen App den Speicher aufbraucht, skaliere für den Build vorübergehend auf einen CX33 (4 vCPU, 8 GB RAM, 8,99 €/Monat) hoch und danach wieder herunter, oder baue das Image auf einer separaten Maschine und betreibe es nur auf dem CX23. - Eine Domain oder Subdomain mit einem A-Record, den du auf den Server zeigen lassen kannst.
- Eine Next.js-App mit einem
build- undstart-Skript inpackage.json, auf Node.js 20.9 oder neuer, dem aktuellen Minimum, das Next.js selbst voraussetzt. - SSH-Zugang zum Server.
Schritt 1: Den Server erstellen
Erstelle in der Hetzner-Cloud-Konsole einen Server mit dem Typ cx23 (oder deiner skalierten Wahl) auf Ubuntu 24.04, füge deinen SSH-Key bei der Erstellung hinzu und notiere die öffentliche IP, sobald er läuft. Installiere Docker und das Compose-Plugin per SSH:
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Schritt 2: DNS auf den Server richten
Erstelle einen A-Record für deine Domain oder Subdomain, der auf die IP des Servers zeigt, zum Beispiel app.yourdomain.com. Gib ihm ein paar Minuten zur Verbreitung vor Schritt 6, da Caddy ihn korrekt aufgelöst braucht, um ein Zertifikat zu beantragen.
Schritt 3: Standalone-Output aktivieren
Next.js’ eigener Build enthält nur die Dateien, die eine Seite braucht, wenn du Output File Tracing aktivierst. Füge das zu next.config.js (oder next.config.ts) hinzu:
module.exports = {
output: "standalone",
}
Das lässt next build einen .next/standalone-Ordner erzeugen, der eine minimale server.js und nur die node_modules enthält, die deine App zur Laufzeit tatsächlich braucht, statt den gesamten node_modules-Baum ins Produktions-Image zu kopieren. Der Standalone-Server kopiert deinen public/-Ordner oder .next/static nicht automatisch, beide werden im Dockerfile unten explizit in den Standalone-Output kopiert.
Schritt 4: Das Dockerfile schreiben
Das folgt dem Muster aus Next.js’ eigenem offiziellen Docker-Beispiel, abgespeckt:
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 ist ein aktuelles Node.js-LTS-Release, deutlich über dem heute von Next.js dokumentierten Minimum von 20.9. Die Drei-Stage-Struktur hält Dependency-Installation, den eigentlichen Build und das Runtime-Image getrennt, sodass das finale Image nur enthält, was output: "standalone" als nötig erkannt hat, plus die zwei Ordner, die es nicht von selbst kopiert. USER node lässt den Prozess als den bereits in den offiziellen Node-Images vorhandenen, nicht-root node-User laufen, statt als root, was nichts kostet und es wert ist, standardmäßig zu tun.
Schritt 5: Die Compose-Datei und das Caddyfile schreiben
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 und HOSTNAME sind die zwei Umgebungsvariablen, die Next.js’ standalone server.js direkt liest, HOSTNAME=0.0.0.0 (im Dockerfile gesetzt) ist das, was den Server auf allen Interfaces im Container lauschen lässt statt nur auf localhost, was ihn sonst für den caddy-Service daneben unerreichbar machen würde. Jede Variable, die deine App zur Laufzeit mit process.env liest, im Gegensatz zu einer, die zur Build-Zeit eingebettet wird, kommt ebenfalls in diesen environment:-Block; nur Variablen mit dem Präfix NEXT_PUBLIC_ werden während next build ins Client-Bundle gebacken, alles andere bleibt serverseitig und kann sich ohne Rebuild ändern.
Schritt 6: Deployen
docker compose up -d --build
Der erste Lauf baut das Image, was der langsamste Teil ist, startet dann beide Container, und Caddy beantragt sein Zertifikat. Verfolge die Logs, um zu bestätigen, dass Build und Zertifikatsausstellung beide erfolgreich waren:
docker compose logs -f
Du solltest sehen, dass der Next.js-Server eine Zeile wie Ready in <n>ms loggt, und dass Caddys Log certificate obtained successfully für deine Domain enthält. Besuche https://app.yourdomain.com und bestätige, dass die App über HTTPS mit gültigem Zertifikat lädt.
Für eine spätere Codeänderung baut derselbe Befehl nur das Image neu, das sich geändert hat, und erstellt diesen einen Container neu:
docker compose up -d --build web
Das ist nicht wirklich zero-downtime, Compose stoppt den alten web-Container, bevor der neue startet, es gibt also eine Lücke, die so lange dauert, wie deine App zum Hochfahren und Beantworten des ersten Requests braucht. Falls diese Lücke für deinen Traffic relevant ist, ist die Standardlösung, zwei App-Container hinter Caddys eigenem Load Balancing zu betreiben und sie einen nach dem anderen zu ersetzen, was mehr Infrastruktur ist, als diese Anleitung für ein Single-Server-Setup abdeckt.
Die Abkürzung: mit Railyard
Alles aus Schritt 1 und 3 bis 6 ist genau das, was Railyards eigene Node-Erkennung automatisch macht, wenn du ein Repository deployst statt das Dockerfile selbst zu schreiben. Für ein Repo ohne eigenes Dockerfile liest sie package.json, wählt deinen Paketmanager anhand des vorhandenen Lockfiles (npm, pnpm, yarn oder bun) und führt dein start-Skript als Web-Prozess aus, sobald der Build fertig ist, alles ohne dass du überhaupt ein Dockerfile schreibst. Bringt das Repo sein eigenes Dockerfile mit, wird das direkt genutzt statt des automatischen Pfads.
Der Container lauscht standardmäßig auf Port 3000, speziell für ein Next.js-Repo, passend zu dem Port, den das Dockerfile dieser Anleitung exponiert. Auf Hetzner kann der Server selbst direkt aus Railyard heraus erstellt werden, mit der Region und Größe, die du bei der Erstellung wählst, statt über den separaten Cloud-Konsolen-Schritt aus Schritt 1.
Direkt deployen: /deploy/nextjs-to-hetzner. Siehe Aus einem Repository deployen, wie Erkennung und Build-Schritt insgesamt funktionieren.
Troubleshooting
Der Build schlägt fehl, der Prozess wird mitten im Lauf abgebrochen, kein klarer Fehler. Das ist fast immer Out-of-Memory während next build auf einem kleinen Server; entweder vorübergehend hochskalieren, wie unter “Was du brauchst” erwähnt, oder Swap als günstigeren Notbehelf hinzufügen (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
HTTPS wird nie ausgestellt, Caddy loggt wiederholt einen Timeout gegen Let’s Encrypt. Bestätige, dass der DNS-A-Record aus Schritt 2 tatsächlich auf die IP dieses Servers von außerhalb deines Netzwerks auflöst, und dass Ports 80 und 443 sowohl in Hetzners Firewall als auch in etwaigen ufw-Regeln auf der Box selbst offen sind, beides muss eingehenden Traffic für die Erfüllung der ACME-Challenge erlauben.
Die App baut und startet, aber die Seite lädt nie, Caddy läuft in einen Timeout. Prüfe, dass HOSTNAME=0.0.0.0 tatsächlich im laufenden Container angekommen ist (docker compose exec web env); ein Server, der nur auf localhost innerhalb seines eigenen Container-Network-Namespace lauscht, ist für den caddy-Service unerreichbar, auch wenn beide Container laufen.
NEXT_PUBLIC_-Variablen zeigen den alten Wert, nachdem du sie geändert hast. Diese werden zur Build-Zeit ins JavaScript-Bundle eingebettet, eine Änderung erfordert also erneut docker compose up -d --build web, nur den Container neu zu starten ohne Rebuild bewirkt dafür nichts.
Port 3000 (oder 80) ist auf dem Host schon belegt. Etwas anderes ist schon daran gebunden, häufig ein früherer Reverse Proxy oder ein übriggebliebener Container von einem früheren Versuch. sudo ss -tlnp | grep ':80\|:3000' zeigt, was ihn hält; die Compose-Datei dieser Anleitung publiziert 3000 gar nicht auf den Host (es wird nur dem caddy-Service exposed), ein Konflikt hier deutet also meist eher auf Port 80 oder 443 hin.
FAQ
Brauche ich output: "standalone", oder funktioniert ein normales next start auch in Docker? next start funktioniert einwandfrei in einem Container, braucht aber dein vollständiges node_modules mitkopiert, was ein merklich größeres Image und einen langsameren Deploy ergibt; der Standalone-Modus ist der offiziell empfohlene Weg speziell für containerisierte Deployments.
Kann ich pnpm oder yarn statt npm nutzen? Ja, das Dockerfile aus Schritt 4 verzweigt bereits anhand des vorhandenen Lockfiles, keine Änderungen nötig, außer dass dieses Lockfile committet ist.
Was, wenn meine App die next/image-Optimierung nutzt? Sie funktioniert ohne Konfiguration mit next start, auch im Standalone-Modus, solange der Container genug Speicher für die On-the-fly-Bildverarbeitung hat; das ist ein anderes Thema als das Build-Zeit-Speicherproblem im Troubleshooting oben.
Ist das sicher, um später mit mehreren Instanzen hinter einem Load Balancer zu laufen? Größtenteils, aber Next.js’ eigene Docs weisen auf ein paar Dinge hin, die vorher einzurichten sind, falls du das tust: ein geteilter Cache-Handler statt des Standard-On-Disk-Handlers, und ein konsistenter NEXT_SERVER_ACTIONS_ENCRYPTION_KEY über alle Instanzen hinweg, falls du Server Actions nutzt, sonst kann eine Instanz nicht entschlüsseln, was eine andere verschlüsselt hat.
Wie unterscheidet sich das vom Deployen auf Vercel? Funktional läuft deine App beide Male denselben Code; was du eintauschst, ist Vercels verwalteten Build und Edge-Netzwerk gegen den Besitz von Server, Dockerfile und Zertifikat selbst, was bei stetigem Traffic günstiger und an den Rändern (Skalierung, Image-Optimierungs-Infrastruktur, Preview-Umgebungen) mehr Arbeit ist.
Das Dockerfile und die Compose-Datei hier ändern sich zwischen Next.js-Projekten nicht groß, der Teil, der tatsächlich variiert, ist das, was deine App zur Laufzeit aus der Umgebung liest, es lohnt sich, das vor dem ersten echten Deploy gegen diese Liste zu prüfen.
Warum Leute sich dafür Railyard entscheiden
- Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
- Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
- Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
- Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
- Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.