Modul 12

Deployment und Self-Hosting

Herzlichen Glückwunsch – du bist beim letzten Modul angekommen. Die DevNotes-App läuft lokal, hat eine echte Datenbank, Authentifizierung, Server Actions und Tests. Jetzt fehlt noch der letzte Schritt: die App in Produktion bringen, damit sie nicht nur auf deinem Laptop läuft.

Deployment ist der Moment, an dem alles zusammenkommt. Ein kleiner Konfigurationsfehler – ein fehlender NEXT_PUBLIC_-Prefix, ein falsches Datenbankpasswort, eine fehlende Migration – und die App ist stumm kaputt. Dieses Modul zeigt dir, wie du diese Fallen vermeidest.


Deployment-Optionen

Next.js ist kein an Heroku gebundenes Framework. Du hast echte Wahl:

Vercel ist die einfachste Option – es ist der Hoster, den Vercel (die Firma hinter Next.js) selbst betreibt. git push, und die App ist live. Automatisches HTTPS, globales CDN, Serverless Functions. Perfekt für Prototypen und kleinere Projekte. Die Grenze: kein persistentes Filesystem, und sobald du eine eigene Postgres-Instanz willst, bezahlst du schnell mehr, als du erwartest.

Node.js-Server – du baust die App mit npm run build, startest sie mit node .next/standalone/server.js. Maximal flexibel, aber du verwaltest alles selbst: Reverse Proxy, TLS, Prozessmanager (PM2 oder systemd), Updates.

Docker ist der goldene Mittelweg für Self-Hosting. Das Image ist reproduzierbar, läuft überall, und du kannst es mit einem Orchestrator wie Coolify, Portainer oder Kubernetes steuern. Das ist der Weg, den dieses Modul beschreibt.

Rails-Analogie
Ruby on Rails
# Heroku (managed, einfachstes):
git push heroku main

# Kamal (Docker, self-hosted):
kamal deploy

# Capistrano (klassisch, bare-metal):
bundle exec cap production deploy
Next.js
# Vercel (managed, einfachstes):
git push  # Vercel erkennt Next.js automatisch

# Docker + Coolify (self-hosted):
docker build -t devnotes .
docker push registry.example.com/devnotes

# Node.js (bare-metal):
npm run build && node .next/standalone/server.js

Coolify ist dabei besonders interessant für Rails-Entwickler: Es ist eine Open-Source-Alternative zu Heroku, die du auf deinem eigenen VPS (z. B. Hetzner, DigitalOcean) installierst. Push-to-deploy, automatisches HTTPS über Let's Encrypt, eingebaute Datenbankservices, Secrets-Management – alles was Heroku kann, aber auf deiner eigenen Infrastruktur.


output: 'standalone'

Bevor du ein Docker-Image baust, musst du Next.js sagen, dass es ein eigenständiges Bundle produzieren soll. In der next.config.ts findest du bereits:

const nextConfig: NextConfig = {
  output: "standalone",
};

Was passiert hier genau? Ohne diese Option erzeugt npm run build einen .next-Ordner, der auf node_modules im Projektverzeichnis angewiesen ist. Das bedeutet: beim Deployment musst du alle Dependencies mitschleppen – in einem typischen Next.js-Projekt leicht 400–600 MB.

Mit output: "standalone" analysiert Next.js beim Build-Schritt den gesamten Dependency-Graph und kopiert nur die Pakete, die zur Laufzeit tatsächlich gebraucht werden, in .next/standalone/node_modules. Das Ergebnis ist ein in sich geschlossenes Verzeichnis:

.next/
  standalone/
    node_modules/   ← nur Produktions-Dependencies, tree-shaked
    server.js       ← der eigentliche Einstiegspunkt
    .next/          ← kompiliertes Bundle

Ein Docker-Image mit diesem Ansatz kommt typischerweise auf 100–150 MB statt 500+ MB. Das ist der Unterschied zwischen einer VM, die 30 Sekunden zum Starten braucht, und einem Container, der in 3 Sekunden läuft.

