Framework Guide

SvelteKit + Railway Secrets Management

SvelteKit has four $env modules that control exactly when and where environment variables are accessible. Get the wrong import and your database password ships to the browser. This guide shows you how to manage secrets across all four modules — from vite dev to production on Railway.

SvelteKit's Four $env Modules

SvelteKit splits environment variables along two axes: static vs dynamic, and private vs public. Understanding this grid is critical before touching any secrets:

ModuleWhen resolvedClient accessUse for
$env/static/privateBuild timeBlocked — build error if imported in client codeDB URLs, API keys, signing secrets
$env/static/publicBuild timeExposed — inlined into JS bundleAnalytics IDs, public API URLs
$env/dynamic/privateRequest timeBlocked — server-onlySecrets that change without rebuilds
$env/dynamic/publicRequest timeExposed — sent via server renderingFeature flags, public config

SvelteKit uses Vite's env convention: variables prefixed with PUBLIC_ are exposed to client code. Everything else is private. secr stores all variables together — the prefix controls visibility at build time.

Step 1: Set Up Your Project

Terminal
npm i -g @secr/cli
secr login
secr init

Run this in your SvelteKit project root. secr creates a .secr.json config with three default environments: development, staging, and production.

Step 2: Import Your Existing .env

Terminal
# Import development secrets
secr set --from-env .env --env development

# Import production secrets if you have them
secr set --from-env .env.production --env production

SvelteKit reads from .env, .env.local, and .env.production via Vite. Import whichever files you have, then delete them.

Step 3: Local Development

Inject secrets into the Vite dev server without writing files to disk:

Terminal
# Start SvelteKit dev server with secrets
secr run -- vite dev

# Or with the npm script wrapper
secr run -- npm run dev

All four $env modules resolve from the injected environment variables. Both PUBLIC_ -prefixed and private variables work as expected.

Tip: Update your package.json so every developer uses secr:

package.json
{
  "scripts": {
    "dev": "secr run -- vite dev",
    "dev:staging": "secr run --env staging -- vite dev",
    "preview": "secr run -- vite preview"
  }
}

Step 4: Using Secrets in SvelteKit Code

Server load functions

Use $env/static/private in +page.server.ts and +layout.server.ts. SvelteKit guarantees these never reach the client:

src/routes/dashboard/+page.server.ts
import { DATABASE_URL, STRIPE_SECRET_KEY } from '$env/static/private';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const sql = postgres(DATABASE_URL);
const db = drizzle(sql);

