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:

# .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):

VariableTypeSafe to log?
PORTConfigYes
NODE_ENVConfigYes
LOG_LEVELConfigYes
MAX_SESSIONSConfigYes
OPENAI_API_KEYSecretNever
DATABASE_URLSecretNever (contains password)
WEBHOOK_SECRETSecretNever

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:

  1. Issue a new API key (the old key is still valid).
  2. Update the secret in your platform's store (flyctl secrets set OPENAI_API_KEY=new-key).
  3. Deploy immediately — the running server picks up the new key on restart.
  4. Verify the post-deploy probe passes (initializetools/list → tool call).
  5. 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