Containerization

Docker Secrets and Configuration Management

Comprehensive guide to managing secrets and configuration in Docker containers, covering Docker secrets, environment variables, config files, secret rotation, and security best practices for Node.js applications.

Docker Secrets and Configuration Management

Every application needs configuration — database URLs, API keys, feature flags, service endpoints. How you inject that configuration into containers determines your security posture, operational flexibility, and deployment workflow. Get it wrong and you have secrets baked into image layers, scattered across environment variables, or hardcoded in source files. Get it right and you have a clean separation between code and config with secrets that rotate without redeployment. This guide covers every approach from simple environment variables to Docker Swarm secrets and Kubernetes-native secret management.

Prerequisites

  • Docker and Docker Compose
  • Familiarity with Node.js environment variable handling
  • Basic understanding of container security concepts
  • Optional: Docker Swarm or Kubernetes cluster for secrets management sections

The Configuration Hierarchy

Not all configuration is equal. I categorize it into three tiers:

Tier 1: Static Config — port numbers, log levels, feature flags. Not sensitive. Can live in environment variables or config files committed to version control.

Tier 2: Environment-Specific Config — database hostnames, service URLs, cache TTLs. Not sensitive but changes per environment. Inject via environment variables or .env files.

Tier 3: Secrets — passwords, API keys, tokens, certificates. Sensitive. Must never appear in image layers, environment variable listings, or logs.

// config.js — handle all three tiers
var config = {
  // Tier 1: Static (can have defaults)
  port: parseInt(process.env.PORT) || 3000,
  logLevel: process.env.LOG_LEVEL || 'info',
  enableCache: process.env.ENABLE_CACHE !== 'false',

  // Tier 2: Environment-specific (no defaults in production)
  databaseHost: process.env.DATABASE_HOST,
  redisUrl: process.env.REDIS_URL,

  // Tier 3: Secrets (loaded from files or env)
  databasePassword: loadSecret('DATABASE_PASSWORD'),
  apiKey: loadSecret('API_KEY'),
  jwtSecret: loadSecret('JWT_SECRET')
};

function loadSecret(name) {
  var fs = require('fs');
  var secretPath = '/run/secrets/' + name.toLowerCase();

  // Try file-based secret first (Docker Swarm / Kubernetes)
  try {
    return fs.readFileSync(secretPath, 'utf8').trim();
  } catch (e) {
    // Fall back to environment variable
    return process.env[name];
  }
}

// Validate required config
var required = ['databaseHost', 'databasePassword', 'jwtSecret'];
var missing = required.filter(function(key) { return !config[key]; });
if (missing.length > 0) {
  console.error('Missing required configuration:', missing.join(', '));
  process.exit(1);
}

module.exports = config;

Environment Variables

The most common approach for containerized applications. The Twelve-Factor App methodology recommends environment variables for all configuration.

Docker Run

docker run \
  -e NODE_ENV=production \
  -e PORT=3000 \
  -e DATABASE_URL=postgresql://user:pass@db:5432/myapp \
  myapp:latest

Docker Compose

services:
  api:
    image: myapp:latest
    environment:
      - NODE_ENV=production
      - PORT=3000
      - LOG_LEVEL=info
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
      - REDIS_URL=redis://redis:6379

.env Files

# .env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@db:5432/myapp
REDIS_URL=redis://redis:6379
JWT_SECRET=super-secret-key-here
services:
  api:
    env_file:
      - .env

Security concern: .env files with secrets should NEVER be committed to version control. Add to .gitignore:

.env
.env.local
.env.production

Commit a template instead:

# .env.example (safe to commit)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://devuser:devpass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-in-production

Environment Variable Limitations

  1. Visible in process listings. docker inspect shows all environment variables.
  2. Inherited by child processes. Every exec or spawn inherits the full environment.
  3. Logged accidentally. Many frameworks log environment on startup.
  4. No rotation without restart. Changing an env var requires container restart.
# Anyone with Docker access can see secrets
docker inspect myapp --format '{{json .Config.Env}}' | python -m json.tool
# [
#   "DATABASE_URL=postgresql://user:SuperSecretPassword@db:5432/myapp",
#   "JWT_SECRET=my-production-secret"
# ]

For Tier 3 secrets, environment variables are insufficient.

Docker Swarm Secrets

Docker Swarm provides encrypted, access-controlled secret management. Secrets are stored encrypted in the Raft log and mounted as files inside containers.

# Create a secret
echo "SuperSecretPassword" | docker secret create db_password -

# Or from a file
docker secret create tls_cert ./server.crt

