← Blog

Deploy SvelteKit to Hetzner

Railyard

September 20, 2026 · 7 min read

Follow this on your own server. Start free, no card needed

You’ve got a SvelteKit app and you want it answering at your own domain over HTTPS, on a server you pay for by the month instead of by the request. This is the fully manual version: a real Hetzner server, a Dockerfile built around @sveltejs/adapter-node, and Caddy in front of it for certificates. By the end you’ll have vite build running inside a small production image, served behind HTTPS, with a repeatable way to rebuild and restart it.

What you’ll need

  • A Hetzner Cloud account and API token.
  • A server: a CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) runs a small-to-medium SvelteKit app fine at runtime. If vite build itself runs out of memory on a large site, resize up to a CX33 (4 vCPU, 8 GB RAM, €8.99/month) for the build, then back down afterward.
  • A domain or subdomain with an A record you can point at the server.
  • A SvelteKit app using @sveltejs/adapter-node, on Node.js 20.19 or newer, which is the minimum Vite 7 (and therefore current SvelteKit) requires.
  • SSH access to the server.

Step 1: Create the server

From the Hetzner Cloud console, create a server using the cx23 type (or your resized choice) on Ubuntu 24.04, add your SSH key at creation time, and note the public IP once it’s running. Install Docker and the Compose plugin over SSH:

curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Step 2: Point DNS at the server

Create an A record for your domain or subdomain pointing at the server’s IP, for example app.yourdomain.com. Give it a few minutes to propagate before Step 6, since Caddy needs it resolving correctly to request a certificate.

Step 3: Switch to adapter-node

SvelteKit picks its adapter from svelte.config.js. The default adapter-auto guesses a target from your deployment platform, which has no idea you’re building a plain Docker image, so swap it for the Node adapter explicitly:

npm install -D @sveltejs/adapter-node@6
import adapter from "@sveltejs/adapter-node";

export default {
  kit: {
    adapter: adapter(),
  },
};

This guide uses @sveltejs/adapter-node 6.0.0 against SvelteKit 3.1.0 and Svelte 5.57.2, the current versions as of writing. Running npm run build now produces a build/ directory instead of whatever adapter-auto would have picked, containing build/index.js and build/handler.js, the two entry points the adapter’s docs describe.

Step 4: Write the Dockerfile

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 tracks Node 24, the currently active LTS line. The three-stage layout keeps dependency installation, the actual build, and the runtime image separate; npm prune --omit=dev in the builder stage drops devDependencies before they get copied into the runner, since adapter-node’s output still needs your production node_modules alongside it, unlike a fully standalone bundle. USER node runs the process as the non-root node user already present in the official Node images.

Step 5: Write the Compose file and Caddyfile

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 and HOST are what adapter-node’s built-in server reads to decide where to listen; HOST=0.0.0.0 is what makes it reachable from the caddy service next to it, rather than only from inside its own container. ORIGIN matters any time SvelteKit needs to know its own absolute URL, most commonly to validate the Origin header on form actions, a safeguard against cross-site request forgery; leaving it unset behind a reverse proxy is the single most common cause of form actions failing in production while working fine in local dev. Any other variable your server-side code reads with $env/dynamic/private goes in this same environment: block; only variables prefixed PUBLIC_ and read via $env/static/public or $env/dynamic/public get bundled for the client at build time.

Check the file parses cleanly:

docker compose config

You should see the fully resolved configuration printed with no error lines.

Step 6: Deploy

docker compose up -d --build

The first run builds the image, which is the slowest part, then starts both containers and Caddy requests its certificate. Tail the logs to confirm both succeeded:

docker compose logs -f

You should see a line like Listening on 0.0.0.0:3000 from the web container, and Caddy’s log include a successful certificate obtain for your domain. Visit https://app.yourdomain.com and confirm the app loads with a valid certificate, no browser warning.

For a later code change, the same command rebuilds just the image that changed:

docker compose up -d --build web

This isn’t zero-downtime. Compose stops the old web container before starting the new one, so there’s a gap measured in however long the Node process takes to boot and serve its first request. If that gap matters for your traffic, the standard fix is running two app containers behind Caddy’s own load balancing and replacing them one at a time, more infrastructure than this single-server guide covers.

The shortcut: do it with Railyard

SvelteKit doesn’t get its own build path on Railyard, and that’s deliberate: a SvelteKit app built with adapter-node or left on adapter-auto looks, from the outside, exactly like any other Node.js server that reads package.json and starts with node build. Railyard’s repository detection reads your lockfile to pick npm, pnpm, yarn or bun, confirms @sveltejs/adapter-node (or adapter-auto resolving to it) and a build script matching vite build or svelte-kit build, and then runs the app the same way Step 4 through Step 6 do by hand, without you writing a Dockerfile at all. If your repo ships its own Dockerfile instead, that one gets used directly.

Deploy directly through the Node.js path: /deploy/nodejs-to-hetzner. See Deploy from a repository for how the detection and build step works end to end, and the quickstart if this is your first deploy on Railyard.

Troubleshooting

The build fails with the process getting killed partway through, no clear error. Almost always out-of-memory during vite build on a small server; resize up temporarily as mentioned in “What you’ll need,” or add swap as a cheaper stopgap (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).

Form actions fail with a 403 in production but work locally. This is almost always a missing or wrong ORIGIN. SvelteKit checks the request’s Origin header against this value for any non-GET form submission; behind a reverse proxy the app can’t infer it on its own, so it has to be set explicitly to the exact HTTPS URL users see.

HTTPS never issues, Caddy keeps logging a timeout against Let’s Encrypt. Confirm the DNS A record from Step 2 actually resolves to this server’s IP from outside your own network, and that ports 80 and 443 are open in Hetzner’s firewall as well as any ufw rules on the box itself.

The app builds and starts, but the page never loads, Caddy times out. Check that HOST=0.0.0.0 actually made it into the running container (docker compose exec web env); a server listening only on localhost inside its own network namespace is unreachable from the caddy service even though both containers are up.

PUBLIC_ variables show the old value after you changed them. These get inlined into the client bundle at build time, so changing one needs docker compose up -d --build web again; restarting the container alone does nothing for them.

FAQ

Do I need adapter-node, or does adapter-auto work in Docker too? adapter-auto only resolves to a real adapter when it recognizes the platform it’s running on from environment variables those platforms set; a plain Docker image on your own server isn’t one of them, so it has to be adapter-node explicitly, as in Step 3.

Can I use pnpm or yarn instead of npm? Yes, the Dockerfile in Step 4 already branches on whichever lockfile is present, no changes needed beyond having that lockfile committed.

Does this need a database? Not by default. SvelteKit itself has no opinion on persistence; if your app’s load functions or form actions talk to Postgres or another database, add it as its own Compose service and pass its connection string in the web service’s environment: block the same way ORIGIN is set above.

What if my app has WebSocket or long-lived connections? reverse_proxy in Caddy forwards WebSocket upgrade headers without any extra configuration, the same single line in Step 5 already covers it.

How is this different from deploying to Vercel? Functionally your app runs the same code either way; what you’re trading is a managed build and edge network for owning the server, the Dockerfile and the certificate yourself, cheaper at steady traffic and more work at the edges (scaling, previews, image optimization infrastructure).

The Dockerfile and Compose file here don’t change much between SvelteKit projects. What varies is whatever your load functions and form actions read from the environment at runtime, worth double-checking against this list before the first real deploy.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Deploy this repo without the Dockerfile

Paste the repo URL into Railyard and it builds, ports and env vars are detected for you on a Hetzner server.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.