Self-Hosting a SvelteKit App in Production
SvelteKit is often shown deploying to Vercel or Cloudflare with a single adapter swap, which is great — until you want to own the box, avoid per-request pricing, or run alongside a database on the same server. Self-hosting SvelteKit is genuinely simple: adapter-node turns your app into a plain Node server, and from there it’s the same story as any Node service.
Step 1: Switch to adapter-node
Install the Node adapter and point your config at it:
npm install -D @sveltejs/adapter-node // svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({ out: 'build' })
}
}; Build it:
npm run build You now have a standalone Node server in build/. Run it with:
node build By default it listens on PORT (default 3000) and HOST. That’s the whole “SvelteKit-specific” part — everything after this is generic Node production practice.
Step 2: Environment and the origin gotcha
SvelteKit’s Node server needs a few env vars set correctly in production, or you’ll hit confusing bugs:
PORT=3000
HOST=127.0.0.1 # bind to loopback; the proxy faces the internet
ORIGIN=https://yourapp.com # REQUIRED for form actions / CSRF to work behind a proxy
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host
BODY_SIZE_LIMIT=10M The one that bites everyone is ORIGIN. Without it, POST form actions fail CSRF checks behind a reverse proxy. Set it to your public URL. Keep these in an env file managed the right way, not hardcoded.
Step 3: Supervise it
node build in a terminal isn’t a deployment. You want crash recovery, reboot survival, and health-gated reloads. With Oxmgr:
[processes.web]
command = "node build"
working_dir = "/srv/myapp"
restart_on_exit = true
[processes.web.env]
PORT = "3000"
HOST = "127.0.0.1"
ORIGIN = "https://yourapp.com"
PROTOCOL_HEADER = "x-forwarded-proto"
HOST_HEADER = "x-forwarded-host"
[processes.web.health_check]
endpoint = "http://localhost:3000/"
interval_secs = 30 Add a lightweight +server.js health route (returning 200) and point the health check at it for a cleaner readiness signal than the homepage.
Step 4: Use all your cores
A single Node process uses one core. SvelteKit’s server is a standard Node HTTP server, so it scales the same way any Node app does — either via clustering or, more simply, by running multiple instances behind the proxy:
[processes.web]
command = "node build"
instances = 4
restart_on_exit = true Each instance needs its own port, or you use a cluster setup so they share one. For most SvelteKit apps (SSR that’s IO-bound on data fetching), 2–4 instances on a 4-core box is plenty — see worker threads vs cluster for when you’d need more.
Step 5: Reverse proxy + SSL
Put Caddy or nginx in front for TLS termination, HTTP/2, and serving the immutable build/client assets directly (don’t make Node serve static files it doesn’t have to). The nginx + SSL VPS guide applies directly; a minimal Caddyfile is just:
yourapp.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
} Caddy handles Let’s Encrypt automatically. For the Caddy vs nginx vs Traefik trade-offs, see the reverse proxy comparison.
Step 6: Deploy without downtime
Your deploy is: pull, npm ci, npm run build, then oxmgr reload web for a zero-downtime rollover. Drive it from a git webhook and pushing to main ships to production. Because the build is a self-contained build/ directory, rollback is just pointing at the previous build and reloading.
Why self-host SvelteKit at all
Three honest reasons: cost (a $10 VPS runs a lot of SSR traffic with no per-request billing), colocation (your app and its Postgres on the same box means sub-millisecond DB latency), and control (no cold starts, no platform limits, no adapter surprises). The trade is that you own uptime — which is exactly what a process manager plus a reverse proxy is for. Once it’s set up, a self-hosted SvelteKit app is boring to operate, which is the goal.