# List secrets
docker secret ls
# ID            NAME          CREATED
# abc123def     db_password   2 hours ago
# ghi789jkl     tls_cert      2 hours ago

Secrets are mounted at /run/secrets/<name> inside the container:

# docker-compose.yml (Swarm mode)
version: "3.8"

services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - jwt_secret
      - api_key
    environment:
      - DATABASE_HOST=postgres
      - DATABASE_USER=appuser
      - DATABASE_NAME=myapp

secrets:
  db_password:
    external: true
  jwt_secret:
    external: true
  api_key:
    external: true
// Reading Swarm secrets in Node.js
var fs = require('fs');

function readSecret(name) {
  try {
    return fs.readFileSync('/run/secrets/' + name, 'utf8').trim();
  } catch (err) {
    console.error('Secret not found: ' + name);
    return null;
  }
}

var dbPassword = readSecret('db_password');
var jwtSecret = readSecret('jwt_secret');

Secrets are:

  • Encrypted at rest in the Swarm Raft log
  • Transmitted over TLS between managers and workers
  • Mounted as tmpfs (in-memory filesystem) inside containers
  • Only accessible to services explicitly granted access
  • Not visible in docker inspect

File-Based Secrets in Docker Compose (Non-Swarm)

Docker Compose supports file-based secrets even without Swarm:

version: "3.8"

services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - jwt_secret

secrets:
  db_password:
    file: ./secrets/db_password.txt
  jwt_secret:
    file: ./secrets/jwt_secret.txt

The files are bind-mounted to /run/secrets/ inside the container. Not as secure as Swarm secrets (no encryption), but maintains the same code path.

Configuration Files

For complex configuration that does not fit neatly into environment variables.

Mounted Config Files

services:
  api:
    volumes:
      - ./config/production.json:/app/config/production.json:ro
// config/production.json
{
  "server": {
    "port": 3000,
    "host": "0.0.0.0"
  },
  "database": {
    "host": "postgres",
    "port": 5432,
    "name": "myapp",
    "pool": {
      "min": 5,
      "max": 20,
      "idleTimeout": 30000
    }
  },
  "cache": {
    "ttl": 300,
    "maxSize": 10000
  },
  "features": {
    "newDashboard": true,
    "betaAPI": false
  }
}
// config/index.js
var fs = require('fs');
var path = require('path');

var env = process.env.NODE_ENV || 'development';
var configPath = path.join(__dirname, env + '.json');

var fileConfig = {};
try {
  fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
  console.warn('No config file found for environment: ' + env);
}

// Environment variables override file config
var config = {
  server: {
    port: parseInt(process.env.PORT) || fileConfig.server && fileConfig.server.port || 3000,
    host: process.env.HOST || fileConfig.server && fileConfig.server.host || '0.0.0.0'
  },
  database: {
    host: process.env.DATABASE_HOST || fileConfig.database && fileConfig.database.host,
    port: parseInt(process.env.DATABASE_PORT) || fileConfig.database && fileConfig.database.port || 5432,
    name: process.env.DATABASE_NAME || fileConfig.database && fileConfig.database.name,
    password: loadSecret('DATABASE_PASSWORD'),
    pool: fileConfig.database && fileConfig.database.pool || { min: 2, max: 10 }
  }
};

function loadSecret(name) {
  var secretPath = '/run/secrets/' + name.toLowerCase();
  try {
    return fs.readFileSync(secretPath, 'utf8').trim();
  } catch (e) {
    return process.env[name];
  }
}

module.exports = config;

Docker Configs (Swarm)

Docker Swarm also has a config primitive for non-sensitive configuration:

docker config create app_config ./config/production.json
services:
  api:
    configs:
      - source: app_config
        target: /app/config/production.json

configs:
  app_config:
    external: true

Configs are not encrypted (they are not secrets) but they are version-controlled in the Swarm and can be updated without rebuilding images.

Kubernetes Secrets and ConfigMaps

ConfigMaps for Non-Sensitive Config

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
data:
  NODE_ENV: "production"
  LOG_LEVEL: "info"
  DATABASE_HOST: "postgres"
  DATABASE_PORT: "5432"
  DATABASE_NAME: "myapp"
  config.json: |
    {
      "cache": { "ttl": 300 },
      "features": { "newDashboard": true }
    }

Mount as environment variables or files:

spec:
  containers:
    - name: api
      envFrom:
        - configMapRef:
            name: api-config
      volumeMounts:
        - name: config-volume
          mountPath: /app/config
  volumes:
    - name: config-volume
      configMap:
        name: api-config
        items:
          - key: config.json
            path: production.json

