DEV Community

Claudio
Claudio

Posted on

ConfigMaps and Secrets: Managing Configuration in Kubernetes

When you're building applications for Kubernetes, one of the first challenges you'll face is: "Where do I put my configuration data?" Should database connection strings live in your Docker image? What about API keys? How do you handle different configurations for development, staging, and production environments?

This is where Kubernetes ConfigMaps and Secrets come to the rescue. In this post, I would like to explore, with you, these essential Kubernetes resources that separate your application code from its configuration, making your deployments more secure, flexible, and maintainable.

The Problem: Hardcoded Configuration

Before we dive into the solution, let's understand the problem. Consider this simple Node.js application:

// app.js - DON'T DO THIS
const express = require('express');
const mysql = require('mysql2');

const app = express();

// Hardcoded configuration - BAD PRACTICE
const config = {
  port: 3000,
  database: {
    host: 'mysql-prod.company.com',
    user: 'admin',
    password: 'super-secret-password',
    database: 'production_db'
  },
  apiKey: 'sk-1234567890abcdef'
};

const connection = mysql.createConnection(config.database);
app.listen(config.port);

Enter fullscreen mode Exit fullscreen mode

This approach has several critical problems:

  1. Security Risk: Sensitive data like passwords and API keys are embedded in your code
  2. Environment Rigidity: The same image can't work across different environments
  3. Secret Exposure: Passwords end up in your Git repository and Docker images
  4. Deployment Complexity: Changing configuration requires rebuilding and redeploying your entire application

The Kubernetes Solution: Externalizing Configuration

Kubernetes provides two primary resources for managing configuration:

  • ConfigMaps: For non-sensitive configuration data
  • Secrets: For sensitive information like passwords, tokens, and keys

Both resources follow the same core principle: separate configuration from application code.

ConfigMaps: Your Configuration Data Store

What is a ConfigMap?

A ConfigMap is a Kubernetes API object that stores configuration data as key-value pairs. Think of it as a dictionary or hash map that your applications can reference at runtime.

Creating ConfigMaps

There are several ways to create ConfigMaps. Let's explore each method:

Method 1: Using kubectl with Literal Values

kubectl create configmap app-config \
  --from-literal=PORT=3000 \
  --from-literal=DATABASE_HOST=mysql.default.svc.cluster.local \
  --from-literal=DATABASE_NAME=myapp \
  --from-literal=LOG_LEVEL=info

Enter fullscreen mode Exit fullscreen mode

Method 2: From a Configuration File

First, create a configuration file:

# app.properties
PORT=3000
DATABASE_HOST=mysql.default.svc.cluster.local
DATABASE_NAME=myapp
LOG_LEVEL=info
DEBUG_MODE=false

Enter fullscreen mode Exit fullscreen mode

Then create the ConfigMap:

kubectl create configmap app-config --from-file=app.properties

Enter fullscreen mode Exit fullscreen mode

Method 3: Using YAML Manifests (Recommended)

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: default
data:
  PORT: "3000"
  DATABASE_HOST: "mysql.default.svc.cluster.local"
  DATABASE_NAME: "myapp"
  LOG_LEVEL: "info"
  DEBUG_MODE: "false"
  # You can also include entire configuration files
  app.properties: |
    PORT=3000
    DATABASE_HOST=mysql.default.svc.cluster.local
    DATABASE_NAME=myapp
    LOG_LEVEL=info
    DEBUG_MODE=false

Enter fullscreen mode Exit fullscreen mode

Apply the ConfigMap:

kubectl apply -f configmap.yaml

Enter fullscreen mode Exit fullscreen mode

Consuming ConfigMaps in Your Applications

There are three primary ways to use ConfigMaps in your pods:

1. Environment Variables

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        env:
        # Individual environment variables
        - name: PORT
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: PORT
        - name: DATABASE_HOST
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: DATABASE_HOST
        # Or load all ConfigMap keys as environment variables
        envFrom:
        - configMapRef:
            name: app-config

Enter fullscreen mode Exit fullscreen mode

2. Volume Mounts

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        volumeMounts:
        - name: config-volume
          mountPath: /etc/config
          readOnly: true
      volumes:
      - name: config-volume
        configMap:
          name: app-config

