Artikel

Next.js selbst hosten mit Dokploy und Docker

Wann lohnt es sich, Vercel zu verlassen? Wir schauen uns output standalone, Multi-Stage-Dockerfiles, den Grund, warum Datenbank-Migrationen in den Entrypoint statt in den Build-Schritt gehören, und eine einzige Codezeile an, die verhindert, dass SIGTERM verschluckt wird.

Furkan Özay2. September 2026 · 7 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
CONTAINER & DEVOPS

#Araçlar · 7 MIN LESEZEIT

FURKAN ÖZAYVOL. 2026 // #EC95

Vercel ist für die meisten Projekte die richtige Wahl: keine Konfiguration, automatische Previews und ein globales Edge-Netzwerk. Dieser Beitrag will dich nicht zum Wechsel überreden. Aber wenn der Moment kommt, in dem du wechseln musst – weil die Serverkosten unberechenbar geworden sind, deine Datenbank auf derselben Maschine laufen soll oder die Daten im Land bleiben müssen –, zeigt dir dieser Beitrag, wie eine solche Migration tatsächlich aussieht.

Das Folgende habe ich erlebt, als ich student.czay.dev auf meinem eigenen Server gehostet habe. Der leichte Teil ist, das Docker-Image zu schreiben; der schwierige ist der Startablauf des Containers.

Was macht Dokploy?

Es ist ein selbst gehostetes PaaS: Du verbindest dein Git-Repository, und bei jedem Push baut und deployt es dein Image. Unter der Haube nutzt es Docker und Traefik – Domain-Routing und Let's-Encrypt-SSL-Zertifikate laufen vollautomatisch. Im Kern übernimmt es auf einem einzelnen VPS den Großteil dessen, was Vercel tut.

Erklärung: Was du gewinnst, was du verlierst

Vorteile: Feste, planbare Kosten, Datenbank und App im selben Netzwerk, volle Kontrolle über den Speicherort der Daten und keine Begrenzung der Ausführungszeit.

Nachteile: Kein globales Edge-Netzwerk, die Skalierung liegt bei dir, und Server-Updates und Backups sind deine Verantwortung. Eine einzelne Maschine ist ein Single Point of Failure.

1. output: "standalone"

Ohne diese Zeile wiegt dein Image unnötigerweise mehrere hundert Megabyte.

TypeScript
// next.config.ts
const nextConfig = {
	output: "standalone" as const,
};

Das weist Next.js an, nur die tatsächlich verwendeten node_modules zu tracen und zu bündeln, und erzeugt ein eigenständiges Verzeichnis unter .next/standalone. Du musst nicht mehr den gesamten node_modules-Ordner in dein Runner-Image kopieren.

Bei einem Monorepo brauchst du eine weitere Zeile:

TypeScript
import path from "node:path";
 
const nextConfig = {
	output: "standalone" as const,
	outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};

Wenn du sie weglässt, wird dein Image erfolgreich gebaut, stürzt aber zur Laufzeit mit dem Fehler „module not found“ ab. Der Grund: pnpm hebt Abhängigkeiten in das Wurzelverzeichnis des Repositorys, während Next.js standardmäßig nur im Verzeichnis der Anwendung sucht.

2. Multi-Stage-Dockerfile

Drei Stages: Abhängigkeiten, Build und Runner.

Beispiel: Dependency-Stage

dockerfile
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo
 
FROM base AS deps
# Copy only the manifest files first: if source code changes, this layer
# is served from cache and pnpm install does not run again.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
 
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --filter student...

Zwei Details drücken die Build-Zeit von Minuten auf Sekunden:

  • Kopiere zuerst die Manifeste. Der Layer-Cache von Docker prüft auf Dateiänderungen. Wenn du deinen Quellcode vor pnpm install kopierst, macht jede einzelne Code-Änderung den Cache ungültig und löst eine komplette Neuinstallation aller Abhängigkeiten aus.
  • Verwende --mount=type=cache, um den pnpm-Store über Builds hinweg zu erhalten.

3. Runner-Image: klein und ohne Root-Rechte

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV PORT=3003
ENV HOSTNAME=0.0.0.0
 
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
 
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
 
USER nextjs
EXPOSE 3003

HOSTNAME=0.0.0.0 ist Pflicht. Steht der Wert standardmäßig auf localhost, ist der Server nur von innerhalb des Containers erreichbar, und Traefik kann nicht dorthin routen. Das ist der häufigste Grund hinter dem berüchtigten Fehler „Container läuft, aber ich bekomme einen 502“.

USER nextjs ist genauso wichtig: Wenn Container als Root laufen, bestimmt das, wie schwer ein Einbruch im Falle eines Exploits ausfällt.

Warnung: Ein Fehler, der nur bei einem frischen Clone auftaucht

Mein public/-Ordner war leer, und Git trackt keine leeren Verzeichnisse. Lokal funktionierte alles, weil der Ordner existierte; im frischen Clone auf dem Server fehlte das Verzeichnis aber komplett, wodurch die obige COPY-Anweisung mit „not found“ fehlschlug. Ich habe der Build-Stage eine einzige Zeile hinzugefügt, um das zu beheben:

dockerfile
RUN mkdir -p apps/student/public

Die Differenz zwischen deinem lokalen Workspace und einem frischen Clone erklärt einen guten Teil der Fehler, die beim Deployment auffallen.

4. Die eigentliche Herausforderung: der Startablauf

Wenn deine Anwendung eine Datenbank hat – wann führst du deine Schema-Migrationen aus?