Kubernetes Secrets

# Create secret from literal values
kubectl create secret generic api-secrets \
  --from-literal=DATABASE_PASSWORD=SuperSecret \
  --from-literal=JWT_SECRET=jwt-production-key \
  --from-literal=API_KEY=sk-prod-12345

# Create from file
kubectl create secret generic tls-secret \
  --from-file=tls.crt=./server.crt \
  --from-file=tls.key=./server.key
spec:
  containers:
    - name: api
      env:
        - name: DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: api-secrets
              key: DATABASE_PASSWORD
      volumeMounts:
        - name: secrets-volume
          mountPath: /run/secrets
          readOnly: true
  volumes:
    - name: secrets-volume
      secret:
        secretName: api-secrets

Secret Rotation

Secrets need to change periodically. The approach depends on your infrastructure.

File-Based Rotation (Zero Downtime)

When secrets are mounted as files, you can update the source and the container reads the new value on next access:

// secret-reader.js — re-reads secrets on each call
var fs = require('fs');

var secretCache = {};
var cacheTTL = 60000; // Re-read every 60 seconds

function getSecret(name) {
  var now = Date.now();
  var cached = secretCache[name];

  if (cached && (now - cached.readAt) < cacheTTL) {
    return cached.value;
  }

  var value;
  try {
    value = fs.readFileSync('/run/secrets/' + name, 'utf8').trim();
  } catch (e) {
    value = process.env[name.toUpperCase()];
  }

  secretCache[name] = { value: value, readAt: now };
  return value;
}

module.exports = { getSecret: getSecret };
// Usage — always call getSecret() instead of caching the value
var secrets = require('./secret-reader');

app.use(function(req, res, next) {
  // Re-reads the secret file periodically
  req.jwtSecret = secrets.getSecret('jwt_secret');
  next();
});

Environment Variable Rotation (Requires Restart)

# Update the secret
docker service update --secret-rm old_db_password --secret-add new_db_password api

# Or with Kubernetes
kubectl create secret generic api-secrets \
  --from-literal=DATABASE_PASSWORD=NewPassword \
  --dry-run=client -o yaml | kubectl apply -f -

# Rolling restart to pick up new secret
kubectl rollout restart deployment/api

Dual-Secret Pattern

During rotation, both old and new secrets must work simultaneously:

// Support both old and new secrets during rotation
var jwtSecrets = [
  secrets.getSecret('jwt_secret'),
  secrets.getSecret('jwt_secret_previous')
].filter(Boolean);

function verifyToken(token) {
  var errors = [];
  for (var i = 0; i < jwtSecrets.length; i++) {
    try {
      return jwt.verify(token, jwtSecrets[i]);
    } catch (err) {
      errors.push(err);
    }
  }
  throw errors[0]; // All secrets failed
}

function signToken(payload) {
  // Always sign with the current (first) secret
  return jwt.sign(payload, jwtSecrets[0]);
}

Anti-Patterns to Avoid

1. Secrets in Dockerfile

# NEVER do this
ENV DATABASE_PASSWORD=SuperSecret
# or
ARG DB_PASS
RUN echo $DB_PASS > /app/.env

Build args and ENV instructions are stored in image layers permanently. Anyone with access to the image can extract them:

docker history myapp --no-trunc | grep PASSWORD

2. Secrets in docker-compose.yml (Committed)

# NEVER commit this
services:
  api:
    environment:
      - DATABASE_PASSWORD=SuperSecret

Use env_file referencing a gitignored file, or external secret management.

3. Logging Secrets

// NEVER do this
console.log('Config:', JSON.stringify(process.env));
console.log('Connecting with password:', config.database.password);

Sanitize logs:

function sanitizeConfig(config) {
  var sanitized = JSON.parse(JSON.stringify(config));
  var sensitiveKeys = ['password', 'secret', 'key', 'token'];

  function redact(obj) {
    Object.keys(obj).forEach(function(key) {
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        redact(obj[key]);
      } else if (sensitiveKeys.some(function(s) { return key.toLowerCase().includes(s); })) {
        obj[key] = '***REDACTED***';
      }
    });
  }

  redact(sanitized);
  return sanitized;
}

console.log('Config:', JSON.stringify(sanitizeConfig(config)));
// {"database":{"host":"postgres","password":"***REDACTED***"},"jwt":{"secret":"***REDACTED***"}}

Complete Working Example

# docker-compose.yml
version: "3.8"

services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env
    secrets:
      - db_password
      - jwt_secret
      - api_key
    volumes:
      - ./config:/app/config:ro
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: myapp
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser"]
      interval: 5s
      timeout: 5s
      retries: 5