Enter fullscreen mode Exit fullscreen mode

With this approach, your configuration files will be available at /etc/config/ inside the container.

3. Command Line Arguments

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        command: ["./myapp"]
        args:
        - "--port=$(PORT)"
        - "--database-host=$(DATABASE_HOST)"
        env:
        - name: PORT
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: PORT
        - name: DATABASE_HOST
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: DATABASE_HOST

Enter fullscreen mode Exit fullscreen mode

Secrets: Protecting Sensitive Data

What is a Secret?

A Secret is similar to a ConfigMap, but specifically designed for sensitive data. Kubernetes stores Secrets in base64 encoding and provides additional security features like:

  • Secrets are stored in etcd in encrypted form (when encryption at rest is enabled)
  • Secrets are only sent to nodes that have pods requiring them
  • Secrets are stored in memory (tmpfs) and never written to disk
  • Access can be controlled with RBAC policies

Types of Secrets

Kubernetes supports several Secret types:

  1. Opaque: Generic secrets for arbitrary user data (default)
  2. kubernetes.io/dockerconfigjson: Docker registry credentials
  3. kubernetes.io/tls: TLS certificates and keys
  4. kubernetes.io/service-account-token: Service account tokens

Creating Secrets

Method 1: Using kubectl with Literal Values

kubectl create secret generic app-secrets \
  --from-literal=DATABASE_PASSWORD=super-secret-password \
  --from-literal=API_KEY=sk-1234567890abcdef \
  --from-literal=JWT_SECRET=my-jwt-secret-key

Enter fullscreen mode Exit fullscreen mode

Method 2: From Files

# Create files with sensitive data
echo -n 'super-secret-password' > db-password.txt
echo -n 'sk-1234567890abcdef' > api-key.txt

kubectl create secret generic app-secrets \
  --from-file=DATABASE_PASSWORD=db-password.txt \
  --from-file=API_KEY=api-key.txt

# Clean up the files
rm db-password.txt api-key.txt

Enter fullscreen mode Exit fullscreen mode

Method 3: Using YAML Manifests

# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
  namespace: default
type: Opaque
data:
  # Values must be base64 encoded
  DATABASE_PASSWORD: c3VwZXItc2VjcmV0LXBhc3N3b3Jk  # super-secret-password
  API_KEY: c2stMTIzNDU2Nzg5MGFiY2RlZg==  # sk-1234567890abcdef
  JWT_SECRET: bXktand0LXNlY3JldC1rZXk=  # my-jwt-secret-key

Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use the stringData field to avoid manual base64 encoding:

apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_PASSWORD: super-secret-password
  API_KEY: sk-1234567890abcdef
  JWT_SECRET: my-jwt-secret-key

Enter fullscreen mode Exit fullscreen mode

Consuming Secrets

Secrets are consumed in the same ways as ConfigMaps:

Environment Variables

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        env:
        - name: DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: DATABASE_PASSWORD
        - name: API_KEY
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: API_KEY
        envFrom:
        - secretRef:
            name: app-secrets

Enter fullscreen mode Exit fullscreen mode

Volume Mounts

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        volumeMounts:
        - name: secret-volume
          mountPath: /etc/secrets
          readOnly: true
      volumes:
      - name: secret-volume
        secret:
          secretName: app-secrets
          defaultMode: 0400  # Read-only for owner only

Enter fullscreen mode Exit fullscreen mode

In the next post we’ll see how to use secrets and ConfigMap in a complete application setup.


Real-World Example: Complete Application Setup

Let's put it all together with a realistic example. We'll deploy a Node.js application that connects to a MySQL database.

Step 1: Create the ConfigMap

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: webapp-config
data:
  PORT: "3000"
  NODE_ENV: "production"
  DATABASE_HOST: "mysql.default.svc.cluster.local"
  DATABASE_PORT: "3306"
  DATABASE_NAME: "webapp"
  LOG_LEVEL: "info"
  CACHE_TTL: "3600"
  RATE_LIMIT: "100"

Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Secret

# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: webapp-secrets
type: Opaque
stringData:
  DATABASE_USER: admin
  DATABASE_PASSWORD: my-secure-password-123
  JWT_SECRET: super-secret-jwt-key-that-should-be-random
  API_KEY: sk-live-1234567890abcdef

Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Deployment

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp
  labels:
    app: webapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
      - name: webapp
        image: mycompany/webapp:v1.2.0
        ports:
        - containerPort: 3000
        # Load non-sensitive config as environment variables
        envFrom:
        - configMapRef:
            name: webapp-config
        # Load sensitive data as environment variables
        - secretRef:
            name: webapp-secrets
        # Health checks
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5
        # Resource limits
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "256Mi"
            cpu: "200m"

Enter fullscreen mode Exit fullscreen mode

Step 4: Updated Application Code

Now your application code becomes much cleaner:

// app.js - Clean configuration management
const express = require('express');
const mysql = require('mysql2/promise');

const app = express();

// Configuration from environment variables
const config = {
  port: process.env.PORT || 3000,
  nodeEnv: process.env.NODE_ENV || 'development',
  database: {
    host: process.env.DATABASE_HOST || 'localhost',
    port: process.env.DATABASE_PORT || 3306,
    user: process.env.DATABASE_USER || 'root',
    password: process.env.DATABASE_PASSWORD || '',
    database: process.env.DATABASE_NAME || 'myapp'
  },
  jwtSecret: process.env.JWT_SECRET,
  apiKey: process.env.API_KEY,
  logLevel: process.env.LOG_LEVEL || 'info',
  cacheTtl: parseInt(process.env.CACHE_TTL) || 3600,
  rateLimit: parseInt(process.env.RATE_LIMIT) || 100
};

// Validate required configuration
if (!config.jwtSecret) {
  console.error('JWT_SECRET is required');
  process.exit(1);
}

if (!config.apiKey) {
  console.error('API_KEY is required');
  process.exit(1);
}

// Initialize database connection
let connection;

async function initDatabase() {
  try {
    connection = await mysql.createConnection(config.database);
    console.log('Database connected successfully');
  } catch (error) {
    console.error('Database connection failed:', error);
    process.exit(1);
  }
}

// Health check endpoints
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

app.get('/ready', async (req, res) => {
  try {
    await connection.ping();
    res.status(200).json({ status: 'ready' });
  } catch (error) {
    res.status(503).json({ status: 'not ready', error: error.message });
  }
});

// Start the application
async function start() {
  await initDatabase();

  app.listen(config.port, () => {
    console.log(`Server running on port ${config.port}`);
    console.log(`Environment: ${config.nodeEnv}`);
    console.log(`Log Level: ${config.logLevel}`);
  });
}

start().catch(error => {
  console.error('Failed to start application:', error);
  process.exit(1);
});

Enter fullscreen mode Exit fullscreen mode

Step 5: Deploy Everything

kubectl apply -f configmap.yaml
kubectl apply -f secret.yaml
kubectl apply -f deployment.yaml

Enter fullscreen mode Exit fullscreen mode

Advanced Patterns and Best Practices

1. Environment-Specific Configurations

Use different ConfigMaps and Secrets for different environments:

# Development
kubectl create configmap webapp-config --from-env-file=dev.env
kubectl create secret generic webapp-secrets --from-env-file=dev-secrets.env

# Production
kubectl create configmap webapp-config --from-env-file=prod.env
kubectl create secret generic webapp-secrets --from-env-file=prod-secrets.env

Enter fullscreen mode Exit fullscreen mode

2. Configuration Validation

Add configuration validation to your application:

const joi = require('joi');

const configSchema = joi.object({
  port: joi.number().port().required(),
  nodeEnv: joi.string().valid('development', 'production', 'test').required(),
  database: joi.object({
    host: joi.string().hostname().required(),
    port: joi.number().port().required(),
    user: joi.string().required(),
    password: joi.string().min(8).required(),
    database: joi.string().required()
  }).required(),
  jwtSecret: joi.string().min(32).required(),
  apiKey: joi.string().required(),
  logLevel: joi.string().valid('error', 'warn', 'info', 'debug').required(),
  cacheTtl: joi.number().min(0).required(),
  rateLimit: joi.number().min(1).required()
});

const { error, value } = configSchema.validate(config);
if (error) {
  console.error('Configuration validation failed:', error.details);
  process.exit(1);
}

