Back to Blog
Tutorial6 min read2026-07-13

How to Deploy SvelteKit with a Managed PostgreSQL Database

Deploy SvelteKit with adapter-node: the ORIGIN gotcha, runtime vs build-time env vars, Drizzle migrations, and going live with a Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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

And 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 build

One 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 to 3000. Most platforms inject this; SvelteKit picks it up automatically.
  • HOST — defaults to 0.0.0.0, which is what you want in a container. Don't set it to localhost or 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 from process.env at 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 migrate

Commit 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:

  1. 1Provision the database. Create a managed PostgreSQL instance (14.x or 16.x are available) from the dashboard. Backups are scheduled daily automatically.
  2. 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.
  3. 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.
  4. 4Attach the database. Because the Postgres instance is linked to the app, DATABASE_URL is injected into the container at runtime — which is exactly why the $env/dynamic/private import above matters. No copying connection strings between tabs.
  5. 5Push. Every git push triggers a build — rootless BuildKit in an ephemeral Kubernetes job — and you can watch the build logs stream live while it runs. Run npx drizzle-kit migrate against 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also