SvelteKit is unusual among full-stack frameworks in that it doesn't have one deployment target — it has adapters, and the adapter you pick changes everything about how the app builds and runs. Most deployment problems I see with SvelteKit come down to three things: the wrong adapter, environment variables imported from the wrong module, and a missing ORIGIN variable that silently breaks every form on the site. Let's walk through all three, plus wiring up a real PostgreSQL database with migrations.
Pick adapter-node, explicitly
A fresh SvelteKit project (npx sv create my-app) ships with adapter-auto, which guesses your platform at build time. Guessing is fine for Vercel or Netlify, but for a container platform you want to be explicit. Install the Node adapter:
npm i -D @sveltejs/adapter-nodeAnd swap it in svelte.config.js:
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;Now npm run build produces a build/ directory containing a standalone Node server. You run it with:
node buildOne thing that trips people up: unlike some frameworks, adapter-node's output is not fully self-contained. It bundles your source code but expects your production node_modules and package.json to be present next to it. Keep that in mind for the Dockerfile below.
The three env vars the server actually reads
The adapter-node server is configured entirely through environment variables:
PORT— defaults to3000. Most platforms inject this; SvelteKit picks it up automatically.HOST— defaults to0.0.0.0, which is what you want in a container. Don't set it tolocalhostor nothing outside the container can reach it.ORIGIN— this is the one that bites. Set it to your public URL, e.g.ORIGIN=https://myapp.example.com.
If you skip ORIGIN, GET requests work fine and you'll think everything is healthy — then every form action fails with a 403 "cross-site POST form submissions are forbidden" error, because SvelteKit's CSRF protection can't verify the request origin behind a proxy. It's the single most common "works locally, broken in prod" report I see with this framework.
There's also BODY_SIZE_LIMIT if you accept file uploads — the default request body cap is small, so raise it (e.g. BODY_SIZE_LIMIT=10M) before your users hit it.
Static vs dynamic env: the import that matters
SvelteKit gives you four env modules, and picking the wrong one is a subtle production bug. The distinction that matters for deployment:
$env/static/private— values are inlined at build time. If the variable isn't present during the build, the build can fail or bake in an empty string.$env/dynamic/private— values are read fromprocess.envat runtime.
On any platform that injects secrets into the running container — which is how DATABASE_URL reaches your app on PandaStack — you want the dynamic module for anything infrastructure-shaped:
// src/lib/server/db/index.ts
import { env } from '$env/dynamic/private';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client);Use $env/static/private only for values that genuinely are build-time constants. If you import DATABASE_URL from the static module, your build server needs the production database credentials at compile time — which is both a hassle and a bad idea.
Adding PostgreSQL with Drizzle
The sv CLI can scaffold Drizzle for you (npx sv add drizzle), or set it up manually. Either way you end up with a schema file and a drizzle.config.ts:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! }
});The workflow is two commands: generate writes SQL migration files from your schema diff, migrate applies them:
npx drizzle-kit generate
npx drizzle-kit migrateCommit the generated SQL files in drizzle/ to the repo. In production, run drizzle-kit migrate as a separate step before the new version takes traffic — not inside the app's startup code. Two replicas booting simultaneously and both attempting the same migration is a race you don't want to debug at 2am. (Avoid drizzle-kit push in production entirely; it diffs against the live database with no migration history, which is fine for prototyping and dangerous everywhere else.)
Keep all database imports under src/lib/server/ — SvelteKit enforces that server-only modules never leak into client bundles, and this directory makes the boundary explicit.
A production Dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "build"]Note the npm prune --omit=dev in the build stage and the copied node_modules — that's the adapter-node quirk from earlier. The final image carries only production dependencies.
Deploying on PandaStack
With the adapter and env handling sorted, the deploy itself is short:
- 1Provision the database. Create a managed PostgreSQL instance (14.x or 16.x are available) from the dashboard. Backups are scheduled daily automatically.
- 2Connect the repo. Add your SvelteKit repo as a container app. If there's a Dockerfile, it's used; otherwise the Node buildpack auto-detects the framework and build command. npm, yarn, pnpm, and bun are all supported for the install step.
- 3Set
ORIGIN. Add it as an environment variable pointing at your app's URL (or your custom domain — SSL is automatic either way). This is the step people forget. - 4Attach the database. Because the Postgres instance is linked to the app,
DATABASE_URLis injected into the container at runtime — which is exactly why the$env/dynamic/privateimport above matters. No copying connection strings between tabs. - 5Push. Every
git pushtriggers a build — rootless BuildKit in an ephemeral Kubernetes job — and you can watch the build logs stream live while it runs. Runnpx drizzle-kit migrateagainst the database as your release step before the new version goes live.
One honest caveat: on the free tier, idle apps scale to zero and cold-start on the next request. For a side project that's a fair trade for $0; for anything latency-sensitive, a paid compute tier keeps the process warm.
That's the whole path: explicit adapter, runtime env, ORIGIN set, migrations as a separate step. If you want to see it end to end, connect a SvelteKit repo at https://pandastack.io and push.