Guide · Configuration
MCP server environment variables
MCP servers are mostly thin protocol wrappers over tool implementations that call external APIs, databases, or file systems. Every one of those integrations needs secrets. The wrong pattern — hardcoding keys, committing .env files, or logging the full environment at startup — is the most common cause of credential exposure in MCP server deployments. The right pattern depends on your platform, but the structure is the same everywhere: load at startup, validate immediately, fail fast if required values are missing, and never let secret values reach your log output.
TL;DR
Use dotenv for local development only — never load it in production. Inject production secrets via your platform's native secret store (Fly.io flyctl secrets set, Railway dashboard, Render environment tab, Kubernetes Secret resources). Write a config-validation function that runs at server startup and throws with a specific message if required variables are missing — this prevents the common case where a server starts but fails on the first tool call because process.env.OPENAI_API_KEY is undefined. Never log secret values, even at debug level; structured log redaction is not reliable enough to catch every code path.
Config validation at startup
The most important environment variable practice for MCP servers is validating required config at startup and failing immediately if anything is missing. Without this, a missing API_KEY causes the server to start successfully, pass the initialize probe, and then return a JSON-RPC error on the first real tool call — often 10–30 minutes after deploy when a user actually invokes the tool.
// config.js — load and validate at module import time
const REQUIRED = [
'PORT',
'OPENAI_API_KEY',
'DATABASE_URL',
];
const OPTIONAL_WITH_DEFAULTS = {
NODE_ENV: 'production',
LOG_LEVEL: 'info',
PROBE_TIMEOUT_MS: '5000',
MAX_SESSIONS: '100',
};
function loadConfig() {
// In development only: load .env file
if (process.env.NODE_ENV !== 'production') {
const { config } = await import('dotenv');
config(); // loads .env into process.env
}
const missing = REQUIRED.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. ` +
'Check your platform secret store or .env file.'
);
}
return {
port: parseInt(process.env.PORT, 10),
openaiApiKey: process.env.OPENAI_API_KEY,
databaseUrl: process.env.DATABASE_URL,
nodeEnv: process.env.NODE_ENV ?? OPTIONAL_WITH_DEFAULTS.NODE_ENV,
logLevel: process.env.LOG_LEVEL ?? OPTIONAL_WITH_DEFAULTS.LOG_LEVEL,
probeTimeoutMs: parseInt(
process.env.PROBE_TIMEOUT_MS ?? OPTIONAL_WITH_DEFAULTS.PROBE_TIMEOUT_MS, 10
),
maxSessions: parseInt(
process.env.MAX_SESSIONS ?? OPTIONAL_WITH_DEFAULTS.MAX_SESSIONS, 10
),
};
}
export const config = loadConfig();
Notice that config.openaiApiKey is a named field with the value. But the exported config object should never be passed to a logger or serialized to JSON. The pattern of pulling all env vars into a typed config object at startup serves two purposes: validation (missing vars throw immediately) and centralisation (searching for process.env. elsewhere in the codebase is a code review smell). See MCP server logging for structured log redaction patterns.
Platform-specific injection
Each deployment platform has its own mechanism for injecting secrets into running containers. None of them involve editing a file inside the container at runtime — the secrets are injected as environment variables when the container starts.
Fly.io
# Set secrets — encrypted at rest, injected at container start
flyctl secrets set OPENAI_API_KEY=sk-... DATABASE_URL=postgres://...
# List current secret names (values are never shown)
flyctl secrets list
# Import from a local .env file (development-only, do not use in CI pipelines)
flyctl secrets import < .env
Fly.io secrets are injected as environment variables into every instance. They survive deploys and scale-up events. After running flyctl secrets set, you need to trigger a new deploy (flyctl deploy) for running instances to pick up the new values.
Railway
# Railway injects variables set in the dashboard as env vars
# at container startup. No CLI command needed — use the Variables tab.
# For automated injection from CI:
railway variables set OPENAI_API_KEY=sk-... --service your-service
Docker Compose (VPS self-hosted)
# docker-compose.yml — use env_file for local dev, never commit the file
services:
mcp:
image: your-image
env_file:
- .env.production # never commit — add to .gitignore
# Or inject from the host environment:
services:
mcp:
image: your-image
environment:
- OPENAI_API_KEY # reads OPENAI_API_KEY from host shell
- DATABASE_URL
Kubernetes
# Create a Secret resource (base64-encoded values)
kubectl create secret generic mcp-secrets \
--from-literal=OPENAI_API_KEY=sk-... \
--from-literal=DATABASE_URL=postgres://...
# Reference it in your Deployment's envFrom:
spec:
containers:
- name: mcp
image: your-image
envFrom:
- secretRef:
name: mcp-secrets
For Kubernetes, prefer External Secrets Operator over manually created Secret resources — it syncs secrets from AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault into Kubernetes Secret resources automatically, and rotates them without requiring a pod restart.
The .env file pattern
.env files are for local development only. The three rules:
- Always add
.env,.env.*to.gitignore— before the first commit. If you forget, usegit rm --cached .envto remove it from tracking. A committed.envfile is a security incident even if you rotate the keys immediately, because the key value is in git history. - Commit a
.env.examplefile with all required variable names and placeholder values (OPENAI_API_KEY=your-key-here). This documents required config without exposing values. - Never load
dotenvin production — guard it withif (process.env.NODE_ENV !== 'production'). Production environment variables come from the platform's secret injection mechanism, not from a file on disk inside the container.
# .env.example — commit this file
PORT=3001
OPENAI_API_KEY=your-openai-api-key
DATABASE_URL=postgres://user:password@localhost:5432/mcp_dev
LOG_LEVEL=debug
NODE_ENV=development
# .gitignore — add this early
.env
.env.*
!.env.example
Runtime config versus secrets
Not all environment variables are secrets. Distinguish between configuration (safe to log, safe to expose in health endpoints) and secrets (never log, never expose):
| Variable | Type | Safe to log? |
|---|---|---|
PORT | Config | Yes |
NODE_ENV | Config | Yes |
LOG_LEVEL | Config | Yes |
MAX_SESSIONS | Config | Yes |
OPENAI_API_KEY | Secret | Never |
DATABASE_URL | Secret | Never (contains password) |
WEBHOOK_SECRET | Secret | Never |
A safe startup log line logs only configuration values, never secrets:
// SAFE: log only non-secret config
logger.info('Server starting', {
port: config.port,
nodeEnv: config.nodeEnv,
logLevel: config.logLevel,
maxSessions: config.maxSessions,
// intentionally omitted: openaiApiKey, databaseUrl, webhookSecret
});
Rotating secrets without downtime
Secret rotation is most dangerous during the window between updating the secret value and redeploying the server. During that window, the running server uses the old key while the new key is active. The pattern to avoid a gap:
- Issue a new API key (the old key is still valid).
- Update the secret in your platform's store (
flyctl secrets set OPENAI_API_KEY=new-key). - Deploy immediately — the running server picks up the new key on restart.
- Verify the post-deploy probe passes (
initialize→tools/list→ tool call). - Revoke the old API key only after the probe passes.
Never revoke the old key before the deploy completes. The window where the old key is invalid but the new key isn't yet loaded is when availability drops. AliveMCP's probe will catch this window if it's longer than 60 seconds — a failed probe immediately after a deploy with a recent secret change is a reliable signal that the rotation window was too wide.
Related questions
Should I use a secrets manager (AWS Secrets Manager, HashiCorp Vault) or platform-native secrets?
For most indie MCP server teams, platform-native secrets (Fly.io secrets, Railway variables, Render environment) are sufficient and have zero operational overhead. A dedicated secrets manager adds value when you have more than one deployment platform, need fine-grained access control per secret, want automated rotation, or need an audit log of every secret access. Start with platform-native and migrate to a secrets manager when the audit or rotation requirements justify the complexity.
How do I pass secrets to a Dockerfile build step?
Don't. Secrets baked into Docker image layers are accessible by anyone with access to the image, including in history layers. Use Docker BuildKit's --secret flag for build-time secrets that don't persist in the image: RUN --mount=type=secret,id=npm_token npm install. For runtime secrets (API keys the server uses when handling requests), inject via environment variables at container start — never ENV in the Dockerfile.
What happens if a required environment variable is missing in production?
Without startup validation, the server starts and passes the initialize probe — AliveMCP shows it as healthy. The first user who calls the tool gets a Cannot read properties of undefined (reading 'some-method') unhandled exception that crashes their session. With the config validation pattern above, the server fails to start immediately with a clear error message, the post-deploy probe fails, and the CI rollback triggers automatically. Fail fast at startup is always better than failing silently at runtime.
Further reading
- MCP server logging — structured logs, redaction, and retention
- MCP server CI/CD — automated build, test, deploy, and rollback
- MCP server Docker — containerization, env_file, and health checks
- MCP server Kubernetes — Secrets, External Secrets Operator, and envFrom
- MCP server deployment — post-deploy verification checklist
- MCP server security monitoring — credential monitoring and auth anomalies
- AliveMCP — production monitoring that detects the first minute of downtime after a bad deploy