Run SvelteKit in Docker

Aug 27, 2026 SvelteKit

adapter-node turns a SvelteKit app into a Node process. Docker packages that process into an image, so your laptop and the host use the same Node version and Linux packages. The project types page lists Docker as a container target for this adapter.

adapter-auto and adapter-vercel produce files for serverless hosts. A container needs a process that keeps listening. adapter-static has no server, so it cannot receive a form POST. That static build can still guard pages in the client if a separate API does the real check.

The Postgres post uses Docker for the database. Here, the SvelteKit app goes into the image too. Deploy on EC2 uses the same adapter without a container. rsync copies build/, while PM2 and nginx run directly on the box.

The examples use Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib, and ORIGIN moves to paths.origin in config. See the SvelteKit 3 migration guide for those renames.

You will add these files:

Dockerfile
.dockerignore
compose.yml
svelte.config.js
src/routes/
├── +layout.svelte
├── +page.svelte
└── +page.server.ts

Step 1: adapter-node on your laptop

You can reuse the shop from the tutorial or routing. To start fresh, install Node 18 or newer, then run the Svelte CLI:

npx sv create shop
cd shop
npm install

Pick TypeScript. Skip the add-ons.

sv create ships adapter-auto. Swap it:

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

The adapter add-on installs @sveltejs/adapter-node and writes svelte.config.js. With SvelteKit 3, it writes vite.config.ts instead. You can also configure the adapter by hand:

npm i -D @sveltejs/adapter-node
import adapter from '@sveltejs/adapter-node';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  kit: {
    adapter: adapter()
  }
};

export default config;

Keep the defaults: out: 'build' and precompress: true. The adapter writes .gz and .br files beside the assets for Node to serve.

Step 2: a form so ORIGIN is visible

A page can load with the wrong public URL, but a form POST will fail. This small form makes a bad ORIGIN value easy to spot.

Replace src/routes/+layout.svelte:

<script lang="ts">
  let { children } = $props();
</script>

<nav>
  <a href="/">Home</a>
</nav>

{@render children()}

Replace src/routes/+page.svelte:

<script lang="ts">
  import type { ActionData } from './$types';

  let { form }: { form: ActionData } = $props();
</script>

<h1>Northside Hardware</h1>
<p>Hammers, tape, sandpaper.</p>

<form method="POST">
  <label>
    Name
    <input name="name" value={form?.name ?? ''} required />
  </label>
  <button type="submit">Say hi</button>
</form>

{#if form?.message}
  <p>{form.message}</p>
{/if}

{#if form?.greeting}
  <p>{form.greeting}</p>
{/if}

Create src/routes/+page.server.ts:

import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const name = String(data.get('name') ?? '').trim();
    if (!name) {
      return fail(400, { message: 'Name is required', name });
    }
    return { greeting: `Hi ${name}.` };
  }
};

Skip this step if you already have the tutorial’s reading list. Its form will do the same job.

Build and start it without Docker first:

npm run build
ORIGIN=http://localhost:3000 node build

Open http://localhost:3000 and submit the form. The page should show the greeting. Press Ctrl+C when you are done.

Do not use vite preview here. It loads .env the way vite dev does, while the container runs node build. Test the command you will deploy.

adapter-node defaults HOST to 0.0.0.0, which is what Docker needs. The EC2 post uses HOST=127.0.0.1 because nginx runs on the same machine. Copy that setting into a container and Docker’s port mapping cannot reach Node.

Step 3: .dockerignore

Create .dockerignore in the project root:

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

The builder runs COPY . . after npm ci. Without this ignore rule, the copy replaces the Linux node_modules with packages from your laptop. A Mac ARM better-sqlite3 binary will not load in the image. The SQLite post has more on native bindings.

Keep .env out of the image and pass its values at run time.

Step 4: the Dockerfile

Create Dockerfile in the project root:

FROM node:22-bookworm-slim AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev 
  && chown -R node:node /app

USER node
EXPOSE 3000
CMD ["node", "build"]

The first stage installs every package and runs Vite. The second starts with a clean image and keeps only build/, the package files, and production node_modules. These are the adapter-node deployment steps expressed as Docker layers.

Because package.json is copied before the source, Docker can reuse the npm ci layer when only a .svelte file changes.

I would skip Alpine here. node:22-alpine is smaller, but native addons compiled against glibc often fail on musl. bookworm-slim is the boring option, and it matches a Debian VPS. The official Node image already includes a node user with uid 1000. The runner switches to that user after chown.

adapter-node bundles devDependencies into build/, while runtime packages remain in node_modules. Put postgres and better-sqlite3 in dependencies so the runner’s npm ci --omit=dev installs them. UI libraries can remain in devDependencies.

The examples use npm because sv create does. For pnpm, copy pnpm-lock.yaml, enable Corepack, run pnpm install --frozen-lockfile in the builder, and run pnpm install --prod --frozen-lockfile in the runner.

Step 5: build and run the image

From the project root:

docker build -t shop .
docker run --rm --init -p 3000:3000 -e ORIGIN=http://localhost:3000 shop

--init runs tini as pid 1. This lets Ctrl+C and docker stop reach Node as SIGTERM. adapter-node stops accepting new work and gives in-flight requests up to 30 seconds to finish.

Open http://localhost:3000 again and submit the form. You should get the same greeting, now from the container.

