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:
- Node.js 24 LTS has stable native
--env-filesupport — dotenv is no longer necessary - Railway replaced Nixpacks with Railpack — smaller images, better secret handling via BuildKit
- AI coding agents read every file in your project, including
.env— your secrets end up in LLM context windows
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, andSTRIPE_KEYnever touch the filesystem. - It's fast. Secrets are cached locally (encrypted) after the first fetch. Subsequent
keyway runcalls 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 readsprocess.env. - Environment variables set in the shell still take precedence. This is how Railway (and every other PaaS) injects config at runtime — the
startscript has nokeyway 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:
| Field | What it does |
|---|---|
healthcheckPath | Railway hits this endpoint after deploy — if it fails, the deploy rolls back |
drainingSeconds | Seconds between SIGTERM and SIGKILL — gives your server time to finish requests |
restartPolicyType | ON_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:
- Rotate the exposed secret immediately
- Delete the
.envfile:rm .env - 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
.envfile on disk — usekeyway runfor local development -
.envis still in.gitignore(safety net) -
.env.exampleis 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.jsonusesRAILPACKbuilder (not NIXPACKS) -
drainingSecondsconfigured for zero-downtime deploys - CI/CD pulls secrets before running migrations
- Secrets never logged — not even in development
Further Reading
- Keyway Security: AES-256-GCM Encryption & Zero-Trust Secrets — how Keyway protects your secrets with an isolated crypto service
Have questions about this workflow? Read the docs or try Keyway free.