./sysctl.id

Containerization Best Practices for Production

May 05, 2025 | 17 Minute Read

Containerization has revolutionized how we build, ship, and run applications. By packaging applications with their dependencies into standardized, isolated units, containers provide consistency across environments and enable efficient resource utilization. However, as containerization has matured, so have the best practices for implementing it effectively in production environments.

This article explores essential containerization best practices that will help you build secure, efficient, and maintainable container-based applications.

Understanding Container Fundamentals

Before diving into best practices, let’s quickly review what makes containers different from traditional virtualization:

  • Lightweight: Containers share the host OS kernel, making them significantly more resource-efficient than VMs
  • Portable: Containers package applications with dependencies, ensuring consistency across environments
  • Isolated: Each container runs in its own namespace, providing process and filesystem isolation
  • Ephemeral: Containers are designed to be stateless and replaceable

With these fundamentals in mind, let’s explore best practices for containerizing applications for production.

1. Optimize Container Images

The foundation of containerization is the container image. Optimizing your images improves security, reduces resource usage, and speeds up deployments.

Use Minimal Base Images

Start with the smallest possible base image that can support your application:

# Bad practice: Using a full OS image
FROM ubuntu:22.04

# Better practice: Using a minimal image
FROM alpine:3.19

# Best practice: Using distroless images when possible
FROM gcr.io/distroless/java17

Alpine Linux images are typically 5-10MB compared to Ubuntu’s 180+MB. For even smaller images, consider “distroless” images that contain only your application and its runtime dependencies.

Implement Multi-Stage Builds

Multi-stage builds separate build dependencies from runtime dependencies:

# Build stage
FROM node:18 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
USER node
CMD ["node", "dist/server.js"]

This approach drastically reduces image size by excluding build tools and intermediate artifacts from the final image.

Layer Image Efficiently

Docker images consist of layers that are cached for efficiency. Optimize layer caching by ordering Dockerfile commands from least to most frequently changing:

# Inefficient: Frequent code changes invalidate npm install cache
COPY . .
RUN npm install

# Efficient: Package files rarely change compared to source code
COPY package*.json ./
RUN npm install
COPY . .

This structure ensures that expensive operations like dependency installation don’t repeat unnecessarily when only application code changes.

2. Implement Container Security

Security should be a primary consideration when containerizing applications for production.

Run Containers as Non-Root Users

By default, processes in containers run as root, creating unnecessary security risks:

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci

# Create a non-root user and switch to it
RUN addgroup -g 1001 appuser && \
    adduser -u 1001 -G appuser -s /bin/sh -D appuser
USER appuser

CMD ["node", "server.js"]

Running as a non-root user prevents container breakout exploits and follows the principle of least privilege.

Scan Images for Vulnerabilities

Integrate vulnerability scanning into your CI/CD pipeline:

# Example GitHub Actions workflow step
- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'myapp:latest'
    format: 'table'
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

Tools like Trivy, Clair, or Snyk can identify vulnerabilities in your container images before they reach production.

Use Read-Only Filesystems

Make container filesystems read-only wherever possible:

# In Kubernetes
apiVersion: v1
kind: Pod
metadata:
  name: secure-app
spec:
  containers:
  - name: app
    image: myapp:1.0.0
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir: {}

This prevents attackers from writing malicious files and makes containers more secure by enforcing immutability.

3. Configure Health Checks

Proper health checks enable orchestrators like Kubernetes to restart unhealthy containers automatically.

Implement Liveness and Readiness Probes

For containerized applications, especially in Kubernetes, implement both types of health checks:

apiVersion: v1
kind: Pod
metadata:
  name: app-with-probes
spec:
  containers:
  - name: app
    image: myapp:1.0.0
    ports:
    - containerPort: 8080
    livenessProbe:
      httpGet:
        path: /health/live
        port: 8080
      initialDelaySeconds: 10
      periodSeconds: 5
    readinessProbe:
      httpGet:
        path: /health/ready
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 5
  • Liveness probe: Detects if an application is running but deadlocked or otherwise unhealthy
  • Readiness probe: Determines if an application is ready to receive traffic

Create Meaningful Health Endpoints

