# How to deploy SvelteKit to a VPS with adapter-node

Source: https://tenkai.cloud/help/deploy-sveltekit

> 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.

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](https://tenkai.cloud/help/projects-and-clusters).

## Switch to adapter-node

```sh
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.

```sh
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.

```ts
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`.

```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`.

```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.

```dockerfile
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.

```text
.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.

```sh
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](https://tenkai.cloud/help/publish-image-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](https://tenkai.cloud/help/managed-services#create-the-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](https://tenkai.cloud/help/pull-private-ghcr-image) explains.
3. **Bind the database.** [Bind the PostgreSQL service](https://tenkai.cloud/help/managed-services#bind-an-application) 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](https://tenkai.cloud/help/blue-green-deployments#the-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](https://tenkai.cloud/help/deployment-commands). 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.

```dockerfile
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](https://tenkai.cloud/help/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](https://tenkai.cloud/help/custom-domain).
