SvelteKit auf Hetzner deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Du hast eine SvelteKit-App und willst, dass sie unter deiner eigenen Domain über HTTPS antwortet, auf einem Server, den du im Monat statt pro Request bezahlst. Das hier ist die vollständig manuelle Version: ein echter Hetzner-Server, ein Dockerfile rund um @sveltejs/adapter-node, und Caddy davor für Zertifikate. Am Ende hast du vite build, das in einem kleinen Produktions-Image läuft, serviert hinter HTTPS, mit einem wiederholbaren Weg, es neu zu bauen und neu zu starten.
Was du brauchst
- Ein Hetzner-Cloud-Konto und API-Token.
- Einen Server: Ein CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, 5,99 €/Monat exkl. MwSt.) betreibt eine kleine bis mittelgroße SvelteKit-App zur Laufzeit problemlos. Falls
vite buildselbst bei einer großen Site den Speicher sprengt, skaliere für den Build auf einen CX33 (4 vCPU, 8 GB RAM, 8,99 €/Monat) hoch, danach wieder runter. - Eine Domain oder Subdomain mit einem A-Record, den du auf den Server zeigen lassen kannst.
- Eine SvelteKit-App mit
@sveltejs/adapter-node, auf Node.js 20.19 oder neuer, dem Minimum, das Vite 7 (und damit aktuelles SvelteKit) verlangt. - SSH-Zugriff auf den Server.
Schritt 1: Den Server erstellen
Erstelle in der Hetzner Cloud Console einen Server vom Typ cx23 (oder deiner skalierten Wahl) auf Ubuntu 24.04, füge beim Erstellen deinen SSH-Schlüssel hinzu und notiere die öffentliche IP, sobald er läuft. Installiere Docker und das Compose-Plugin über SSH:
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Schritt 2: DNS auf den Server zeigen lassen
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 Propagierung vor Schritt 6, da Caddy ihn korrekt aufgelöst braucht, um ein Zertifikat anzufordern.
Schritt 3: Zu adapter-node wechseln
SvelteKit wählt seinen Adapter aus svelte.config.js. Der Standard adapter-auto rät ein Ziel basierend auf deiner Deployment-Plattform, die keine Ahnung hat, dass du ein einfaches Docker-Image baust, also tausche ihn explizit gegen den Node-Adapter:
npm install -D @sveltejs/adapter-node@6
import adapter from "@sveltejs/adapter-node";
export default {
kit: {
adapter: adapter(),
},
};
Diese Anleitung nutzt @sveltejs/adapter-node 6.0.0 mit SvelteKit 3.1.0 und Svelte 5.57.2, den aktuellen Versionen zum Zeitpunkt des Schreibens. npm run build erzeugt jetzt ein build/-Verzeichnis statt was adapter-auto gewählt hätte, mit build/index.js und build/handler.js, den beiden Einstiegspunkten, die die Doku des Adapters beschreibt.
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
RUN npm prune --omit=dev
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["node", "build"]
node:24-alpine folgt Node 24, der aktuell aktiven LTS-Linie. Der dreistufige Aufbau trennt Abhängigkeitsinstallation, den eigentlichen Build und das Runtime-Image. npm prune --omit=dev in der Builder-Stage entfernt devDependencies, bevor sie in den Runner kopiert werden, da die Ausgabe von adapter-node immer noch deine Produktions-node_modules daneben braucht, anders als ein vollständig eigenständiges Bundle. USER node lässt den Prozess als der bereits in den offiziellen Node-Images vorhandene Non-Root-Nutzer node laufen.
Schritt 5: Die Compose-Datei und das Caddyfile schreiben
services:
web:
build: .
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "3000"
HOST: "0.0.0.0"
ORIGIN: "https://app.yourdomain.com"
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 HOST sind das, was der eingebaute Server von adapter-node liest, um zu entscheiden, wo er lauscht; HOST=0.0.0.0 macht ihn vom caddy-Service daneben aus erreichbar, statt nur aus dem eigenen Container. ORIGIN ist immer dann wichtig, wenn SvelteKit seine eigene absolute URL kennen muss, am häufigsten um den Origin-Header bei Form-Actions zu validieren, eine Absicherung gegen Cross-Site-Request-Forgery; es hinter einem Reverse-Proxy unsetzt zu lassen, ist die mit Abstand häufigste Ursache dafür, dass Form-Actions in Produktion fehlschlagen, während sie lokal problemlos funktionieren. Jede andere Variable, die dein serverseitiger Code über $env/dynamic/private liest, kommt in denselben environment:-Block; nur Variablen mit dem Präfix PUBLIC_, gelesen über $env/static/public oder $env/dynamic/public, werden zur Build-Zeit für den Client gebündelt.
Prüfe, dass die Datei sauber geparst wird:
docker compose config
Du solltest die vollständig aufgelöste Konfiguration ohne Fehlerzeilen ausgegeben sehen.
Schritt 6: Deployen
docker compose up -d --build
Der erste Lauf baut das Image, der langsamste Teil, startet dann beide Container, und Caddy fordert sein Zertifikat an. Verfolge die Logs, um zu bestätigen, dass beides erfolgreich war:
docker compose logs -f
Du solltest eine Zeile wie Listening on 0.0.0.0:3000 vom web-Container sehen, und Caddys Log sollte einen erfolgreichen Zertifikatserhalt für deine Domain enthalten. Besuche https://app.yourdomain.com und bestätige, dass die App mit gültigem Zertifikat lädt, ohne Browserwarnung.
Für eine spätere Codeänderung baut derselbe Befehl nur das geänderte Image neu:
docker compose up -d --build web
Das ist nicht Zero-Downtime. Compose stoppt den alten web-Container, bevor der neue startet, also gibt es eine Lücke, gemessen daran, wie lange der Node-Prozess braucht, um zu booten und seinen ersten Request zu bedienen. Falls diese Lücke für deinen Traffic wichtig ist, ist der Standard-Fix, zwei App-Container hinter Caddys eigenem Load Balancing zu betreiben und sie einen nach dem anderen zu ersetzen, mehr Infrastruktur, als diese Single-Server-Anleitung abdeckt.
Die Abkürzung: mit Railyard
SvelteKit bekommt bei Railyard keinen eigenen Build-Pfad, und das ist beabsichtigt: eine mit adapter-node gebaute SvelteKit-App, oder eine, die bei adapter-auto geblieben ist, sieht von außen genau wie jeder andere Node.js-Server aus, der package.json liest und mit node build startet. Railyards Repository-Erkennung liest deine Lockfile, um npm, pnpm, yarn oder bun zu wählen, bestätigt @sveltejs/adapter-node (oder adapter-auto, das darauf aufgelöst wird) und ein Build-Skript, das zu vite build oder svelte-kit build passt, und betreibt die App dann auf dieselbe Weise, wie Schritt 4 bis Schritt 6 es von Hand tun, ohne dass du überhaupt ein Dockerfile schreibst. Falls dein Repo stattdessen sein eigenes Dockerfile mitbringt, wird genau das verwendet.
Deploye direkt über den Node.js-Pfad: /deploy/nodejs-to-hetzner. Siehe Deployment aus einem Repository für den gesamten Erkennungs- und Build-Ablauf, und den Quickstart, falls dies dein erstes Deployment auf Railyard ist.
Troubleshooting
Der Build schlägt mit abgebrochenem Prozess mittendrin fehl, kein klarer Fehler. Fast immer Speichermangel während vite build auf einem kleinen Server; skaliere vorübergehend hoch wie unter “Was du brauchst” erwähnt, oder füge als günstigere Zwischenlösung Swap hinzu (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
Form-Actions schlagen mit 403 in Produktion fehl, funktionieren aber lokal. Das ist fast immer ein fehlendes oder falsches ORIGIN. SvelteKit prüft den Origin-Header des Requests gegen diesen Wert für jede Nicht-GET-Formularübermittlung; hinter einem Reverse-Proxy kann die App ihn nicht selbst ableiten, also muss er explizit auf die exakte HTTPS-URL gesetzt werden, die Nutzer sehen.
HTTPS wird nie ausgestellt, Caddy protokolliert wiederholt ein Timeout gegen Let’s Encrypt. Bestätige, dass der DNS-A-Record aus Schritt 2 tatsächlich von außerhalb deines eigenen Netzwerks auf die IP dieses Servers auflöst, und dass die Ports 80 und 443 sowohl in Hetzners Firewall als auch in etwaigen ufw-Regeln auf der Box selbst offen sind.
Die App baut und startet, aber die Seite lädt nie, Caddy läuft in ein Timeout. Prüfe, ob 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 Netzwerk-Namespaces lauscht, ist vom caddy-Service aus unerreichbar, selbst wenn beide Container laufen.
PUBLIC_-Variablen zeigen den alten Wert, nachdem du sie geändert hast. Diese werden zur Build-Zeit in das Client-Bundle eingebettet, also braucht eine Änderung erneut docker compose up -d --build web; ein Neustart des Containers allein bewirkt für sie nichts.
FAQ
Brauche ich adapter-node, oder funktioniert adapter-auto auch in Docker? adapter-auto löst sich nur zu einem echten Adapter auf, wenn es die Plattform erkennt, auf der es läuft, anhand von Umgebungsvariablen, die diese Plattformen setzen; ein einfaches Docker-Image auf deinem eigenen Server ist keine davon, also muss es explizit adapter-node sein, wie in Schritt 3.
Kann ich pnpm oder yarn statt npm verwenden? Ja, das Dockerfile aus Schritt 4 verzweigt bereits je nach vorhandener Lockfile, keine Änderungen nötig, außer dass diese Lockfile committet ist.
Braucht das eine Datenbank? Nicht standardmäßig. SvelteKit selbst hat keine Meinung zur Persistenz; falls die load-Funktionen oder Form-Actions deiner App mit Postgres oder einer anderen Datenbank sprechen, füge sie als eigenen Compose-Service hinzu und übergib ihren Connection-String im environment:-Block des web-Services, genauso wie ORIGIN oben gesetzt wird.
Was, wenn meine App WebSocket- oder langlebige Verbindungen hat? reverse_proxy in Caddy leitet WebSocket-Upgrade-Header ohne zusätzliche Konfiguration weiter, dieselbe einzelne Zeile in Schritt 5 deckt das schon ab.
Wie unterscheidet sich das vom Deployment auf Vercel? Funktional läuft deine App in beiden Fällen mit demselben Code; was du eintauschst, ist einen gemanagten Build und ein Edge-Netzwerk gegen das Besitzen des Servers, des Dockerfiles und des Zertifikats selbst, günstiger bei konstantem Traffic und mehr Arbeit an den Rändern (Skalierung, Previews, Bildoptimierungs-Infrastruktur).
Das Dockerfile und die Compose-Datei hier ändern sich zwischen SvelteKit-Projekten nicht groß. Was variiert, ist das, was deine load-Funktionen und Form-Actions zur Laufzeit aus der Umgebung lesen, bevor dem ersten echten Deployment einen Blick wert, abgeglichen mit dieser Liste.
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.