Wichtig: Die statischen Assets (CSS, JS für den Browser, Bilder) landen nicht automatisch im standalone-Ordner. Deshalb kopiert das Dockerfile public/ und .next/static/ manuell hinein – mehr dazu gleich.


Environment Variables

Eines der häufigsten Deployment-Probleme ist falsch konfigurierte Umgebungsvariablen. Next.js hat hier ein klares System – aber es muss verstanden werden.

Drei Dateien, drei Kontexte

| Datei | Verwendet bei | Committen? | |---|---|---| | .env | Alle Umgebungen, Defaults | Ja (keine Secrets) | | .env.local | Lokal, wird von .env überschrieben | Nein – in .gitignore | | .env.production | Nur beim NODE_ENV=production-Build | Nein (Secrets) |

Im Produktions-Deployment setzt du Umgebungsvariablen direkt in der Laufzeitumgebung – also als Docker-Environment-Variablen, Coolify-Secrets oder Kubernetes-Secrets. Dateien im Dateisystem sind für Secrets unpraktisch.

Das NEXT_PUBLIC_-Prefix – kritisch!

Next.js unterscheidet streng zwischen Server- und Client-Code. Diese Unterscheidung gilt auch für Umgebungsvariablen:

# ✅ Nur auf dem Server sichtbar (Server Components, API Routes, Server Actions)
DATABASE_URL=postgresql://...
SESSION_SECRET=very-secret-key-min-32-chars

# ✅ Auf dem Server UND im Browser sichtbar (wird ins Client-Bundle eingebettet!)
NEXT_PUBLIC_APP_URL=https://devnotes.example.com
NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/123

Ohne das Prefix gibt process.env.DATABASE_URL im Browser-Code undefined zurück – kein Fehler, keine Warnung, einfach nichts. Mit NEXT_PUBLIC_ wird der Wert zur Build-Zeit in den JavaScript-Bundle eingebettet und ist für jeden Nutzer lesbar.

Die Konsequenz: Datenbankverbindungsstrings und Session-Secrets dürfen niemals das NEXT_PUBLIC_-Prefix erhalten. Sie wären sonst für jeden Nutzer im Browser sichtbar.

Rails-Analogie
Ruby on Rails
# config/credentials.yml.enc (verschlüsselt, committet)
# Zugriff:
Rails.application.credentials.database[:password]
Rails.application.credentials.session_key

# .env (dotenv-gem, lokal):
DATABASE_URL=postgresql://...
# Zugriff im Code:
ENV["DATABASE_URL"]
Next.js
# .env.local (lokal, nicht committet):
DATABASE_URL=postgresql://...
SESSION_SECRET=abc123...

# Zugriff im Server-Code:
process.env.DATABASE_URL    // ✅ server-only
process.env.SESSION_SECRET  // ✅ server-only

# Im Client-Code erreichbar (ins Bundle eingebettet):
process.env.NEXT_PUBLIC_APP_URL  // ⚠️ öffentlich!

Das Dockerfile erklärt

Das Dockerfile im Projektverzeichnis verwendet einen mehrstufigen Build (multi-stage build). Das ist der Best-Practice-Ansatz für Node.js-Apps in Produktion: jede Stufe hat eine klare Aufgabe, und das finale Image enthält nur das Nötigste.

# ── Stage 1: install dependencies ─────────────────────────────────────────────
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

Stage deps: Nur package.json und package-lock.json werden kopiert, dann npm ci. Docker cached diese Schicht – solange sich die Lockfile nicht ändert, wird npm install nicht wiederholt. Das spart bei jedem Rebuild Minuten.

# ── Stage 2: build the application ────────────────────────────────────────────
FROM node:24-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1

# Generate Prisma client before building
RUN npx prisma generate
RUN npm run build

