NEWNow open source & self-hostable. Star us on GitHub →
·10 min·By Nicolas Ritouet

Fastify + Railway: How We Actually Manage Environment Variables

Our real workflow for managing environment variables in a Fastify 5 API deployed on Railway. No .env file on disk, Zod validation, Pino secret redaction, Railpack builds, and keyway run for zero-trust secrets injection.

TL;DR: We run Fastify 5 on Railway. We don't use .env files — not even locally. keyway run injects secrets directly into process memory. Zod validates everything at startup. Pino redacts sensitive headers from logs. Here's the exact setup.


Why This Article Exists

There are hundreds of "how to use environment variables in Node.js" articles. Most are outdated. They tell you to install dotenv, create a .env file, and call it a day.

The landscape has changed:

That last point is why we stopped using .env files entirely. Not just in production — locally too.

This article is our actual production workflow. Not a tutorial — a reference.


Project Structure

my-fastify-api/
├── src/
│   ├── server.ts         # Entry point
│   ├── env.ts            # Environment validation
│   ├── routes/
│   │   ├── auth.ts
│   │   └── users.ts
│   └── db/
│       └── index.ts      # Drizzle + node-postgres
├── .env.example          # Variable names only (committed)
├── railway.json          # Deploy config
├── drizzle.config.ts
└── package.json

Notice what's missing: no .env file. Secrets live in Keyway, encrypted with AES-256-GCM. They're injected into memory at runtime via keyway run. Nothing on disk for AI agents, scripts, or accidental git add . to pick up.

Your .gitignore — still important as a safety net:

.env
.env.local
.env.*.local
*.pem
*.key

Step 1: Validate Environment Variables at Startup

Don't sprinkle process.env.SOMETHING across your codebase. Validate everything once, at startup, and crash immediately if something is missing.

// src/env.ts
import { z } from 'zod'

const envSchema = z.object({
  // Server
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  HOST: z.string().default('0.0.0.0'),

  // Database
  DATABASE_URL: z.string().url(),

  // Auth
  JWT_SECRET: z.string().min(32),
  GITHUB_CLIENT_ID: z.string(),
  GITHUB_CLIENT_SECRET: z.string(),

  // Encryption
  ENCRYPTION_KEY: z.string().length(64), // 32 bytes hex-encoded

  // Railway-provided (optional locally)
  RAILWAY_PUBLIC_DOMAIN: z.string().optional(),
  RAILWAY_ENVIRONMENT_NAME: z.string().optional(),
})

export type Env = z.infer<typeof envSchema>

function loadEnv(): Env {
  const parsed = envSchema.safeParse(process.env)

  if (!parsed.success) {
    console.error('Invalid environment variables:')
    console.error(parsed.error.flatten().fieldErrors)
    process.exit(1)
  }

  return parsed.data
}

export const env = loadEnv()

This works identically whether secrets come from a .env file, from Railway's runtime injection, or from keyway run. The Zod schema doesn't care where process.env was populated — it just validates what's there.

Why Zod instead of @fastify/env? We use Zod everywhere else in the codebase (request validation, API contracts). Using the same library for env validation means one schema language, not two. If your team already uses JSON Schema and Ajv, @fastify/env is a perfectly valid choice.

The key point: your app should crash at startup if config is wrong — not when a user hits the broken code path 3 weeks later.


Step 2: No .env File — Use keyway run

Most guides tell you to use dotenv or Node.js native --env-file. Both approaches write secrets to a .env file on disk. That file is readable by every process on your machine — including AI coding agents like Cursor, Claude Code, and Copilot. Your database password ends up in an LLM context window.

Our approach: no .env file at all.

{
  "scripts": {
    "dev": "keyway run -- node --watch src/server.ts",
    "start": "node src/server.js"
  }
}

keyway run fetches secrets from Keyway's API, decrypts them, and injects them as environment variables directly into the child process memory. No file is written to disk. When the process stops, the secrets vanish.

$ keyway run -- node --watch src/server.ts
# ✓ 7 secrets injected into process memory
# Server running at http://0.0.0.0:3000

$ cat .env
# cat: .env: No such file or directory

$ ls -la | grep env
# (nothing — there's no .env file)

A few things to know:

  • AI agents see nothing. There's no file on disk to read. Your DATABASE_URL, JWT_SECRET, and STRIPE_KEY never touch the filesystem.
  • It's fast. Secrets are cached locally (encrypted) after the first fetch. Subsequent keyway run calls are near-instant.
  • It works with any command. keyway run -- npm test, keyway run -- npx drizzle-kit migrate, keyway run -- docker compose up. Anything that reads process.env.
  • Environment variables set in the shell still take precedence. This is how Railway (and every other PaaS) injects config at runtime — the start script has no keyway run, Railway handles injection.