export async function load() {
  const charges = await fetch('https://api.stripe.com/v1/charges', {
    headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` },
  }).then(r => r.json());

  // Return only what the component needs
  return { chargeCount: charges.data.length };
}

API routes

src/routes/api/webhooks/stripe/+server.ts
import { STRIPE_WEBHOOK_SECRET } from '$env/static/private';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const signature = request.headers.get('stripe-signature')!;
  const body = await request.text();

  // Verify using the secret from secr
  const event = stripe.webhooks.constructEvent(
    body, signature, STRIPE_WEBHOOK_SECRET
  );

  // Handle the event...
  return new Response('ok');
};

Hooks

src/hooks.server.ts
import { SESSION_SECRET } from '$env/static/private';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionToken = event.cookies.get('session');

  if (sessionToken) {
    // Verify against your auth service using the secret
    event.locals.user = await verifySession(sessionToken, SESSION_SECRET);
  }

  return resolve(event);
};

Public variables for client code

Variables prefixed with PUBLIC_ are accessible in Svelte components and client-side code:

src/routes/+layout.svelte
<script>
  import { PUBLIC_POSTHOG_KEY, PUBLIC_APP_URL } from '$env/static/public';
  import posthog from 'posthog-js';

  posthog.init(PUBLIC_POSTHOG_KEY, {
    api_host: 'https://app.posthog.com',
  });
</script>
Terminal
# Store both in secr — the PUBLIC_ prefix controls visibility
secr set PUBLIC_POSTHOG_KEY "phc_abc123" \
  --description "PostHog key (client-safe, PUBLIC_ prefix)"

secr set DATABASE_URL "postgresql://..." \
  --description "Production DB (server-only, no PUBLIC_ prefix)"

Static vs Dynamic: Use $env/static/* by default — values are inlined at build time for better performance. Switch to $env/dynamic/* only when you need secrets to change without rebuilding (e.g. rotating keys on a long-running adapter-node server).

Step 5: Deploy to Railway

Railway builds your app from source using Nixpacks. You'll install the secr CLI as a dev dependency and pull secrets during the build step.

1. Use adapter-node

Railway runs Node.js servers, so make sure your SvelteKit app uses adapter-node:

svelte.config.js
import adapter from '@sveltejs/adapter-node';

export default {
  kit: {
    adapter: adapter()
  }
};

2. Add secr CLI as a dependency

npm install --save-dev @secr/cli

3. Create a deploy token

secr token create --name "railway-prod"

4. Set variables in Railway

In your Railway service settings, add these environment variables:

VariableValuePurpose
SECR_TOKENsecr_tok_...Authenticates the CLI during build
SECR_ORGmy-orgYour secr organization slug
SECR_PROJECTmy-sveltekit-appYour secr project slug

5. Configure the build command

In Railway's service settings under Build → Custom Build Command:

Build Command
npx secr pull --format dotenv --env production > .env && vite build

This pulls secrets into a .env file that Vite reads during vite build. The file exists only in the ephemeral build container and is discarded after the build completes.

6. Runtime secrets (adapter-node)

If you use $env/dynamic/private for secrets that need to resolve at request time (not baked in at build), set the start command to inject secrets at runtime:

Start Command
npx secr run --env production -- node build

This injects secrets into process.env before the Node server starts. Use this approach when you need to rotate secrets without redeploying.

Railway Environments

Railway supports multiple environments per project. Map each Railway environment to a secr environment by setting different values for SECR_ENVIRONMENT in each:

Railway EnvironmentBuild commandsecr env
Productionnpx secr pull --format dotenv --env production > .env && vite buildproduction
Stagingnpx secr pull --format dotenv --env staging > .env && vite buildstaging
PR Deploynpx secr pull --format dotenv --env development > .env && vite builddevelopment

Or use a single build command with a variable: set SECR_ENVIRONMENT=staging in Railway's staging environment, then use npx secr pull --format dotenv --env $SECR_ENVIRONMENT > .env && vite build as the build command for all environments.

CI: Run Tests with Secrets

.github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Pull secrets
        uses: secr-dev/secr-action@v1
        with:
          token: ${{ secrets.SECR_TOKEN }}
          org: my-org
          project: my-sveltekit-app
          environment: staging

      - run: npm ci
      - run: npm run test
      - run: npm run build
      - run: npm run check  # svelte-check

Scan for Leaked Secrets

SvelteKit projects often accumulate .env, .env.local, and .env.production files from Vite's convention. Scan and clean up:

Terminal
# Scan the project
secr scan

# Block future leaks with a pre-commit hook
secr guard install

Common SvelteKit Patterns

Database in a server module

src/lib/server/db.ts
import { DATABASE_URL } from '$env/static/private';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

// This file can only be imported in server code
// SvelteKit enforces this via the $lib/server convention
const sql = postgres(DATABASE_URL);
export const db = drizzle(sql);

Files under src/lib/server/ cannot be imported in client code. Combined with $env/static/private, you get two layers of protection.

Form actions with secrets

src/routes/contact/+page.server.ts
import { RESEND_API_KEY } from '$env/static/private';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email') as string;

    await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'noreply@example.com',
        to: email,
        subject: 'Thanks for reaching out',
        text: 'We got your message.',
      }),
    });

    return { success: true };
  },
};

Templates for required variables

Terminal
secr template add DATABASE_URL
secr template add SESSION_SECRET
secr template add STRIPE_SECRET_KEY
secr template add PUBLIC_POSTHOG_KEY
secr template add PUBLIC_APP_URL

# Validate before deploying
secr template validate --env production

Complete Workflow

Local dev

secr run -- vite dev injects secrets into the process. All four $env modules resolve correctly.

Railway builds

secr pull writes .env before vite build. Static $env values are inlined. File discarded after build.

Runtime (adapter-node)

secr run -- node build injects secrets at startup for $env/dynamic/* modules.

Secret scanning

secr guard blocks commits containing secrets. Delete your local .env files entirely.

Ship SvelteKit apps without .env files

npm i -g @secr/cli

secr init

secr run -- vite dev