Nicht in der Build-Stage. Während des Builds hast du keinen Datenbankzugriff, und du solltest ihn auch nicht haben; dasselbe Image soll sich gegen jede beliebige Datenbankinstanz deployen lassen. Außerdem verwenden Neustarts, Skalierung und Rollbacks in Dokploy alle dasselbe Image – das Datenbankschema lässt sich nicht fest in das statische Image einbacken.

Der richtige Ansatz ist ein Entrypoint-Skript beim Start:

Beispiel: docker-entrypoint.sh

Bash
#!/bin/sh
set -e
 
cd /app/apps/student
 
echo "[entrypoint] running schema migrations…"
node scripts/migrate.cjs
 
# These two do NOT fail the deployment
set +e
node scripts/seed-content.cjs --dagitim
node scripts/create-admin.cjs --dagitim
set -e
 
echo "[entrypoint] starting server…"
cd /app
exec node apps/student/server.js

Alle drei Entscheidungen hier sind bewusst getroffen:

Welche Fehler sollen das Deployment scheitern lassen? Schlägt die Schema-Migration fehl, darf der Container gar nicht erst starten – stürzt die App auf einem veralteten Schema schon bei der allerersten Anfrage ab, und die Ursache lässt sich schwerer debuggen. Schlagen aber das Seeding der Inhalte oder die Anlage des Admin-Kontos fehl, soll das Deployment weiterlaufen; ein leeres Dashboard ist weiterhin erreichbar, und beides lässt sich später manuell beheben.

set -e / set +e erzwingt diese Trennung: Jeder Fehler im ersten Block stoppt das Skript sofort, während Fehler im zweiten Block ignoriert werden.

Achtung: Ohne exec killt Dokploy den Container

Das exec in der letzten Zeile ist nicht nur Kosmetik. Ohne exec läuft Node als Subprozess des Shell-Skripts, das heißt, das SIGTERM-Signal von Docker geht an die Shell und erreicht den Node-Prozess nie.

Die Folge: Die Stop-Anfrage läuft in ein Timeout, und Docker beendet den Container per SIGKILL. Deine App fährt bei Deployments nie sauber herunter – offene Datenbankverbindungen und hängende Anfragen bleiben im Schwebezustand zurück.

exec ersetzt den Prozess: Node wird PID 1 und empfängt das Signal direkt.

5. Der Healthcheck muss auf die Migrationen warten

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/giris').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

--start-period=40s ist entscheidend: Während die Migrationen laufen, kann die Anwendung nicht antworten. Ohne diese Karenzzeit markiert Dokploy den Container als „unhealthy“ und startet ihn neu, wodurch die Migrationen erneut starten und du in einer Endlosschleife landest.

Auch dass für den Check eine echte Seite abgefragt wird, ist Absicht: Der Root-Pfad könnte eine Weiterleitung zurückgeben, und die beweist nicht, dass die App voll funktionsfähig ist.

6. Umgebungsvariablen: Build-Zeit oder Laufzeit?

Diese Unterscheidung bereitet in Docker deutlich mehr Kopfschmerzen.

Wichtig: NEXT_PUBLIC_ wird zur Build-Zeit eingebacken

Variablen mit dem Präfix NEXT_PUBLIC_ werden zur Build-Zeit in das Client-Bundle eingefügt. Sie dem Container erst zur Laufzeit zu übergeben, hat überhaupt keine Wirkung – das kompilierte JavaScript im Image bleibt bei seinen alten Werten.

Du musst sie in Dokploy als Build-Argumente (Build Args) übergeben. Wenn du sie änderst, musst du einen vollständigen Rebuild auslösen; ein einfacher Neustart reicht nicht.

Umgekehrt müssen serverseitige Variablen (DATABASE_URL, API-Keys) zur Laufzeit bereitgestellt werden und dürfen niemals ins Image gelangen.

Deshalb ist die .dockerignore so wichtig:

plaintext
node_modules
**/node_modules
**/.next
**/.turbo
.git
 
# Environment files are not included in the image
.env
.env.*
**/.env
**/.env.*
!**/.env.example

Wenn du deine .env-Datei ins Image kopierst, werden deine Secrets in die Image-Layer eingebacken – sie bleiben also in der Registry-Historie erhalten, selbst wenn du das finale Image löschst.

Checkliste

Zusammenfassung: Vor dem Launch

  • Ist output: "standalone" aktiviert? Und outputFileTracingRoot, falls du ein Monorepo hast?
  • Ist HOSTNAME=0.0.0.0 gesetzt? (Sonst bekommst du einen 502)
  • Läuft der Container als Root? (Das sollte er nicht)
  • Laufen die Schema-Migrationen beim Start oder beim Build? (Sollte beim Start sein)
  • Verwendet die letzte Zeile exec? (Sonst erwartet dich bei jedem Deploy ein SIGKILL)
  • Ist --start-period lang genug, damit deine Migrationen fertig werden?
  • Werden die NEXT_PUBLIC_*-Variablen als Build Args übergeben?
  • Steht .env in der .dockerignore?
  • Hast du Datenbank-Backups eingerichtet? (Auf Vercel war das nicht deine Aufgabe, jetzt schon)

Der letzte Punkt wird am häufigsten übersehen. Der Umzug auf den eigenen Server senkt die Kosten, legt aber die ganze Verantwortung auf deine Schultern: Updates, Backups und Monitoring sind jetzt dein Job. Wenn dir dafür die Kapazität fehlt, ist der Verbleib bei Vercel tatsächlich günstiger – nicht in Dollar, sondern in Zeit.

Verwandter Beitrag: Monorepo-Verwaltung mit Turborepo

Interaktive Roadmap & Tool

Als Nächstes lesen