Eine Next.js-App von Vercel auf deinen eigenen Server umziehen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Deine Next.js-App läuft auf Vercel, und du willst sie auf einem Server, den du kontrollierst, mit einer festen monatlichen Rechnung. Der Code ändert sich kaum. Was sich ändert, ist alles, was Vercel um ihn herum erledigt hat: bei jedem Push bauen, deine Umgebungsvariablen vorhalten, ein paar eigene Variablen setzen, Cron-Jobs ausführen und HTTPS bereitstellen. Diese Anleitung verschiebt jedes dieser Teile auf einen einzigen Server, stellt dann DNS ohne Downtime um und behält das Vercel-Deployment als Fallback, bis sich das neue bewährt hat.
Was du brauchst
- Einen Server. Diese Anleitung nutzt einen DigitalOcean Basic Droplet,
s-1vcpu-2gb(1 vCPU, 2 GB RAM, 50 GB SSD), für 12 $ im Monat. Falls dein Build speicherhungrig ist, kostets-2vcpu-4gb24 $ im Monat. Jeder Ubuntu-24.04-Server mit öffentlicher IP funktioniert genauso. - Zugriff auf das DNS der Domain, egal wo es heute gehostet ist (Vercels Nameserver oder dein Registrar).
- Die Vercel-CLI auf deiner eigenen Maschine (
npm i -g vercel), eingeloggt in das Team, dem das Projekt gehört. - Das Repository, aus dem Vercel deployt, mit eingechecktem Lockfile.
Die Befehle gehen von Next.js 16 (16.4.3 zum Zeitpunkt des Schreibens) und Node.js 24, der aktuellen LTS-Linie, aus.
Schritt 1: Auflisten, was Vercel für dich erledigt
Bevor du den Server berührst, prüfe das Projekt auf Teile, die von Vercel selbst abhängen:
grep -rn "VERCEL_" --include=*.ts --include=*.tsx --include=*.js --include=*.mjs . | grep -v node_modules
cat vercel.json 2>/dev/null
Typischerweise findest du einige davon:
- System-Umgebungsvariablen. Vercel setzt
VERCEL_ENV,VERCEL_URL,VERCEL_PROJECT_PRODUCTION_URL,VERCEL_GIT_COMMIT_SHAund andere bei jedem Deployment. Keine davon existiert auf deinem Server. Schritt 3 ersetzt die, die dein Code liest. - Cron-Jobs, definiert unter
cronsinvercel.json. Vercel löst sie durch einen GET-Request auf den angegebenen Pfad aus, nach einem Zeitplan in UTC. Schritt 8 verschiebt sie auf den Cron des Servers. - Preview-Deployments für jeden Branch und Pull Request. Ein einzelner Server betreibt eine Umgebung; die FAQ behandelt deine Optionen.
- Vercel-exklusive Pakete wie
@vercel/analyticsoder@vercel/functions. Prüfe deren Dokumentation, was sie außerhalb von Vercel tun, bevor du dich auf sie verlässt.
Die Framework-Features selbst kommen mit dir. Next.js’ eigener Self-Hosting-Guide besagt, dass Image Optimization über next/image und Proxy (die Datei-Konvention, die früher Middleware hieß) beide ohne jede Konfiguration funktionieren, wenn du den Next.js-Server selbst betreibst.
Schritt 2: Umgebungsvariablen exportieren
Linke das Projektverzeichnis und zieh die Produktionsvariablen in eine Datei:
vercel link
vercel env pull .env.vercel --environment=production
Öffne .env.vercel und prüfe jeden Wert. Variablen, die mit Vercels Secret-Typ gespeichert sind, lassen sich nicht zurücklesen, die CLI schreibt für jede davon einen Platzhalter. Hol die echten Werte von dort, wo sie herkommen (dein Datenbank-Provider, Stripe, dein Auth-Provider) und trag sie ein. Halte diese Datei aus Git raus; das Standard-.gitignore von Next.js ignoriert .env*-Dateien schon.
Am Ende solltest du eine vollständige Liste von NAME=value-Zeilen ohne Platzhalter haben.
Schritt 3: Die Variablen ersetzen, die Vercel für dich gesetzt hat
Füge für jede VERCEL_-Variable, die dein Code liest, eine eigene Variable hinzu und richte den Code darauf aus. Der häufigste Fall ist der Aufbau absoluter URLs:
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
Füge dann NEXT_PUBLIC_SITE_URL=https://app.example.com zu deiner Umgebungsdatei hinzu. Mach dasselbe für Auth-Bibliotheken: Auth.js liest AUTH_URL (NextAuth v4 liest NEXTAUTH_URL), und auf Vercel konnte die URL abgeleitet werden. Auf deinem Server setzt du sie auf deine echte Domain.
Committe die Code-Änderungen. Du solltest grep -rn "VERCEL_" erneut ausführen können und nichts mehr sehen, was deine App noch braucht.
Schritt 4: Standalone-Output aktivieren
Füge output: "standalone" zu next.config.ts hinzu, deine übrigen Optionen bleiben erhalten:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Damit schreibt next build einen eigenständigen Server nach .next/standalone, sodass das Produktions-Image nicht dein komplettes node_modules braucht.
Schritt 5: Ein Dockerfile hinzufügen
Dieses Dockerfile folgt dem offiziellen with-docker-Beispiel aus dem Next.js-Repository: Abhängigkeiten mit dem zum Lockfile passenden Paketmanager installieren, bauen, und nur den Standalone-Output als unprivilegierten node-User ausführen.
FROM node:24-slim AS dependencies
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
else echo "No lockfile found." && exit 1; \
fi
FROM node:24-slim AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NODE_ENV=production
RUN if [ -f package-lock.json ]; then npm run build; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \
fi
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
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"]
Auf Vercel war jede Variable während des Builds vorhanden. In Docker ist nur das vorhanden, was du selbst übergibst. Next.js inliniert NEXT_PUBLIC_-Variablen während next build in das Browser-Bundle, jede davon braucht also ein ARG-und-ENV-Paar in der Builder-Stage, wie NEXT_PUBLIC_SITE_URL oben. Server-only-Variablen brauchen das nicht; die App liest sie zur Laufzeit.
Füge ein .dockerignore hinzu, damit lokale Dateien und Secrets aus dem Build herausbleiben:
.git
.next
node_modules
.env*
Baue und starte es einmal auf deiner eigenen Maschine mit den exportierten Variablen, um Probleme zu finden, bevor der Server ins Spiel kommt:
docker build --build-arg NEXT_PUBLIC_SITE_URL=http://localhost:3000 -t my-app .
docker run --rm -p 3000:3000 --env-file .env.vercel my-app
Du solltest die App unter http://localhost:3000 sehen, nicht einen Absturz wegen einer fehlenden Variable.
Schritt 6: Den Server vorbereiten
Erstelle den Droplet (Ubuntu 24.04, Basic, 12 $), verbinde dich per SSH und installiere Docker mit dem offiziellen Skript:
curl -fsSL https://get.docker.com | sh
docker compose version
Du solltest eine Compose-Version ausgegeben sehen. Klone dann das Repository und kopiere deine Umgebungsdatei dorthin, umbenannt zu .env:
git clone https://github.com/you/your-app.git /opt/your-app
scp .env.vercel root@your-server-ip:/opt/your-app/.env
Führe den ersten Befehl auf dem Server aus und den zweiten auf deiner eigenen Maschine. Stell auf dem Server sicher, dass .env NEXT_PUBLIC_SITE_URL mit deiner echten Domain enthält, und sperre die Datei ab mit chmod 600 /opt/your-app/.env.
Schritt 7: Compose und Caddy hinzufügen, dann starten
Next.js’ Self-Hosting-Guide empfiehlt einen Reverse-Proxy vor dem Node-Server. Caddy übernimmt diese Rolle und kümmert sich um Zertifikate. Füge compose.yaml zum Repository hinzu:
services:
app:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
restart: unless-stopped
env_file: .env
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
caddy-data:
caddy-config:
Und ein Caddyfile mit deiner Domain:
app.example.com {
reverse_proxy app:3000
}
Committe beides und mach git pull auf dem Server. Starte vorerst nur die App. Caddy wartet bis Schritt 9, weil es kein Zertifikat bekommen kann, solange die Domain noch auf Vercel zeigt:
cd /opt/your-app
docker compose up -d --build app
Der erste Build dauert ein paar Minuten. Prüfe dann, ob die App innerhalb ihres Containers antwortet:
docker compose exec app node -e "fetch('http://localhost:3000').then((r) => console.log(r.status))"
Du solltest 200 sehen. Falls stattdessen ein Fehler kommt, zeigt docker compose logs app, warum die App nicht gestartet ist, meist eine fehlende Variable.
Schritt 8: Vercels Cron-Jobs auf den Server verschieben
Füge für jeden Eintrag unter crons in vercel.json eine Crontab-Zeile hinzu, die denselben Pfad anfragt. Vercels Zeitpläne sind immer UTC, und das ist ein frischer Ubuntu-Server auch, die Ausdrücke lassen sich also unverändert übernehmen. Falls deine Route CRON_SECRET prüft (Vercel sendet es als Authorization: Bearer <value>), prüfe es weiter und sende den Header selbst.
Erstelle ein kleines Skript auf dem Server:
cat > /opt/your-app/cron-request.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
set -a
source /opt/your-app/.env
set +a
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" "https://app.example.com$1"
EOF
chmod 700 /opt/your-app/cron-request.sh
Dann crontab -e und füge eine Zeile pro Job hinzu, mit Pfad und Zeitplan aus vercel.json:
0 5 * * * /opt/your-app/cron-request.sh /api/cron >> /var/log/your-app-cron.log 2>&1
Setze vorerst ein # vor jede Zeile und entferne es erst, nachdem DNS in Schritt 9 umgestellt ist, damit kein Job auf beiden Plattformen gleichzeitig läuft. Sobald es live ist, sollte tail /var/log/your-app-cron.log nach der geplanten Zeit die Antwort deiner Route zeigen.
Schritt 9: DNS umschalten
Senke die TTL des DNS-Records der Domain einen Tag vorher, wenn möglich. Ändere dann den Record, sodass er auf deinen Server zeigt: ein A-Record mit der IPv4-Adresse des Servers, der den Vercel-Record ersetzt. Falls die Domain Vercels Nameserver nutzt, mach die Änderung in Vercels DNS-Einstellungen, oder zieh die Domain zuerst zu einem anderen DNS-Provider um.
Warte, bis die neue Adresse das ist, was die Welt sieht:
dig +short app.example.com
Wenn das die IP deines Servers ausgibt, starte Caddy und beobachte, wie es das Zertifikat bekommt:
docker compose up -d caddy
docker compose logs -f caddy
Du solltest eine Zeile sehen, die besagt, dass das Zertifikat erfolgreich erhalten wurde. Danach lädt https://app.example.com von deinem Server mit einem gültigen Zertifikat. Entkommentiere die Crontab-Zeilen und deaktiviere die Cron-Jobs in Vercels Projekteinstellungen.
Behalte das Vercel-Projekt noch ein paar Tage. Besucher, deren DNS noch nicht aktualisiert ist, erreichen es weiterhin, und falls etwas schiefgeht, kannst du den Record zurückstellen.
Schritt 10: Updates deployen und Backups pflegen
Ein Release ist jetzt:
cd /opt/your-app
git pull
docker compose up -d --build app
Caddy läuft weiter und behält seine Zertifikate; die App ist für die paar Sekunden, die der neue Container zum Starten braucht, nicht erreichbar. Vercel baut deine Pushes außerdem nicht mehr, sobald du die Git-Integration trennst, mach das also, wenn du dir beim neuen Setup sicher bist.
Der Server selbst hält nichts, was du nicht neu bauen könntest, außer .env, also halte eine Kopie davon in einem Passwortmanager. Deine Datenbank bleibt, wo sie heute ist, und behält ihre eigenen Backups; du kannst sie später separat migrieren.
Die Abkürzung: mit Railyard
Schritte 4 bis 10 sind das, was Railyard für dich erledigt. Verbinde dasselbe Repository, und es baut und deployt die App auf einem Server in deinem eigenen Cloud-Account, mit HTTPS und einem Redeploy bei jedem Push. Es gibt kein Dockerfile, keine Compose-Datei und kein Caddyfile zu schreiben.
- Füge einen Server hinzu: Öffne im DigitalOcean-Control-Panel API, generiere einen persönlichen Zugriffstoken mit Lese- und Schreibzugriff, und erstelle den Droplet unter Servers → Add a server in Railyard. Oder verbinde einen Server, den du schon hast (Ubuntu 22.04 oder 24.04, passwortloses
sudo, Ports 22, 80 und 443 offen). - Deploy das Repository, oder öffne
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitmit deiner eigenen URL. - Füge die Variablen aus Schritt 2 unter Variables → Bulk import ein.
Railyard wählt den Paketmanager anhand deines Lockfiles, führt dein Build-Skript aus und startet die App mit npm start, lauschend auf $PORT. Postgres oder Redis, von der App-Seite hinzugefügt, bekommt seinen Connection String als Variable injiziert. Erstelle jeden vercel.json-Cron-Job neu als Eintrag im Cron jobs-Tab der App, das einen Befehl im Image der App nach einem Zeitplan (in UTC) ausführt und eine Historie jedes Laufs behält. Um eine Route anzufragen, nutze Nodes eingebautes fetch mit der RAILYARD_URL-Variable der App:
node -e "fetch(process.env.RAILYARD_URL + '/api/cron', { headers: { authorization: 'Bearer ' + process.env.CRON_SECRET } }).then((r) => process.exit(r.ok ? 0 : 1))"
``` Pull-Request-Previews lassen sich für die App aktivieren, sobald GitHub verbunden ist; siehe [Environments and previews](/docs/environments-and-previews).
Der Free-Plan deckt einen Server plus einen Datenbankserver und drei Apps ohne Kreditkarte ab; Pro kostet 19 $ im Monat pro Team und Team 49 $ im Monat pro Team. Starte mit [Deploy from a repository](/docs/deploy-from-a-repository), oder sieh dir [Deploy Next.js to DigitalOcean](/deploy/nextjs-to-digitalocean) für die Anbieterseite an. Ziehst du auch Apps von Heroku, Render, Fly oder Railway um? [Import from another platform](/docs/import-from-another-platform) liest sie mit einem API-Token aus.
## Troubleshooting
**Der Build schlägt mit einer fehlenden Variable fehl, oder ein `NEXT_PUBLIC_`-Wert ist im Browser leer.** Sie war während `next build` nicht vorhanden. Füge im Dockerfile ein `ARG`-und-`ENV`-Paar dafür hinzu, übergib es unter `build.args` in `compose.yaml`, und baue neu. Ein Container-Neustart ändert keine Werte, die zur Build-Zeit inliniert wurden.
**Logins leiten auf eine `vercel.app`-Adresse um.** Deine Auth-URL hält noch einen Vercel-Wert aus dem Export. Setze `AUTH_URL` oder `NEXTAUTH_URL` auf deine Domain in `.env` und führe `docker compose up -d app` aus.
**Caddy meldet stundenlang Zertifikatsfehler.** DNS zeigt noch nicht auf den Server, oder eine Firewall blockiert Port 80 und 443. `dig +short app.example.com` sollte die IP deines Servers zurückgeben. Caddy versucht es automatisch erneut, sobald das stimmt.
**Code, der Geolocation oder Vercel-Request-Header liest, bekommt nichts.** Header wie `x-vercel-ip-country` werden von Vercels Netzwerk hinzugefügt und existieren hinter Caddy nicht. Entferne diesen Code-Pfad oder nutze einen eigenen Geolocation-Dienst.
**Der Build wird auf dem 2-GB-Server abgebrochen.** Ihm ist der Speicher ausgegangen. Füge eine 2-GB-Swap-Datei hinzu (`fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile`), wechsle zur 24-$-Größe, oder baue das Image anderswo und zieh es aus einer Registry.
## FAQ
**Muss sich der Code meiner App ändern?** Meist nur dort, wo er `VERCEL_`-Variablen liest oder Vercel-spezifische Pakete nutzt. Routing, Server Components, Server Actions, Image Optimization und Proxy laufen alle auf dem Standalone-Server.
**Was ist mit Preview-Deployments?** Ein Server betreibt eine Umgebung. Ein zweiter, kleinerer Server für Staging, aus einem anderen Branch gebaut, deckt die meisten Bedürfnisse ab. Auf Railyard sind Pull-Request-Previews eine Einstellung der App.
**Funktioniert ISR weiter?** Ja. Next.js speichert seinen Cache standardmäßig auf der lokalen Festplatte jeder Server-Instanz, was für eine einzelne Instanz funktioniert. Der Cache startet nach jedem Deploy leer, weil der Container ersetzt wird.
**Kann ich Dienste weiternutzen, die ich über Vercel gekauft habe?** Dienste, die dir einen Connection String oder ein API-Token geben, etwa eine Marketplace-Datenbank, funktionieren von jedem Server weiter, solange du die Credentials kopierst. Prüfe die jeweils eigene Dokumentation für Limits außerhalb von Vercel.
Der Umzug ist größtenteils Buchhaltung: Variablen, Cron-Jobs und DNS. Sind diese drei erledigt, ist ein Release auf deinem eigenen Server ein `git pull` und ein Befehl. 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.