Stage builder: Hier passiert die eigentliche Arbeit. NEXT_TELEMETRY_DISABLED=1 verhindert, dass der Build Telemetrie-Daten an Vercel sendet – sinnvoll für Offline-Umgebungen und aus Datenschutzgründen. prisma generate muss vor npm run build laufen, weil Next.js beim Build Prisma-Typen importiert.

# ── Stage 3: production runner ─────────────────────────────────────────────────
FROM node:24-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Copy standalone output and static assets
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# Non-root user for security
RUN addgroup -g 1001 nodejs && adduser -S nextjs -u 1001
USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Stage runner: Das finale Image. Es basiert auf node:24-alpine (kein Build-Tooling, kein npm, kein Compiler) und enthält nur:

  • .next/standalone/ – der Tree-shaked Server-Code
  • .next/static/ – CSS, JS-Chunks, Fonts für den Browser
  • public/ – statische Assets wie SVGs und Bilder

Der non-root User (nextjs) ist wichtig: sollte ein Angreifer den Node.js-Prozess kompromittieren, hat er keine Root-Rechte im Container.

HOSTNAME="0.0.0.0" sorgt dafür, dass der Server auf allen Netzwerkinterfaces lauscht – ohne das ist er innerhalb des Docker-Netzwerks nicht erreichbar.


docker-compose.yml

Für das lokale Testen und für einfache Deployments ohne Kubernetes reicht eine docker-compose.yml. Das Projekt hat bereits eine:

services:
  # ── PostgreSQL database ───────────────────────────────────────────────────
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: next_tutorial
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  # ── Run Prisma migrations (one-shot) ──────────────────────────────────────
  migrate:
    image: node:24-alpine
    working_dir: /app
    volumes:
      - .:/app
    command: sh -c "npm ci && npx prisma migrate deploy"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/next_tutorial?schema=public
    depends_on:
      db:
        condition: service_healthy

  # ── Next.js application ───────────────────────────────────────────────────
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/next_tutorial?schema=public
      SESSION_SECRET: ${SESSION_SECRET}
      NODE_ENV: production
    depends_on:
      migrate:
        condition: service_completed_successfully

volumes:
  postgres_data:

Drei Punkte verdienen Aufmerksamkeit:

Reihenfolge über depends_on: Docker Compose startet Services zwar in der definierten Reihenfolge, aber "gestartet" bedeutet nicht "bereit". Der healthcheck auf dem db-Service prüft mit pg_isready, ob PostgreSQL tatsächlich Verbindungen akzeptiert. Erst dann startet migrate. Erst wenn migrate mit Exit-Code 0 abgeschlossen hat (service_completed_successfully), startet app. Ohne diese Kaskade würde die App häufig mit "Connection refused" crashen.

Port 5433 statt 5432: Falls auf deinem Entwicklungsrechner eine lokale PostgreSQL-Instanz auf 5432 läuft (oder in einem anderen Docker-Container), vermeidet 5433:5432 einen Portkonflikt. Im Docker-internen Netzwerk kommunizieren die Services trotzdem über db:5432.

SESSION_SECRET aus der Umgebung: ${SESSION_SECRET} wird zur Laufzeit aus deiner Shell-Umgebung oder einer .env-Datei gelesen. Du brauchst also eine .env-Datei mit diesem Wert, bevor du docker compose up ausführst.


Datenbank-Migrationen beim Deploy

Rails-Entwickler kennen das Ritual:

Rails-Analogie
Ruby on Rails
# Rails: Migrations in Produktion ausführen
bundle exec rails db:migrate RAILS_ENV=production

# Oder via Kamal nach dem Deploy:
kamal app exec 'bin/rails db:migrate'
Next.js
# Prisma: Migrations in Produktion ausführen
npx prisma migrate deploy

# Wichtig: NICHT prisma migrate dev!
# migrate dev ist für die lokale Entwicklung.
# migrate deploy wendet ausstehende Migrations an
# ohne interaktive Nachfragen.