Enter fullscreen mode Exit fullscreen mode

3. Hot Reloading Configuration

Monitor ConfigMap changes and reload configuration without restarting:

const fs = require('fs');
const path = require('path');

// If using volume mounts
function watchConfigFile() {
  const configPath = '/etc/config/app.properties';

  if (fs.existsSync(configPath)) {
    fs.watchFile(configPath, (curr, prev) => {
      console.log('Configuration file changed, reloading...');
      loadConfigFromFile(configPath);
    });
  }
}

function loadConfigFromFile(filePath) {
  try {
    const content = fs.readFileSync(filePath, 'utf8');
    const newConfig = parseConfig(content);

    // Update non-critical configuration
    config.logLevel = newConfig.LOG_LEVEL;
    config.cacheTtl = parseInt(newConfig.CACHE_TTL);
    config.rateLimit = parseInt(newConfig.RATE_LIMIT);

    console.log('Configuration reloaded successfully');
  } catch (error) {
    console.error('Failed to reload configuration:', error);
  }
}

Enter fullscreen mode Exit fullscreen mode

4. Secret Rotation

Implement graceful secret rotation:

const crypto = require('crypto');

class SecretManager {
  constructor() {
    this.currentSecrets = this.loadSecrets();
    this.watchSecrets();
  }

  loadSecrets() {
    return {
      jwtSecret: process.env.JWT_SECRET,
      apiKey: process.env.API_KEY,
      databasePassword: process.env.DATABASE_PASSWORD
    };
  }

  watchSecrets() {
    // Watch for secret file changes if using volume mounts
    const secretsPath = '/etc/secrets';

    if (fs.existsSync(secretsPath)) {
      fs.watch(secretsPath, (eventType, filename) => {
        if (eventType === 'change') {
          console.log(`Secret ${filename} changed, preparing rotation...`);
          this.rotateSecret(filename);
        }
      });
    }
  }

  async rotateSecret(secretName) {
    try {
      const newSecret = fs.readFileSync(`/etc/secrets/${secretName}`, 'utf8');

      // Graceful rotation logic here
      // For example, accept both old and new JWT secrets temporarily
      if (secretName === 'JWT_SECRET') {
        this.gracefulJwtRotation(newSecret);
      }

    } catch (error) {
      console.error(`Failed to rotate secret ${secretName}:`, error);
    }
  }

  gracefulJwtRotation(newSecret) {
    // Keep both secrets active for a transition period
    this.acceptedJwtSecrets = [this.currentSecrets.jwtSecret, newSecret];

    // After transition period, use only the new secret
    setTimeout(() => {
      this.currentSecrets.jwtSecret = newSecret;
      this.acceptedJwtSecrets = [newSecret];
      console.log('JWT secret rotation completed');
    }, 300000); // 5 minutes
  }
}

Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Principle of Least Privilege

Only give pods access to the ConfigMaps and Secrets they actually need:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: webapp-service-account
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: webapp-config-reader
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["webapp-config"]
  verbs: ["get"]
- apiGroups: [""]
  resources: ["secrets"]
  resourceNames: ["webapp-secrets"]
  verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: webapp-config-binding
subjects:
- kind: ServiceAccount
  name: webapp-service-account
roleRef:
  kind: Role
  name: webapp-config-reader
  apiGroup: rbac.authorization.k8s.io

Enter fullscreen mode Exit fullscreen mode

2. Avoid Logging Sensitive Data

Never log environment variables or configuration that might contain secrets:

// DON'T DO THIS
console.log('Starting with config:', JSON.stringify(process.env));

// DO THIS INSTEAD
const safeConfig = {
  port: config.port,
  nodeEnv: config.nodeEnv,
  logLevel: config.logLevel,
  // Mask sensitive data
  databaseHost: config.database.host,
  databaseUser: config.database.user.replace(/./g, '*'),
  // Don't log secrets at all
};
console.log('Starting with config:', JSON.stringify(safeConfig));

Enter fullscreen mode Exit fullscreen mode