ORIGIN must match the URL in the address bar. http://127.0.0.1:3000 and http://localhost:3000 are different origins. A mismatch makes the POST fail with Cross-site POST form submissions are forbidden.

Do not bake ORIGIN into the Dockerfile. Staging and production can share the image and supply their own values.

SvelteKit 3 removed the ORIGIN env var. When paths.origin is unset, adapter-node reads the origin from the Host header. That works for docker run -p 3000:3000. Behind a proxy, set PROTOCOL_HEADER and HOST_HEADER as described below. To keep using an ORIGIN value, set paths.origin: process.env.ORIGIN in vite.config.ts. SvelteKit reads that file during npm run build, so a later -e flag cannot change the value.

Step 6: Compose

Put the run settings in compose.yml:

services:
  web:
    build: .
    ports:
      - '3000:3000'
    environment:
      ORIGIN: http://localhost:3000
    init: true
docker compose up --build

init: true does the same job as docker run --init.

Stop the process with Ctrl+C. Then run docker compose down to remove the container without deleting the image.

Step 7: env that changes per machine

vite dev loads .env; node build and the container do not. The environment variables post covers all four $env modules.

$env/static/private copies a value into the bundle when docker build runs npm run build. Changing DATABASE_URL later with -e has no effect because the running app still has the builder’s value. Do not work around this by passing secrets as ARG. Use $env/dynamic/private to read process.env at run time:

import { env } from '$env/dynamic/private';

const url = env.DATABASE_URL;

Static PUBLIC_ names are also fixed during the build. If the public shop name differs between deployments, use $env/dynamic/public or rebuild the image when the name changes.

Pass values with -e or Compose’s environment setting. env_file: .env reads the host file and injects its keys without copying the file into the image.

PORT and HOST belong to adapter-node. They are not $env names. Leave HOST unset in Docker.

A database in the same Compose file

The Postgres post’s compose.yml publishes port 5432 and connects from a SvelteKit process on your laptop, so it uses localhost. Once the app becomes a Compose service, localhost inside web refers to the app container. Use the database service name instead:

services:
  web:
    build: .
    ports:
      - '3000:3000'
    environment:
      ORIGIN: http://localhost:3000
      DATABASE_URL: postgres://app:app@db:5432/app
    depends_on:
      - db
    init: true
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - dbdata:/var/lib/postgresql/data

volumes:
  dbdata:

Remove the 5432:5432 port mapping if nothing on the host needs Postgres. Drizzle Kit on your laptop still needs that mapping, so keep it while running drizzle-kit push from the host.

SQLite can live in a volume. Bind the file to a path the node user can write:

volumes:
  - ./data:/app/data

Without a volume, docker compose down discards the database. It also discards files from a local upload. Store those files in a volume or send them to S3.

Behind a reverse proxy

If nginx or Caddy terminates TLS in front of the container, set ORIGIN to the HTTPS URL shown in the browser. Set PROTOCOL_HEADER=x-forwarded-proto and HOST_HEADER=x-forwarded-host too. The EC2 post puts the same values on PM2. Without them, form actions see the request as http://web:3000.

Do not set those header names unless the proxy is one you control. Clients can spoof them.

adapter-node’s BODY_SIZE_LIMIT defaults to 512 KB. For larger requests, raise both this value on the container and the proxy’s body limit. The file upload post shows the Node setting.

Things that trip people up

Leaving adapter-auto in svelte.config.js produces a build/ folder for a serverless filesystem, so node build fails or runs the wrong output. With SvelteKit 3, the adapter belongs in vite.config.ts.

Do not run CMD ["npm", "run", "preview"] or vite dev in the production image. Preview is not the adapter server, and dev mode watches files that the runner stage does not contain.

The runner’s npm ci --omit=dev prints svelte-kit: not found when package.json has a prepare script. That script needs the CLI from devDependencies. The install still finishes because the generated script already ends with || echo ''.

Add node_modules to .dockerignore, or the builder’s COPY . . will replace the Linux packages with packages from the host.

Do not set HOST=127.0.0.1 in the container. Docker publishes the container’s public interface, not its loopback interface.

If ORIGIN is missing or differs from the browser URL, GET requests still look fine, but POST requests return the cross-site form error.

Do not copy .env into the image. Anyone who has the image can read it, and staging and production end up sharing the same secret.

Use $env/dynamic/private for values that differ between containers. $env/static/private requires a rebuild whenever the value changes.

An Alpine image may fail with sharp or better-sqlite3 when the addon was built for glibc. Use bookworm-slim, or install the Alpine build tools and compile the addon there.

A Mac ARM docker build produces an arm64 image. An x86 VPS cannot run it unless you pass --platform linux/amd64 or use buildx. The EC2 post covers the same kind of mismatch with AMI architecture.

Old builder layers can fill an 8 GiB Docker disk. Run docker builder prune if docker build fails because the disk is full.

depends_on: db starts Postgres first but does not wait until it accepts connections. If the app connects at import time, the first web start can fail. Retry the app, or add a healthcheck to db and use depends_on: { db: { condition: service_healthy } }.

What to add next

Deploy on EC2 uses PM2 and nginx without a container. Its Vite build can still run in CI. Building the image from this post is another way to keep that work off a 2 GiB box.

HttpOnly cookie auth stores sessions in a Map, which empties when the container restarts. Before using it for real logins, move the sessions to SQLite or Postgres.