What about --env-file?

Node.js 24 LTS has stable native --env-file support. It's a solid improvement over dotenv — one fewer dependency. But it still requires a .env file on disk, which means your secrets are still exposed to AI agents and any process that can read files.

If you're not ready to drop .env files entirely, --env-file is the next best option:

{
  "scripts": {
    "dev": "node --env-file=.env --watch src/server.ts"
  }
}

But we recommend keyway run instead.


Step 3: Fastify Server With Secret Redaction

The server setup itself is straightforward. The important part most people skip: Pino log redaction.

Without redaction, every request log might contain authorization headers, and any console.log(config) during debugging leaks your database URL to whatever log aggregator you use.

// src/server.ts
import Fastify from 'fastify'
import { env } from './env.js'

const fastify = Fastify({
  logger: {
    level: env.NODE_ENV === 'production' ? 'info' : 'debug',
    transport: env.NODE_ENV === 'development'
      ? { target: 'pino-pretty' }
      : undefined,
    redact: {
      paths: [
        'req.headers.authorization',
        'req.headers.cookie',
        'req.headers["x-api-key"]',
      ],
      censor: '[REDACTED]',
    },
  },
})

// Health check — required for Railway healthcheck
fastify.get('/health', async () => ({
  status: 'ok',
  environment: env.RAILWAY_ENVIRONMENT_NAME ?? 'local',
  timestamp: new Date().toISOString(),
}))

// Graceful shutdown — Railway sends SIGTERM before killing the container
const shutdown = async (signal: string) => {
  fastify.log.info(`Received ${signal}, shutting down...`)
  await fastify.close()
  process.exit(0)
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))

// Start
const start = async () => {
  try {
    await fastify.listen({ port: env.PORT, host: env.HOST })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

Why graceful shutdown matters on Railway: Railway sends SIGTERM when redeploying. By default, it waits 5 seconds before sending SIGKILL. If your server doesn't handle SIGTERM, in-flight requests get dropped. You can extend this with drainingSeconds in railway.json.


Step 4: Database Connection (Drizzle + node-postgres)

// src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import { env } from '../env.js'
import * as schema from './schema.js'

const pool = new Pool({
  connectionString: env.DATABASE_URL,
  // Railway Postgres uses SSL in production
  ssl: env.NODE_ENV === 'production'
    ? { rejectUnauthorized: false }
    : false,
  // Connection pool tuning for Railway
  max: 10,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
})

export const db = drizzle(pool, { schema })

For Drizzle Kit migrations, use keyway run to inject the DATABASE_URL:

keyway run -- npx drizzle-kit migrate
keyway run -- npx drizzle-kit studio
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

Note on rejectUnauthorized: false: Railway's internal Postgres uses self-signed certificates. This is fine when your API and database are on the same private network inside Railway. If you're connecting to an external database with a proper certificate, set this to true.


Step 5: Railway Configuration (Railpack)

Railway replaced Nixpacks with Railpack in March 2025. Railpack produces smaller images (38% smaller for Node.js), has better caching, and handles secrets properly using BuildKit — meaning your environment variables never appear in build logs or the final image.

New services use Railpack by default. If you have an existing service still on Nixpacks, update the builder in your service settings or in railway.json:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "RAILPACK"
  },
  "deploy": {
    "startCommand": "node src/server.js",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 3,
    "drainingSeconds": 10
  }
}

Key settings:

FieldWhat it does
healthcheckPathRailway hits this endpoint after deploy — if it fails, the deploy rolls back
drainingSecondsSeconds between SIGTERM and SIGKILL — gives your server time to finish requests
restartPolicyTypeON_FAILURE restarts crashed containers without restarting deliberate shutdowns

Environment-specific overrides

You can override deploy config per Railway environment:

{
  "deploy": {
    "startCommand": "node src/server.js"
  },
  "environments": {
    "production": {
      "deploy": {
        "startCommand": "node --max-old-space-size=512 src/server.js"
      }
    }
  }
}

Step 6: Initial Setup & Team Workflow

First-time setup

# Install Keyway CLI
npm install -g @keywaysh/cli

# Login with GitHub (one time)
keyway login

# Initialize in your project
keyway init

# If migrating from .env files: push existing secrets, then delete the file
keyway push
rm .env

# Connect Railway for auto-sync
keyway integrations add railway

Daily workflow

# Add or update a secret via the web dashboard or CLI
keyway set STRIPE_SECRET_KEY sk_live_xxx

# Sync to Railway
keyway sync railway

# Run locally — secrets injected in memory
keyway run -- node --watch src/server.ts

New team member joins

git clone git@github.com:your-org/my-fastify-api.git
cd my-fastify-api
npm install
keyway run -- npm run dev
# ✓ 7 secrets injected — running in 30 seconds

