Deploy a SvelteKit app

Deploy a SvelteKit app on Lucity. One adapter change stands between a fresh project and a running service.

SvelteKit is the one framework in this list where a brand new project will not deploy as-is. Not because of anything Lucity does, but because the scaffold ships with adapter-auto, which produces nothing runnable outside the four hosts it knows about. Two small changes fix it permanently.

The two changes

npx sv create my-app
cd my-app
npm install -D @sveltejs/adapter-node

Then swap the adapter. In current SvelteKit the adapter lives in vite.config.ts, not in svelte.config.js as older guides say:

// vite.config.ts
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [sveltekit({ adapter: adapter() })],
});

And add a start script, because the adapter writes a server to build/ but nothing runs it for you:

{
  "scripts": {
    "start": "node build"
  }
}

That is the whole setup. zeitlos/sveltekit-example(opens in a new tab) is a minimal project with exactly these changes applied.

Why it fails without them

Worth understanding, because the failure is quiet. With adapter-auto and no start script, the build succeeds: npm run build runs, files are produced, the image is pushed. Detection then looks for a way to start the app, finds no start script, no main field and no index.js, and derives an empty start command. The deployment has nothing to run.

You get a service that built cleanly and never becomes healthy, which is a more confusing outcome than a failed build. If you see that, this is almost always why.

With the two changes above, detection resolves the start command to npm run start, and adapter-node reads the PORT Lucity provides without further configuration.

Environment variables

SvelteKit is strict about the difference, and the strictness is useful:

  • $env/static/private is inlined at build time. Changing one of these values needs a rebuild.
  • $env/dynamic/private is read at runtime from process.env. Changing one of these takes effect on the next rollout, in seconds.
  • The PUBLIC_ prefixed variants of both are exposed to the browser.

For anything that comes from a Lucity dynamic variable, a database URL or bucket credentials, use $env/dynamic/private. Those values can change without your code changing, and a build-time import would freeze whatever was true when the image was made.

// src/routes/+page.server.ts
import { env } from '$env/dynamic/private';
import { Pool } from 'pg';

const pool = new Pool({ connectionString: env.DATABASE_URL });

export async function load() {
  const { rows } = await pool.query('SELECT title FROM posts ORDER BY votes DESC LIMIT 10');
  return { posts: rows };
}

Prerendered routes need their data at build time

If a route is prerendered and it queries a database, that database has to be reachable while the build runs, not just at runtime. Either drop the prerender on those routes or load their data from a source available during the build.

Next steps

  • Builds for what the builder detects and how to override it
  • Services for health checks, resources and replicas
  • Variables for the static and dynamic split