Der Unterschied zwischen prisma migrate dev und prisma migrate deploy ist entscheidend:

  • migrate dev – für lokale Entwicklung. Erstellt neue Migrationsdateien aus Schema-Änderungen, fragt nach, kann Daten zurücksetzen. Nie in Produktion verwenden.
  • migrate deploy – für Produktion. Wendet nur bereits existierende Migrationsdateien an, die noch nicht auf der Datenbank sind. Nicht-interaktiv, sicher.

Im docker-compose.yml übernimmt der migrate-Service diese Aufgabe automatisch. Bei einem Deployment mit Coolify oder Kubernetes kann dieselbe Logik als Init-Container oder Pre-Deploy-Hook konfiguriert werden.

Sicherheitsnetz: migrate deploy ist idempotent. Es wendet nur ausstehende Migrationen an. Wenn du es zweimal ausführst, passiert beim zweiten Mal nichts.


Self-Hosting mit Coolify

Coolify ist ein Open-Source PaaS (Platform as a Service), das du auf einem eigenen Server installierst und das Heroku-ähnliche Features bietet: automatisches HTTPS, Git-Webhooks für automatische Deploys, eingebaute Datenbankservices, Reverse Proxy, Log-Streaming.

Voraussetzungen

  • Einen Linux-VPS (Hetzner CX22, DigitalOcean Droplet, etc.) mit mindestens 2 GB RAM
  • Docker auf dem Server installiert
  • Coolify installiert (curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash)
  • Das Projekt in einem Git-Repository (GitHub, GitLab, Gitea)

Schritt-für-Schritt

1. Repository verbinden

Im Coolify-Dashboard: New ResourceApplicationGitHub / GitLab (oder öffentliches Git-Repository). Wähle dein Repository und den Branch, der deployed werden soll (typischerweise main).

2. Build-Konfiguration

Coolify erkennt das Dockerfile automatisch. Stelle sicher:

  • Build Pack: Dockerfile (nicht Nixpacks oder andere Autodetection)
  • Dockerfile-Pfad: ./Dockerfile
  • Port: 3000

3. Umgebungsvariablen setzen

Im Tab Environment Variables des Coolify-Projekts:

DATABASE_URL=postgresql://postgres:SICHERES_PASSWORT@devnotes-db:5432/devnotes
SESSION_SECRET=mindestens-32-zeichen-zufaelliger-string
NODE_ENV=production

Coolify injiziert diese Variablen zur Laufzeit in den Container – sie stehen nie im Docker-Image.

4. PostgreSQL-Service hinzufügen

Im Coolify-Dashboard: New ResourceDatabasePostgreSQL. Gib der Datenbank einen Namen (z. B. devnotes-db). Coolify gibt dir die interne Adresse zurück – die verwendest du als DATABASE_URL.

5. Deploy

Deploy-Button klicken. Coolify:

  1. Klont das Repository
  2. Baut das Docker-Image via docker build
  3. Startet den Container mit den konfigurierten Umgebungsvariablen
  4. Konfiguriert den Reverse Proxy (Traefik/Caddy) mit automatischem TLS

Nach dem ersten Deploy: Coolify kann so konfiguriert werden, dass jeder Push auf main automatisch ein neues Deployment auslöst. In den Repository-Einstellungen aktivierst du dazu den Webhook.


Production-Checkliste

