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
- Visible in process listings.
docker inspectshows all environment variables. - Inherited by child processes. Every
execorspawninherits the full environment. - Logged accidentally. Many frameworks log environment on startup.
- 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
secretskey 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, orCOPYfor secrets. Use BuildKit--mount=type=secretfor 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.