Skip to guide
Help center

How to deploy SvelteKit to a VPS with adapter-node

Build a SvelteKit 3 app with adapter-node, declare its environment variables in src/env.ts, keep runtime packages in dependencies and run it in Docker.

Updated

This guide deploys a SvelteKit 3 app to your own server with adapter-node, which turns it into a Node.js server you run in Docker. Most of the work is in two details that differ from other Node.js frameworks, which packages the server keeps outside its bundle and how it reads environment variables. The last sections deploy it with Tenkai and PostgreSQL.

We tested it on 2 November 2026 with a new app from npx sv create and its minimal template, on SvelteKit 3.1.1, adapter-node 6.0.1 and node:24-trixie-slim, so adapt the routes and packages to your own app.

Prerequisites

You need a SvelteKit 3 app in a Git repository with a CI such as GitHub Actions, and Node.js 24 with npm. Docker is useful to test the image on your machine, and the Tenkai part needs a project on a ready cluster.

Switch to adapter-node

npx sv add sveltekit-adapter="adapter:node"

The default adapter-auto builds for the platform it detects, and on your own server it detects none, so it writes no server you can start. SvelteKit 3 keeps its configuration in vite.config.ts, where the adapter now appears.

Keep runtime packages in dependencies

vite build bundles every package in devDependencies into the server, and leaves each package in dependencies outside it, to be found in node_modules when the server runs. Install what your server imports at runtime, such as a database client, as a dependency.

npm install postgres

The Dockerfile then installs those dependencies alone in the image.

Declare the environment variables

In SvelteKit 3, an app declares the variables it reads in src/env.ts, and imports them from $app/env/private on the server, or from $app/env/public for those the browser may see. The $env/dynamic/private module is deprecated, and in our test build a value read from it without a declaration was fixed at build time instead of read when the server started.

import { building } from '$app/env';
import { defineEnvVars } from '@sveltejs/kit/env';

export const variables = defineEnvVars({
	DATABASE_URL: {
		// The build runs without it, the server refuses to start without it.
		schema: (value) => {
			if (!value && !building) throw new Error('DATABASE_URL is missing');
			return value ?? '';
		},
	},
});

SvelteKit checks each variable at the build and when the server starts. building lets the build pass without the value, which your build has no reason to know, while a server that starts without it stops with env_invalid and DATABASE_URL is missing. Use the variable in src/lib/server/db.ts.

import { DATABASE_URL } from '$app/env/private';
import postgres from 'postgres';

export const sql = postgres(DATABASE_URL);

// adapter-node emits this once it stops taking requests, and the open pool would keep Node running.
process.on('sveltekit:shutdown', () => sql.end());

Closing the pool on sveltekit:shutdown lets the server exit once it has finished its requests, instead of waiting until Docker kills it 10 seconds later.

The built server reads no .env file, so each value comes from the environment of the container.

Add a health check

Create src/routes/health/+server.ts.

import { sql } from '#lib/server/db.js';

export async function GET() {
	await sql`select 1`;
	return new Response('ok');
}

/health answers ok while PostgreSQL answers, and an error once it does not.

Add a Dockerfile

Create Dockerfile at the repository root.

FROM node:24-trixie-slim AS base
WORKDIR /app

FROM base AS build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM base AS production-dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM base AS runtime
ENV NODE_ENV=production \
	PORT=8080
COPY package.json ./
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY --from=build /app/build ./build
USER node
EXPOSE 8080
CMD ["node", "build"]

The server needs build, package.json and the production node_modules. If your app has no dependencies at all, npm ci --omit=dev creates no node_modules, so delete the production-dependencies stage and its COPY line. The server listens on 0.0.0.0 at PORT, and on SIGTERM it finishes the requests in progress before it exits.

Exclude local files from the image

Create .dockerignore beside the Dockerfile.

.git
node_modules
.svelte-kit
build
.env
.env.*

Behind a proxy

SvelteKit checks that each form posted to the server comes from the same origin, and builds that origin from the request's Host header and https, which is what a proxy that terminates HTTPS sends. You need no ORIGIN setting behind Tenkai, and a form posted from another site is refused with 403.

The server accepts request bodies up to 512 KB. To accept larger uploads, add BODY_SIZE_LIMIT, such as 10M, to the variables.

Test the image locally

Try the image with a PostgreSQL container.

docker build -t hello-sveltekit .
docker network create hello-sveltekit
docker run -d --name hello-sveltekit-db --network hello-sveltekit \
	-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app postgres:18
docker run --rm --name hello-sveltekit --network hello-sveltekit -p 8080:8080 \
	-e DATABASE_URL=postgresql://postgres:secret@hello-sveltekit-db:5432/app hello-sveltekit

Visit http://localhost:8080/health and expect ok, then remove the containers and the network.

Publish the image from your CI

Build the image in your CI on each push and publish it to a container registry, so your servers spend nothing on builds and your code can stay private. Build an image in GitHub Actions and publish it to GHCR gives a complete workflow for this Dockerfile, built for your cluster's CPU architecture, which publishes an image such as ghcr.io/acme/hello-sveltekit:main on each push to main.

Deploy it with Tenkai

  1. Create the database. Create a PostgreSQL managed service and wait until it is running.
  2. Create the application. Choose Add resource, then Application, Web application and Container image. Enter the image reference with its moving tag, such as ghcr.io/acme/hello-sveltekit:main, and for a private package choose a GHCR credential that Can pull it, as pull a private GHCR image explains.
  3. Bind the database. Bind the PostgreSQL service to the application, which gives it DATABASE_URL, and add your other variables in the Variables tab.
  4. Add the health check. In the Configuration tab, add a health check on /health.

When your app migrates its database with a tool such as Drizzle Kit, add the migration command under Pre-deploy commands in Change configuration…, so it runs once before the new version takes traffic. The command runs in the image, so the tool must be in dependencies, and the runtime stage must also copy what it reads, such as drizzle.config.ts and the drizzle folder of generated migrations.

COPY --from=build /app/drizzle.config.ts ./
COPY --from=build /app/drizzle ./drizzle

Each push then publishes a new image, and a last step of your workflow can deploy it by calling the application's deploy hook.

Deploy and verify

Choose Deploy and wait for an active release, then open its default route over HTTPS. When the release does not start, the Logs tab shows why, and env_invalid lists the variables the server is missing. Once it works, connect your own domain.