Implement health endpoints that actually verify application health:

// Simple Express.js health check implementation
const express = require('express');
const app = express();

// Database connection
const db = require('./database');

// Liveness probe - basic application health
app.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

// Readiness probe - checks dependencies
app.get('/health/ready', async (req, res) => {
  try {
    // Check database connection
    await db.ping();
    
    // Check other dependencies
    const cacheHealth = await checkCacheHealth();
    
    res.status(200).json({ status: 'ready' });
  } catch (error) {
    console.error('Health check failed:', error);
    res.status(503).json({ status: 'not ready', error: error.message });
  }
});

function checkCacheHealth() {
  return new Promise((resolve, reject) => {
    // Check cache connectivity
    // ...
    resolve(true);
  });
}

app.listen(8080);

This approach ensures that your application only receives traffic when it’s truly capable of handling it.

4. Implement Resource Management

Properly configured resource limits prevent containers from consuming excessive resources.

Set Resource Requests and Limits

Always specify CPU and memory requirements:

apiVersion: v1
kind: Pod
metadata:
  name: resource-managed-app
spec:
  containers:
  - name: app
    image: myapp:1.0.0
    resources:
      requests:
        memory: "128Mi"
        cpu: "100m"
      limits:
        memory: "256Mi"
        cpu: "500m"

This configuration:

  • Requests what the container needs (guaranteed minimum)
  • Limits what the container can use (enforced maximum)

Right-Size Your Containers

Analyze application resource usage patterns to determine appropriate resource allocations. Tools like Prometheus with metrics exporters can help you gather this data:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: right-sized-app
  annotations:
    prometheus.io/scrape: "true"
    prometheus.io/port: "8080"
    prometheus.io/path: "/metrics"
spec:
  # ...container spec with resource settings

Regularly review and adjust your resource settings based on actual usage patterns.

5. Manage Container Logging

Proper logging configuration is essential for troubleshooting and monitoring containerized applications.

Output Logs to stdout/stderr

Follow the 12-factor app methodology by writing logs to standard output and standard error:

// Instead of writing to log files
console.log('Application started');
console.error('Error encountered:', error);

Container orchestrators and runtime environments can then collect and forward these logs to centralized logging systems.

Implement Structured Logging

Use JSON or another structured format for logs to make them machine-parsable:

// Structured logging with Winston
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  defaultMeta: { service: 'user-service' },
  transports: [
    new winston.transports.Console()
  ]
});

logger.info('User logged in', { userId: '123', timestamp: new Date().toISOString() });

Structured logs are easier to search, filter, and analyze in logging systems like ELK (Elasticsearch, Logstash, Kibana) or Grafana Loki.

6. Implement Proper Configuration Management

Externalize configuration to make containers more portable and reusable.

Use Environment Variables for Configuration

Avoid hardcoding configuration values in your container images:

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci
USER node

# Default values for environment variables
ENV NODE_ENV=production
ENV PORT=8080
ENV DB_HOST=localhost
ENV DB_USER=app
ENV DB_PASSWORD=

CMD ["node", "server.js"]

These variables can be overridden at runtime:

docker run -e DB_HOST=db.example.com -e DB_USER=prod_user -e DB_PASSWORD=secret myapp:1.0.0

Utilize ConfigMaps and Secrets in Kubernetes

For Kubernetes environments, use appropriate resources for configuration:

# ConfigMap for non-sensitive configuration
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DATABASE_HOST: "db.example.com"
  CACHE_TTL: "3600"
  FEATURE_X_ENABLED: "true"
---
# Secret for sensitive configuration
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  DATABASE_PASSWORD: cGFzc3dvcmQxMjM=  # base64 encoded
  API_KEY: c2VjcmV0a2V5MTIz  # base64 encoded
---
# Use in a Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: configured-app
spec:
  template:
    spec:
      containers:
      - name: app
        image: myapp:1.0.0
        envFrom:
        - configMapRef:
            name: app-config
        - secretRef:
            name: app-secrets

This approach separates configuration from application code and allows different environments to use the same container images with different configurations.

7. Implement Proper Container Lifecycle Management

Containers should handle shutdown signals gracefully to prevent data loss and ensure clean termination.

