Nuxt auf Hetzner deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Du hast eine Nuxt-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 Nuxts Nitro-Server 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 Nuxt-App zur Laufzeit problemlos; falls dem Build selbst bei einer großen App der Speicher ausgeht, 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 Nuxt-App mit einem
build-Skript inpackage.json, auf einem aktuellen Nuxt-Release (4.6.2 zum Zeitpunkt dieses Schreibens), der mindestens Node 20.19+ oder 22.12+ braucht. - 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, und bestätige dann, dass er korrekt aufgelöst wird, bevor du zu Schritt 6 übergehst, da Caddy das braucht, um ein Zertifikat zu beantragen:
dig +short app.yourdomain.com
Schritt 3: Nuxts Build-Output
nuxt build (oder nuxi build) auszuführen erzeugt ein .output-Verzeichnis mit zwei Teilen: .output/public für statische Assets, und .output/server/index.mjs als Node-Einstiegspunkt. Dieser Einstiegspunkt ist Nitros “node-server”-Preset, das Nuxts Standard-Preset ist, wenn kein anderes Deployment-Ziel konfiguriert ist, es gibt hier also kein Äquivalent zu Next.js’ explizitem output: "standalone"-Opt-in, ein einfaches nuxt build liefert dir bereits einen eigenständigen Node-Server zum Ausführen.
Das heißt aber auch: “Standard” bedeutet, dass es sich ändern kann, falls sich Nuxts Standardwerte je ändern. Für einen Produktions-Deploy lohnt es sich, ihn explizit in nuxt.config.ts festzulegen, damit ein künftiges Nuxt-Upgrade dein Deployment-Ziel nicht still unter dir wechseln kann:
export default defineNuxtConfig({
nitro: {
preset: "node-server",
},
})
node .output/server/index.mjs auszuführen startet den Produktionsserver direkt, genau das, was das Dockerfile unten tut.
Schritt 4: Das Dockerfile schreiben
ARG NODE_VERSION=24-alpine
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 . .
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
Das ist einfacher als das entsprechende Next.js-Setup, bei dem der Standalone-Output seine nachverfolgten node_modules plus die separat kopierten Ordner public/ und .next/static braucht. Nitros .output/server bündelt bereits seine eigenen minimalen node_modules als Teil des Builds in sich selbst, die Runner-Stage muss also nur das eine .output-Verzeichnis kopieren, nichts anderes aus der Builder-Stage wird zur Laufzeit benötigt. node:24-alpine ist ein aktuelles Node.js-LTS-Image, deutlich über den heute von Nuxt 4 dokumentierten Minima von 20.19/22.12, und die kleinere Alpine-Basis hält das finale Image schlank, da in dieser Stage kein nativer Build-Schritt passiert. USER node lässt den Prozess als den bereits in den offiziellen Node-Images vorhandenen, nicht-root node-User laufen statt als root.
Schritt 5: Die Compose-Datei und das Caddyfile schreiben
services:
web:
build: .
restart: unless-stopped
environment:
NODE_ENV: production
HOST: "0.0.0.0"
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
}
HOST und PORT (oder ihre Äquivalente NITRO_HOST/NITRO_PORT) sind das, was Nitros node-server aus der Umgebung liest, um zu entscheiden, worauf gebunden wird. HOST=0.0.0.0 explizit zu setzen, statt sich auf Nitros eigenen Standardwert zu verlassen, ist das, was den Server für den caddy-Container daneben erreichbar macht, dieselbe Logik wie HOSTNAME=0.0.0.0 in der Next.js-Anleitung, nur unter Nitros eigenen Variablennamen. Jeder Wert, den du zur Laufzeit mit useRuntimeConfig() liest, kommt in diesen environment:-Block; nur Variablen mit dem Präfix NUXT_PUBLIC_ werden während des Builds ins Client-Bundle eingebettet, alles andere bleibt serverseitig und kann sich ohne Neubau des Images ändern.
Validiere vor dem Deployen beide Dateien lokal. Für die Compose-Datei:
docker compose -f compose.yml config
Und für das Caddyfile:
docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile
Beide sollten Erfolg melden, bevor du sie auf den Server kopierst.
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 Nitro eine Zeile wie Listening on http://0.0.0.0:3000 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, oder prüfe es direkt vom Server:
curl -I https://app.yourdomain.com
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 Nitro 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
Nuxt hat keinen eigenen dedizierten Framework-Eintrag in Railyards Guide-System, nur das generische “Node.js” hat einen, weil die darunter liegende Erkennung nicht wissen muss, dass es speziell Nuxt ist. Sie liest 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, derselbe Weg, den ein Nuxt-Repo nimmt wie jedes andere Node.js-Repo. Bringt das Repo sein eigenes Dockerfile mit, wie das aus Schritt 4, wird das direkt genutzt statt des automatischen Pfads. In beiden Fällen wird erwartet, dass die App auf dem von Railyard zugewiesenen $PORT lauscht, dieselbe Anforderung, die Schritt 4s explizite HOST/PORT-Behandlung von Hand erfüllt.
Direkt deployen: /deploy/nodejs-to-hetzner. Siehe /docs/node für die Node.js-Framework-Referenz, die abdeckt, wie die Build- und Start-Erkennung für jedes node-basierte Repo funktioniert, Nuxt eingeschlossen.
Railyards Free-Plan deckt 1 Server plus einen Datenbankserver und 3 Apps ab, ohne Kreditkarte, Pro kostet 19 $/Monat pro Team, und Team kostet 49 $/Monat pro Team.
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 des Builds 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).
Die App baut und startet, aber die Seite lädt nie, Caddy läuft in einen Timeout. Prüfe, dass HOST=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. Das ist speziell bei einem Nitro-node-server-Deploy der häufigste Fehler, da Nitros eigene Standardwerte nicht garantieren, auf allen Interfaces zu binden.
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.
Port 3000 (oder 80) ist auf dem Host schon belegt. Etwas anderes ist schon daran gebunden, häufig eine Standard-nginx-Installation, die von einem Base-Image übriggeblieben ist, 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.
Eine Umgebungsvariable zeigt den alten Wert, nachdem du sie geändert hast. Alles, was mit useRuntimeConfig() gelesen wird, wird beim Start des Containers aufgelöst, es zu ändern und den Container neu zu starten reicht also, kein Rebuild nötig. Alles mit dem Präfix NUXT_PUBLIC_ wird dagegen während des Builds selbst ins client-seitige JavaScript eingebettet, eine Änderung davon erfordert also erneut docker compose up -d --build web, ein bloßer Neustart bewirkt dafür nichts.
FAQ
Muss ich nitro: { preset: "node-server" } explizit setzen, oder reicht der Standard? Der Standard ist funktional in Ordnung, node-server ist bereits das, was man ohne Konfiguration bekommt. Es explizit festzulegen ist eine kleine Versicherung für die Produktion, damit ein künftiges Nuxt-Release, das seinen Standard-Preset ändert, nicht gleichzeitig dein Deployment-Ziel ändert.
Kann ich pnpm, yarn oder bun 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. (Füge einen bun.lockb-Zweig hinzu, falls du speziell Bun nutzt, das obige Beispiel deckt die drei gängigsten Lockfiles ab.)
Was ist mit Nuxts Server-Routen und API-Routen, laufen die in diesem selben Container? Ja, alles unter server/api oder server/routes in deiner Nuxt-App wird in dieselbe .output/server/index.mjs gebündelt und läuft im selben Prozess wie deine Seiten, es gibt kein separates Backend zu deployen, außer du hast bewusst eines abgespalten.
Ist das sicher, um später mit mehreren Instanzen hinter einem Load Balancer zu laufen? Größtenteils, der Nitro-node-server ist standardmäßig stateless, mehrere Container hinter Caddys Load Balancing zu betreiben funktioniert also ohne besondere Konfiguration. Der übliche Vorbehalt gilt, falls deine App irgendeinen In-Memory-State über Requests hinweg hält (Rate-Limiting, In-Process-Caching), der auf etwas Geteiltes wie Redis umziehen müsste, sobald du mehr als eine Instanz betreibst.
Wie unterscheidet sich das vom Deployen auf Vercel oder Netlify? Funktional läuft deine App beide Male denselben Code, Nuxts Build-Output ist absichtlich portabel. Was du eintauschst, ist eine verwaltete Build-Pipeline und ein Edge-Netzwerk gegen den Besitz von Server, Dockerfile und Zertifikat selbst, was bei stetigem Traffic günstiger und an den Rändern (Skalierung, Image-Hosting, Preview-Umgebungen) mehr Arbeit ist.
Das Dockerfile und die Compose-Datei hier ändern sich zwischen Nuxt-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.