3. Use Dedicated Service Accounts

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp
spec:
  template:
    spec:
      serviceAccountName: webapp-service-account  # Dedicated service account
      automountServiceAccountToken: false  # Don't mount unless needed
      containers:
      - name: webapp
        image: mycompany/webapp:v1.2.0
        # ... rest of configuration

Enter fullscreen mode Exit fullscreen mode

Troubleshooting Common Issues

Problem 1: ConfigMap Not Updating in Pods

Symptoms: You updated a ConfigMap, but your pods still see the old values.

Causes and Solutions:

  1. Environment Variables: Environment variables are set at pod startup and don't update automatically.

    # Force pod recreation
    kubectl rollout restart deployment/webapp
    
    
  2. Volume Mounts: May take up to 60 seconds to propagate.

    # Check if files are updated
    kubectl exec -it webapp-xxx -- cat /etc/config/app.properties
    
    

Problem 2: Secret Values Not Decoding Properly

Symptoms: Your application receives base64-encoded values instead of plain text.

Solution: Kubernetes automatically decodes Secret values. If you're getting encoded values, check if you're reading from the wrong source:

// Wrong - reading from a manually created file
const secret = fs.readFileSync('/tmp/my-secret', 'utf8'); // Still encoded

// Right - from environment variable or volume mount
const secret = process.env.MY_SECRET; // Automatically decoded

Enter fullscreen mode Exit fullscreen mode

Problem 3: Permission Denied Errors

Symptoms: Pods can't read ConfigMaps or Secrets.

Solution: Check RBAC permissions and service account configuration:

# Check service account
kubectl get pods webapp-xxx -o yaml | grep serviceAccount

# Check RBAC permissions
kubectl auth can-i get configmap --as=system:serviceaccount:default:webapp-service-account

# Debug with a temporary pod
kubectl run debug --rm -it --image=busybox --restart=Never -- sh

Enter fullscreen mode Exit fullscreen mode

Performance Considerations

1. ConfigMap and Secret Size Limits

  • ConfigMaps and Secrets have a 1MB size limit
  • For larger configuration files, consider using init containers or external configuration services

2. Update Propagation

  • Environment variables: Require pod restart
  • Volume mounts: Propagate within ~60 seconds
  • Choose the right method based on your update frequency needs

3. Memory Usage

  • Secrets are stored in memory (tmpfs) and count against container memory limits
  • Consider this when setting memory limits for pods with large secrets

Monitoring and Observability

Track ConfigMap and Secret usage:

# Add labels for better organization
apiVersion: v1
kind: ConfigMap
metadata:
  name: webapp-config
  labels:
    app: webapp
    version: v1.2.0
    environment: production
    config-type: application
data:
  # ... configuration data

Enter fullscreen mode Exit fullscreen mode

Monitor configuration changes:

# Watch for ConfigMap changes
kubectl get events --field-selector involvedObject.kind=ConfigMap -w

# Check ConfigMap history
kubectl describe configmap webapp-config

Enter fullscreen mode Exit fullscreen mode

Conclusion

ConfigMaps and Secrets are fundamental building blocks for managing configuration in Kubernetes. They enable you to:

  • Separate concerns: Keep configuration separate from application code
  • Enhance security: Protect sensitive data with proper access controls
  • Improve flexibility: Use the same container images across different environments
  • Simplify operations: Update configuration without rebuilding images

Key takeaways for entry-level engineers:

  1. Use ConfigMaps for non-sensitive data like database hostnames, port numbers, and feature flags
  2. Use Secrets for sensitive data like passwords, API keys, and certificates
  3. Choose the right consumption method based on your update requirements
  4. Follow security best practices with proper RBAC and service accounts
  5. Validate configuration in your application code
  6. Monitor and log safely without exposing sensitive information

As you continue your Kubernetes journey, remember that good configuration management is not just about the technology—it's about creating maintainable, secure, and scalable applications. ConfigMaps and Secrets provide the foundation, but how you use them will determine the success of your deployments.

Start small, experiment with different approaches, and gradually build more sophisticated configuration management patterns as your applications and team grow. The investment in proper configuration management will pay dividends in reduced deployment complexity, improved security, and faster development cycles.

👉 If you enjoyed this post, consider supporting me by buying me a coffee.

👉 If you want to stay updated with similar posts, consider joining my newsletter.

Top comments (0)