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:
| Module | When resolved | Client access | Use for |
|---|---|---|---|
| $env/static/private | Build time | Blocked — build error if imported in client code | DB URLs, API keys, signing secrets |
| $env/static/public | Build time | Exposed — inlined into JS bundle | Analytics IDs, public API URLs |
| $env/dynamic/private | Request time | Blocked — server-only | Secrets that change without rebuilds |
| $env/dynamic/public | Request time | Exposed — sent via server rendering | Feature 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
npm i -g @secr/cli
secr login
secr initRun 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
# Import development secrets
secr set --from-env .env --env development
# Import production secrets if you have them
secr set --from-env .env.production --env productionSvelteKit 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:
# Start SvelteKit dev server with secrets
secr run -- vite dev
# Or with the npm script wrapper
secr run -- npm run devAll 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:
{
"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:
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
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
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:
<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># 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:
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter()
}
};2. Add secr CLI as a dependency
npm install --save-dev @secr/cli3. Create a deploy token
secr token create --name "railway-prod"4. Set variables in Railway
In your Railway service settings, add these environment variables:
| Variable | Value | Purpose |
|---|---|---|
| SECR_TOKEN | secr_tok_... | Authenticates the CLI during build |
| SECR_ORG | my-org | Your secr organization slug |
| SECR_PROJECT | my-sveltekit-app | Your secr project slug |
5. Configure the build command
In Railway's service settings under Build → Custom Build Command:
npx secr pull --format dotenv --env production > .env && vite buildThis 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:
npx secr run --env production -- node buildThis 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 Environment | Build command | secr env |
|---|---|---|
| Production | npx secr pull --format dotenv --env production > .env && vite build | production |
| Staging | npx secr pull --format dotenv --env staging > .env && vite build | staging |
| PR Deploy | npx secr pull --format dotenv --env development > .env && vite build | development |
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
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-checkScan for Leaked Secrets
SvelteKit projects often accumulate .env, .env.local, and .env.production files from Vite's convention. Scan and clean up:
# Scan the project
secr scan
# Block future leaks with a pre-commit hook
secr guard installCommon SvelteKit Patterns
Database in a server module
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
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
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 productionComplete 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