Bevor du die URL an jemanden schickst, geh diese Liste durch:

  • SESSION_SECRET gesetzt – Mindestens 32 zufällige Zeichen. Niemals ein sprechendes Passwort wie my-app-secret. Generiere es mit openssl rand -base64 32.
  • DATABASE_URL korrekt – Zeigt auf die Produktionsdatenbank, nicht auf localhost. Verbindung von der App aus testen.
  • NEXT_PUBLIC_-Variablen prüfen – Enthält keiner davon einen Secret-Wert? API-Keys für Server-Dienste müssen ohne dieses Prefix bleiben.
  • HTTPS erzwungen – Kein HTTP in Produktion. Coolify konfiguriert das automatisch; auf anderen Deployments sicherstellen, dass der Reverse Proxy (nginx/Caddy) auf Port 443 hört und Port 80 redirectet.
  • npm run build schlägt nicht fehl – Baue lokal mit NODE_ENV=production npm run build, bevor du deployest. TypeScript-Fehler, die lokal durch ts-ignore übersehen wurden, schlagen hier an.
  • Migrationen wurden ausgeführt – Nach dem Deploy die Logs prüfen oder prisma migrate deploy manuell ausführen.
  • .env.local nicht committetgit status und .gitignore überprüfen. Credentials gehören niemals ins Repository.

Lokal mit Docker testen

Vor dem echten Deployment lohnt es sich, das Setup lokal mit Docker zu testen. Das deckt Fehler auf, die im Entwicklungsserver nicht sichtbar sind – z. B. fehlende Produktions-Dependencies, falsche Pfade oder Environment-Variable-Probleme.

Erstelle zuerst eine .env-Datei (oder setze die Variable in deiner Shell):

# .env (lokal, nicht committen wenn echte Secrets drin sind)
SESSION_SECRET=local-test-secret-min-32-chars-long!!

Dann:

# Image bauen und alle Services starten
docker compose up --build

# Oder im Hintergrund:
docker compose up --build -d

# Logs beobachten:
docker compose logs -f app

# Alles stoppen und aufräumen:
docker compose down

Wenn der Build erfolgreich war und alle Services gestartet sind, öffne http://localhost:3000. Du siehst die DevNotes-App – exakt wie sie in Produktion laufen würde.

Typische Fehler beim ersten Versuch:

  • Error: NEXT_TELEMETRY_DISABLED – Kein echter Fehler, nur eine Info. Ignorieren.
  • PrismaClientInitializationError: Can't reach database server – Der migrate-Service ist noch nicht fertig. docker compose logs migrate zeigt den Fortschritt.
  • Error: SESSION_SECRET not set – Die .env-Datei fehlt oder enthält den Wert nicht.

App mit Docker bauen und starten

Stelle sicher, dass Docker auf deinem Rechner läuft (docker ps sollte ohne Fehler antworten).

  1. Erstelle eine .env-Datei im Projektverzeichnis:
echo "SESSION_SECRET=$(openssl rand -base64 32)" > .env
  1. Baue die App lokal (prüft TypeScript und erstellt das Standalone-Bundle):
npm run build
  1. Starte alle Services mit Docker Compose:
docker compose up --build
  1. Warte bis du ✓ Ready on http://0.0.0.0:3000 in den Logs siehst. Öffne dann http://localhost:3000.

  2. Registriere einen Nutzer, erstelle eine Notiz – alles sollte wie in der Entwicklungsumgebung funktionieren, aber jetzt aus einem Production-Docker-Image heraus.

  3. Schau dir die Image-Größe an:

docker images | grep next-tutorial

Das finale Image sollte deutlich kleiner sein als ein naiver node:24-Build mit node_modules.


Im Capstone: DevNotes

Der Deployment-Schritt für DevNotes ist im Wesentlichen das, was dieses Modul beschreibt: Das Dockerfile und die docker-compose.yml liegen bereits im Projekt. Mit einem konfigurierten Coolify-Server und dem Git-Repository brauchst du nur noch:

  1. Die Umgebungsvariablen (DATABASE_URL, SESSION_SECRET) in Coolify als Secrets hinterlegen
  2. Den ersten Deploy anstoßen
  3. Den PostgreSQL-Service in Coolify verbinden
  4. Den automatischen Deploy-Webhook aktivieren

Von diesem Punkt an ist jedes git push auf main ein Deployment. Genau wie bei Heroku – nur auf deiner eigenen Infrastruktur.


Herzlichen Glückwunsch!

Du hast alle 13 Module des Tutorials abgeschlossen. Lass uns kurz innehalten und schauen, was du gebaut und gelernt hast.

