How to deploy a SvelteKit app without Docker
SvelteKit is unusual among frameworks in that your deployment target is a build-time decision. The adapter you install determines what `vite build` produces — a Node server, a folder of static files, a Cloudflare Worker, a Vercel function — and picking the wrong one produces output your host cannot run, with an error message that rarely says so.
This walks through the Node path, which is the one that works anywhere without containers: `adapter-node`, a built server, a process listening on a port. No Dockerfile, no registry, no image build in CI.
Step 1: pick the adapter deliberately
`adapter-auto` ships by default and guesses your platform. It is fine for a first deploy on a platform it recognises and confusing everywhere else, because the build output changes based on environment variables it detects. For anything you intend to keep, be explicit.
- adapter-node — a Node server in build/. Use this if you have server routes, form actions, hooks, or anything server-rendered. This is the portable choice.
- adapter-static — a folder of pre-rendered HTML. Use it only if every route is prerenderable; one dynamic server route makes the build fail, which is the adapter telling you the truth.
- Platform adapters (Cloudflare, Vercel, Netlify) — best output for those hosts, and no output that runs anywhere else.
npm i -D @sveltejs/adapter-node// svelte.config.js
import adapter from "@sveltejs/adapter-node";
export default {
kit: {
adapter: adapter(),
},
};Now `npm run build` emits a `build/` directory containing a self-contained Node server. Running it is one command:
npm ci
npm run build
node build # reads PORT and HOST from the environmentStep 2: get environment variables right
SvelteKit's four import paths are the clearest env-var design of any framework, and worth learning properly because they encode a security boundary:
- $env/static/private — secrets, inlined at build time, never sent to the browser. Requires a rebuild to change.
- $env/dynamic/private — secrets, read from process.env at runtime. This is what you want on a host where config changes without rebuilding.
- $env/static/public and $env/dynamic/public — values prefixed with PUBLIC_ that may reach the browser. Anything here is public. Not 'probably fine' — public.
The practical rule: use the dynamic private import for anything that differs between staging and production, so rotating a database password is a restart rather than a rebuild. Reserve the static imports for values that genuinely belong to a build.
// src/routes/api/items/+server.js
import { env } from "$env/dynamic/private";
import { json } from "@sveltejs/kit";
export async function GET() {
const res = await fetch("https://api.example.com/items", {
headers: { authorization: "Bearer " + env.API_TOKEN },
});
return json(await res.json());
}Step 3: set ORIGIN, or form actions will fail
This is the SvelteKit deployment trap. Behind a reverse proxy, the Node server sees requests as plain HTTP on localhost, while the browser sees HTTPS on your domain. SvelteKit's CSRF protection compares the request's origin against what it believes its own URL is, decides they differ, and rejects the POST. Your page loads perfectly and every form action returns a 403.
# Tell the server what the browser sees
ORIGIN=https://app.example.com
PORT=3000
HOST=0.0.0.0
# If your proxy sets x-forwarded-* headers and you trust it, this works too
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-hostStep 4: deploy the process
From here it is an ordinary long-lived Node process, so any host that runs one will do. Two paths worth spelling out.
On a VPS with systemd
# /etc/systemd/system/sveltekit.service
[Unit]
Description=SvelteKit app
After=network.target
[Service]
WorkingDirectory=/srv/app
ExecStart=/usr/bin/node build
Environment=NODE_ENV=production PORT=3000 HOST=0.0.0.0
Environment=ORIGIN=https://app.example.com
EnvironmentFile=/etc/app.env
Restart=always
User=app
[Install]
WantedBy=multi-user.targetPut Caddy or nginx in front for TLS, point it at 127.0.0.1:3000, and you are done. Deploys are: pull, `npm ci`, `npm run build`, restart the unit. Unglamorous and extremely reliable.
On a git-driven platform
Platforms with framework detection recognise SvelteKit from your package.json and know that `adapter-node` output starts with `node build`. On PandaStack the app runs in its own Firecracker microVM: the build happens inside the VM, the process stays alive, and the platform exports PORT before starting it.
pandastack app create --name web \
--git-url https://github.com/acme/sveltekit-app \
--env ORIGIN=https://web.example.com,NODE_ENV=production
# Detected as sveltekit: npm ci → npm run build → node build
pandastack app deploy <app-id> --followIf you used `adapter-static` instead, override the start command to serve the output directory — the platform defaults to the Node server because it is the safe assumption, not because it inspected your adapter.
The five checks before you call it done
- Hard-refresh a deep route. If it 404s, you built with adapter-static and are serving it without an SPA fallback.
- Submit a form action. A 403 here means ORIGIN is wrong or unset. This is the failure that reaches production most often, because nobody tests a POST during a smoke check.
- Restart the process and load the page. Anything that only works on a warm process — in-memory session state, a module-level cache holding user data — is a bug waiting for your next deploy.
- Check memory after an hour of traffic. SSR frameworks that hold references in module scope leak slowly, and a container restarted every deploy hides it during development.
- Confirm your health check hits a route that does not query the database. Otherwise a slow database becomes a restart loop, which turns a degraded service into an outage.
Why skipping Docker is reasonable here
A SvelteKit app with `adapter-node` has no system dependencies beyond Node itself. There is no imagemagick, no headless browser, no compiled extension — the build output is JavaScript and the runtime is one binary. A Dockerfile in that situation buys you reproducibility you can also get by pinning the Node version, and costs you an image build in every CI run.
Add the Dockerfile when something changes that argument: a native dependency, a system package, a sidecar process, or a compliance requirement that the artifact promoted to production be byte-identical to the one tested. Until then, `node build` behind a proxy is the whole deployment, and it is a good one.
Frequently asked questions
adapter-node or adapter-static — how do I choose?
Ask whether any route needs to run code at request time. Server routes, form actions, hooks that read cookies, or any load function that cannot be prerendered all require a server, which means adapter-node. If every page can be generated at build time — a blog, docs, a marketing site — adapter-static gives you a folder of HTML you can put on any CDN with no process running and effectively no hosting cost. The build itself will tell you if you chose wrong: adapter-static fails loudly on a route it cannot prerender, which is a feature rather than an inconvenience.
Why do my form actions return 403 in production but work locally?
SvelteKit checks that the origin of a POST matches the origin it believes it is serving. Locally those match trivially. Behind a reverse proxy, the Node server receives plain HTTP on localhost while the browser sent HTTPS to your domain, the two do not match, and CSRF protection rejects the request. Set the ORIGIN environment variable to the public URL the browser uses, or configure PROTOCOL_HEADER and HOST_HEADER if you have a trusted proxy that sets x-forwarded headers. It is the single most common SvelteKit production bug, and it never appears in development.
How do I serve a SvelteKit app and an API from the same origin?
Usually you already can — SvelteKit's server routes are an API. A +server.js file exports GET, POST, and friends, runs on the same process, shares your session handling in hooks, and needs no CORS configuration because it is the same origin by construction. Reach for a separate backend service when the API is consumed by other clients, when it needs a different language or runtime, or when it must scale independently from page rendering. For a single web app, keeping the API inside SvelteKit removes an entire class of deployment and auth problems.
Do I need PM2 or a process manager?
You need something that restarts the process when it dies, and the choice depends on where you run. On a VPS, systemd already does this well and is one file with no extra dependency, so PM2 mostly adds a layer. On a platform that supervises processes for you — any PaaS or container runtime — adding a process manager inside is redundant and can hide crashes from the platform's own restart logic. The one genuinely useful PM2 feature is cluster mode for multiple cores, and even there, running several instances behind the platform's load balancer is usually cleaner.
How much memory does a SvelteKit app need?
The runtime is modest — a typical adapter-node server serves real traffic comfortably in a few hundred megabytes — but the build is not. Vite plus TypeScript on a medium application will use well over a gigabyte, and larger projects routinely exceed two. This is why builds fail on constrained builders with a JavaScript heap out-of-memory error while running fine on a developer laptop. Either build on something with enough memory, or cap the old-space size below the container limit so Node garbage-collects instead of being killed by the kernel.
Keep reading
- App hosting on PandaStack — SvelteKit detected, built and run in its own microVM
- The best SvelteKit hosting platforms
- How to deploy a Nuxt app without Docker
- Build-time vs runtime environment variables
- Fixing JavaScript heap out-of-memory build failures
49ms p50 cold start. Fork, snapshot, and scale to zero.