secrets:
  db_password:
    file: ./secrets/db_password.txt
  jwt_secret:
    file: ./secrets/jwt_secret.txt
  api_key:
    file: ./secrets/api_key.txt

volumes:
  pgdata:
// config/index.js
var fs = require('fs');
var path = require('path');

function loadSecret(name) {
  var filePath = '/run/secrets/' + name;
  try {
    return fs.readFileSync(filePath, 'utf8').trim();
  } catch (e) {
    return process.env[name.toUpperCase().replace(/-/g, '_')];
  }
}

var env = process.env.NODE_ENV || 'development';
var fileConfig = {};

try {
  var configFile = path.join(__dirname, env + '.json');
  fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf8'));
} catch (e) {
  // No config file for this environment
}

module.exports = {
  port: parseInt(process.env.PORT) || 3000,
  logLevel: process.env.LOG_LEVEL || 'info',
  database: {
    host: process.env.DATABASE_HOST || 'postgres',
    port: parseInt(process.env.DATABASE_PORT) || 5432,
    name: process.env.DATABASE_NAME || 'myapp',
    user: process.env.DATABASE_USER || 'appuser',
    password: loadSecret('db_password'),
    pool: fileConfig.database && fileConfig.database.pool || { max: 20 }
  },
  jwt: {
    secret: loadSecret('jwt_secret'),
    expiresIn: process.env.JWT_EXPIRES_IN || '24h'
  },
  apiKey: loadSecret('api_key'),
  features: fileConfig.features || {}
};
// app.js
var express = require('express');
var pg = require('pg');
var config = require('./config');

var app = express();

var pool = new pg.Pool({
  host: config.database.host,
  port: config.database.port,
  database: config.database.name,
  user: config.database.user,
  password: config.database.password,
  max: config.database.pool.max
});

app.get('/health', function(req, res) {
  pool.query('SELECT 1', function(err) {
    res.status(err ? 503 : 200).json({
      status: err ? 'unhealthy' : 'healthy',
      configLoaded: !!config.database.password,
      secretSource: require('fs').existsSync('/run/secrets/db_password') ? 'file' : 'env'
    });
  });
});

app.listen(config.port, function() {
  console.log('Server started on port ' + config.port);
  console.log('Secret source: ' +
    (require('fs').existsSync('/run/secrets/db_password') ? 'Docker secrets' : 'environment'));
});

Common Issues and Troubleshooting

1. Secret File Not Found

Error: ENOENT: no such file or directory, open '/run/secrets/db_password'

The secret is not mounted. Check your docker-compose.yml:

  • The secrets key must be defined at both the service level AND the top level
  • For file-based secrets, the file must exist at the specified path
  • Verify with: docker compose exec api ls /run/secrets/

2. PostgreSQL POSTGRES_PASSWORD_FILE Not Working

FATAL: password authentication failed for user "appuser"

POSTGRES_PASSWORD_FILE requires the official PostgreSQL image. The file must contain only the password with no trailing newline. Check:

# Verify no trailing newline
xxd secrets/db_password.txt | tail -1
# Should end with the password, no 0a (newline) at the end

3. Environment Variable Overriding Secret

# Secret file has new password, but app uses old one from env

If your code checks process.env before reading the secret file, environment variables take precedence. Check your loading order in the config module.

4. Secrets Visible in Docker Inspect

docker inspect api | grep SECRET
# Shows JWT_SECRET in environment

Environment variable secrets are always visible in docker inspect. Use file-based secrets for sensitive values. They are NOT visible in inspect output.

Best Practices

  • Use file-based secrets for sensitive data. Environment variables are visible in docker inspect, process listings, and crash dumps. Files at /run/secrets/ are not.
  • Never bake secrets into images. No ENV, ARG, or COPY for secrets. Use BuildKit --mount=type=secret for build-time secrets.
  • Validate configuration at startup. Fail fast with clear error messages if required config is missing rather than crashing later with a cryptic error.
  • Support multiple secret sources. Check /run/secrets/ first, fall back to environment variables. This lets the same code work in Docker Swarm, Kubernetes, and local development.
  • Rotate secrets without downtime. Use file-based secrets with periodic re-reading, or the dual-secret pattern for JWT/API keys.
  • Sanitize configuration in logs. Redact any key containing "password", "secret", "key", or "token" before logging.
  • Commit .env.example, never .env. Provide templates with safe defaults for developers.
  • Use the principle of least privilege. Only mount secrets that each service actually needs. Do not share database passwords with services that do not access the database.

References

Powered by Contentful