Was du gelernt hast

In M0 hast du die Entwicklungsumgebung aufgesetzt und die Projektstruktur von Next.js verstanden – Dateisystem-Routing, Konfigurationsdateien, den Unterschied zu einer klassischen Rails-App-Struktur.

In M1 kam der React- und TypeScript-Crashkurs: JSX-Syntax, funktionale Komponenten, Props, die wichtigsten Hooks – und warum Server Components so fundamental anders sind als Rails-Partials.

M2 zeigte dir das File-System-Routing des App Routers: Layout-Dateien, dynamische Segmente, verschachtelte Layouts. Das Konzept, dass eine Datei im Verzeichnis gleich eine Route ist, ist einfach – aber mächtig.

In M3 hast du das Kernkonzept von Next.js 13+ verstanden: die Trennung zwischen Server Components (rendern nur auf dem Server) und Client Components (rendern auch im Browser). Diese Unterscheidung zieht sich durch jede Entscheidung im App Router.

M4 brachte Data Fetching: async/await direkt in Server Components, Suspense Boundaries für Streaming, parallele Requests mit Promise.all. Kein getServerSideProps mehr, kein separater API-Layer nötig.

In M5 hast du Prisma als TypeScript-natives ORM kennengelernt – Schema-Definition, Migrationen, den generierten Client. Das Rails-ActiveRecord-Äquivalent für die Node.js-Welt.

M6 war Server Actions: Formulare, die direkt eine TypeScript-Funktion auf dem Server aufrufen, ohne manuellen API-Endpunkt. Progressive Enhancement inklusive.

In M7 hast du das mehrstufige Caching-System von Next.js verstanden: Request-Memoization, Data Cache, Full Route Cache – und wie man mit revalidatePath und revalidateTag gezielt invalidiert.

M8 brachte Tailwind v4, optimierte Bilder mit next/image und webfont-Laden ohne Layout Shift via next/font.

In M9 hast du Session-basierte Authentifizierung mit iron-session implementiert: Login, Logout, geschützte Routen via Middleware.

M10 zeigte Route Handlers: klassische REST-Endpunkte im App Router, die du brauchst, wenn externe Clients (Mobile Apps, Drittdienste) deine App ansprechen sollen.

In M11 hast du das Testing-Dreieck kennengelernt: Unit-Tests mit Vitest, Komponenten-Tests mit React Testing Library, End-to-End-Tests mit Playwright.

Und in diesem Modul, M12, bist du vom lokalen Entwicklungsserver bis zum produktiven Self-Hosted-Deployment gegangen.

Was als nächstes kommt

Dieses Tutorial hat die fundamentalen Konzepte abgedeckt. Es gibt noch mehr zu entdecken:

Auth.js (NextAuth) – Für Produktionsanwendungen mit OAuth (GitHub, Google, etc.) ist Auth.js die De-facto-Standardlösung. Es baut auf denselben Konzepten auf, die du in M9 gelernt hast.

tRPC – Type-safe APIs ohne Code-Generierung. Wenn dein Frontend und Backend in einem Repository leben (was bei Next.js der Fall ist), gibt tRPC dir end-to-end Typensicherheit vom Datenbankmodell bis zur React-Komponente.

Weitere Next.js-Patterns – Parallel Routes für Modals ohne Navigations-State, Intercepting Routes für Instagram-ähnliche Overlay-Galerien, Server Actions mit Optimistic Updates für instant-responsive UIs.

Edge Runtime – Middleware und Route Handlers können optional auf Cloudflare Workers oder Vercel Edge laufen – globale Latenz unter 10ms, aber eingeschränkte Node.js-APIs.


Die DevNotes-App wartet auf dich: Öffne /notes und erstelle deine erste Notiz in der Production-Version.

Du hast jetzt das Handwerkszeug, um professionelle Next.js-Anwendungen zu bauen, zu testen und zu deployen. Viel Erfolg.