Handle Termination Signals

Implement proper signal handlers in your application:

// Node.js example of graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, shutting down gracefully');
  
  // Stop accepting new requests
  server.close(() => {
    console.log('HTTP server closed');
  });
  
  // Close database connections
  await db.disconnect();
  
  // Complete any ongoing operations
  await completeOngoingJobs();
  
  console.log('Graceful shutdown completed');
  process.exit(0);
});

function completeOngoingJobs() {
  return new Promise(resolve => {
    // Wait for ongoing operations to complete
    setTimeout(resolve, 5000);
  });
}

This ensures your application can clean up resources when its container is terminated.

Set Appropriate Timeouts

Configure your orchestrator with appropriate termination grace periods:

apiVersion: v1
kind: Pod
metadata:
  name: graceful-app
spec:
  terminationGracePeriodSeconds: 60
  containers:
  - name: app
    image: myapp:1.0.0

This gives your application enough time to complete its shutdown sequence.

8. Implement Statelessness and Data Management

Containers should be ephemeral and stateless, with persistent data stored externally.

Separate Application and Data

Store persistent data outside your containers:

apiVersion: v1
kind: Pod
metadata:
  name: stateless-app
spec:
  containers:
  - name: app
    image: myapp:1.0.0
    volumeMounts:
    - name: data
      mountPath: /app/data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: app-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

This separation allows containers to be replaced without data loss.

Use Volume Drivers for Specialized Storage

For specific storage requirements, use appropriate volume drivers:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: database-pv
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: fast-ssd
  csi:
    driver: ebs.csi.aws.com
    volumeHandle: vol-0123456789abcdef0
    fsType: ext4

Cloud providers offer specialized storage options with performance characteristics suited to different workloads.

9. Implement Proper Networking

Container networking should be secure and efficient.

Expose Only Necessary Ports

Minimize the attack surface by exposing only required ports:

FROM nginx:alpine
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY ./app /usr/share/nginx/html

# Only expose the HTTP port
EXPOSE 80

Use Network Policies

In Kubernetes, implement network policies to control traffic:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-network-policy
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - protocol: TCP
      port: 8080
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: database
    ports:
    - protocol: TCP
      port: 5432

Network policies enforce the principle of least privilege at the network level, allowing only necessary communication between components.

10. Implement CI/CD for Containers

Automate building, testing, and deploying containerized applications.

Automate Image Building

Use CI/CD pipelines to build and test images:

# GitHub Actions workflow example
name: Build and Push Container Image

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    
    - name: Login to DockerHub
      uses: docker/login-action@v2
      with:
        username: $
        password: $
    
    - name: Build and push
      uses: docker/build-push-action@v4
      with:
        context: .
        push: true
        tags: username/app:latest,username/app:$
        cache-from: type=registry,ref=username/app:buildcache
        cache-to: type=registry,ref=username/app:buildcache,mode=max

This approach ensures consistent, repeatable builds and reduces manual errors.

Implement a Promotion Strategy

Use tags and promotion strategies to move images between environments:

# Tag an image for production after it passes testing
docker tag username/app:$COMMIT_SHA username/app:production-$RELEASE_VERSION
docker push username/app:production-$RELEASE_VERSION

# Update the deployment
kubectl set image deployment/app-deployment app=username/app:production-$RELEASE_VERSION

This creates a clear audit trail of what’s deployed in each environment.

Conclusion

Containerization offers tremendous benefits for modern application deployment, but realizing those benefits requires careful attention to best practices. By following the guidelines in this article, you can build containerized applications that are:

  • Secure: Protected against common vulnerabilities and exploits
  • Efficient: Optimized for resource usage and performance
  • Reliable: Resilient to failures and easy to monitor
  • Maintainable: Well-organized, properly configured, and easy to update

As containerization technologies continue to evolve, staying current with best practices will help you maximize the advantages containers offer while minimizing their challenges.

Remember that containerization is not just a technology choice—it’s an operational philosophy that emphasizes immutability, consistency, and automation. By embracing these principles and implementing the practices outlined in this article, you’ll be well-positioned to succeed with containers in production environments.