Deployment
Which adapter you built with decides the runtime on the host; everything else on this
page applies to both. The build bundles your devDependencies into the output and leaves dependencies external, so a host needs build/, package.json and the production node_modules.
bun --bun vite build && PORT=3000 bun ./build # adapter-bun: the build runs in Bun
vite build && PORT=3000 node build # adapter-node With adapter-bun, keep the --bun flag in your build script (see Getting started) so bun run build does the right thing locally,
in CI and in Docker alike.
Both adapters are SvelteKit’s official ones underneath, so their documentation (Node, Bun) is the reference for every environment variable.
Behind a reverse proxy
Tell the server its public origin so SvelteKit generates correct absolute URLs and CSRF
checks on forms and remote functions pass. Set paths.origin in your config, and the
adapter bakes it into the build:
sveltekit({ adapter: adapter(), paths: { origin: 'https://chat.example.com' } }); To run one build behind more than one domain, leave paths.origin unset and derive the
origin from proxy headers instead: PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host. Without either, the adapters take the host from the Host header and assume https. For client IPs set ADDRESS_HEADER=x-forwarded-for (and XFF_DEPTH to the number of trusted proxies).
WebSockets behind nginx
The proxy has to forward WebSocket upgrades for the events endpoint (/_events). With
nginx that means HTTP/1.1 and the Upgrade/Connection headers:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} nginx closes a proxied connection that stays silent for proxy_read_timeout (60 seconds by
default). The server pings every client every 15 seconds, so an idle socket never gets that
far and the default is fine.
Docker
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile && bun run build
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=build /app/build build
COPY --from=deps /app/node_modules node_modules
ENV PORT=3000
EXPOSE 3000
CMD ["bun", "./build"] The deps stage installs production dependencies only, which keeps build tooling out of the
image: everything under devDependencies is already bundled into build/.
bun run build assumes the build script carries --bun (bun --bun vite build). With adapter-node the script is plain vite build (npm run build works too); swap the runtime
image for node:22-slim and the command for ["node", "build"].
Compose, with Redis
Scaling to more than one instance needs the Redis pub/sub engine, so wire both up in one file:
# compose.yaml
services:
app:
build: .
deploy:
replicas: 2
environment:
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
redis:
image: redis:8-alpine
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5 Put a load balancer in front of the replicas. Sticky sessions are not required: any instance can serve any connection, and the bus fans messages out between them.
systemd
[Unit]
Description=svelte-ws app
After=network.target
[Service]
ExecStart=/usr/local/bin/bun /srv/app/build
Environment=PORT=3000
Restart=on-failure
# the adapter handles SIGTERM: sveltekit:shutdown → drain → exit
TimeoutStopSec=35
[Install]
WantedBy=multi-user.target Scaling out
One instance needs nothing. Multiple instances need exactly one thing: a shared bus. Swap in Redis pub/sub and a message published on one instance reaches subscribers on all of them.
Sticky sessions are not on the list. The client keeps a single WebSocket, any instance can serve it, and state attached at upgrade lives on that connection.
Connection limits
limits in socket.config.ts bound what one connection can do (frame size, concurrent
operations, how far it may fall behind), not how many connections there are or how often a
client may call a mutation. Put those at the edge: limit_conn and limit_req in nginx,
or your load balancer’s equivalent, and a rate limit in the handler for
mutations that cost something.
Graceful shutdown
SIGINT/SIGTERM: live WebSockets close with 1001, the server stops accepting connections
and drains in-flight requests for up to SHUTDOWN_TIMEOUT (default 30 s), then sveltekit:shutdown fires and init’s cleanup runs. See the adapter.