No Slack messages. No "ask John for the API key." No .env file to find or create. If they have GitHub access to the repo, keyway run works immediately.

Environment mapping

┌─────────────────┬────────────────────┬────────────────────┐
│ Keyway Env      │ Railway Service    │ Usage              │
├─────────────────┼────────────────────┼────────────────────┤
│ development     │ (local only)       │ keyway run         │
│ staging         │ api-staging        │ PR previews        │
│ production      │ api-production     │ main branch        │
└─────────────────┴────────────────────┴────────────────────┘
# Run with staging secrets
keyway run --env staging -- node --watch src/server.ts

# Sync only production to Railway
keyway sync railway --env production

# Run migrations against production (careful!)
keyway run --env production -- npx drizzle-kit migrate

CI/CD: GitHub Actions

In CI, there's no keyway run — you use keyway pull to write a temporary .env for the migration step, then Railway injects variables at runtime.

# .github/workflows/deploy.yml
name: Deploy to Railway

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '24'

      - name: Install dependencies
        run: npm ci

      - name: Pull secrets & run migrations
        run: |
          npm install -g @keywaysh/cli
          keyway pull --env production
          npx drizzle-kit migrate
        env:
          KEYWAY_TOKEN: ${{ secrets.KEYWAY_TOKEN }}

      - name: Deploy to Railway
        uses: railwayapp/railway-action@v1
        with:
          railway_token: ${{ secrets.RAILWAY_TOKEN }}

Or use the Keyway GitHub Action directly:

      - name: Pull secrets
        uses: keywaysh/action@v1
        with:
          token: ${{ secrets.KEYWAY_TOKEN }}
          environment: production

Why keyway pull in CI instead of keyway run? CI runners are ephemeral and isolated — there's no AI agent reading files. The .env file is destroyed when the runner terminates. For migrations specifically, keyway run -- npx drizzle-kit migrate works too if you prefer consistency.


Common Errors & Fixes

"Invalid environment variables" at startup

Invalid environment variables:
{ DATABASE_URL: [ 'Required' ] }

The Zod validation caught a missing variable. Make sure you're running with keyway run:

# Wrong — no secrets available
node --watch src/server.ts

# Right — secrets injected from Keyway
keyway run -- node --watch src/server.ts

"ECONNREFUSED 127.0.0.1:5432" on Railway

You're connecting to localhost instead of Railway's Postgres. Your DATABASE_URL should look like:

postgresql://postgres:xxx@containers-us-west-xxx.railway.app:5432/railway

Make sure you synced the right environment:

keyway sync railway --env production

Railway keeps using old environment values

Railway caches aggressively. After syncing new values, trigger a redeploy:

# Via Railway CLI
railway redeploy

# Or just push an empty commit
git commit --allow-empty -m "trigger redeploy" && git push

AI agent autocompleted a secret

If you're still using .env files, your AI tool read it. Three steps:

  1. Rotate the exposed secret immediately
  2. Delete the .env file: rm .env
  3. Switch to keyway run — no file on disk means nothing for AI to read

The Complete Flow

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│  Developer  │      │   Keyway    │      │   Railway   │
│   Machine   │      │  (encrypted)│      │  (deployed) │
└──────┬──────┘      └──────┬──────┘      └──────┬──────┘
       │                    │                    │
       │  keyway set        │                    │
       │───────────────────>│                    │
       │                    │                    │
       │  keyway run        │                    │
       │<───────────────────│                    │
       │  (secrets in       │                    │
       │   memory only,     │                    │
       │   no .env file)    │                    │
       │                    │                    │
       │                    │  keyway sync       │
       │                    │  railway           │
       │                    │───────────────────>│
       │                    │                    │
       │                    │   auto-redeploy    │
       │                    │<─ ─ ─ ─ ─ ─ ─ ─ ─ │
       │                    │                    │
       │        GitHub Auth (repo access)        │
       │<─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│

Checklist

Before you ship, make sure:

  • Zod schema validates all env vars at startup
  • No .env file on disk — use keyway run for local development
  • .env is still in .gitignore (safety net)
  • .env.example is committed with variable names (no values)
  • No import 'dotenv/config' anywhere in the codebase
  • Pino redacts authorization, cookie, and any custom auth headers
  • Health check endpoint at /health
  • Graceful shutdown handles SIGTERM
  • railway.json uses RAILPACK builder (not NIXPACKS)
  • drainingSeconds configured for zero-downtime deploys
  • CI/CD pulls secrets before running migrations
  • Secrets never logged — not even in development


Further Reading


Have questions about this workflow? Read the docs or try Keyway free.

Stop sharing secrets on Slack

Keyway syncs your environment variables securely. Free for open source.