← Back to DevBytes

Docker Init Containers: Production Guide

What Are Docker Init Containers?

Init containers are specialized, short-lived containers that run to completion before the main application containers start. They handle setup tasks that must be finished before your primary services can operate correctly — things like running database migrations, fetching configuration from external sources, setting file permissions on shared volumes, or waiting for dependent services to become available.

In the Docker ecosystem, the init container pattern manifests in two primary contexts:

The key distinction between init containers and regular containers is that init containers must terminate successfully before the main containers are started. If an init container fails, the orchestrator retries it (or fails the entire deployment), ensuring your application never runs against an unprepared environment.

Why Init Containers Matter in Production

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In development environments, you can often get away with sleep-based waiting scripts or manually running setup steps. In production, these approaches break down rapidly. Here's why dedicated init containers are critical:

How to Use Init Containers

Basic Pattern in Docker Compose

Docker Compose doesn't have a native initContainers field like Kubernetes, but you can achieve the same effect using a service that runs a command that exits, combined with depends_on with the service_completed_successfully condition (available in Compose v3 with Docker Engine 24.0+).

Here's a minimal example — an init container that waits for PostgreSQL to be ready and runs database migrations before the API starts:

# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: securepass
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  db-init:
    image: your-app-migrations:latest
    command: ["sh", "-c", "npm run migrate:up && echo 'Migrations complete'"]
    environment:
      DATABASE_URL: postgresql://appuser:securepass@postgres:5432/appdb
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"

  api:
    image: your-app-api:latest
    environment:
      DATABASE_URL: postgresql://appuser:securepass@postgres:5432/appdb
    ports:
      - "8080:8080"
    depends_on:
      db-init:
        condition: service_completed_successfully
    restart: always

volumes:
  pgdata:

Critical details in this setup:

Init Containers in Kubernetes Pods

Kubernetes offers first-class support for init containers. They run in sequence within a pod before any application containers start. If an init container fails, Kubernetes restarts the pod and re-runs all init containers from the beginning.

Here's a pod specification with an init container that fetches configuration from AWS S3 and writes it to a shared volume:

apiVersion: v1
kind: Pod
metadata:
  name: web-app
spec:
  # This init container runs first
  initContainers:
    - name: config-fetcher
      image: amazon/aws-cli:latest
      command:
        - sh
        - -c
        - |
          aws s3 cp s3://my-app-config/production.yaml /config/app.yaml
          echo "Configuration fetched successfully"
      env:
        - name: AWS_REGION
          value: us-east-1
        - name: AWS_ACCESS_KEY_ID
          valueFrom:
            secretKeyRef:
              name: aws-credentials
              key: access-key
        - name: AWS_SECRET_ACCESS_KEY
          valueFrom:
            secretKeyRef:
              name: aws-credentials
              key: secret-key
      volumeMounts:
        - name: config-volume
          mountPath: /config

  # Main application containers start after init containers succeed
  containers:
    - name: nginx
      image: nginx:1.25-alpine
      volumeMounts:
        - name: config-volume
          mountPath: /etc/nginx/conf.d
          readOnly: true
      ports:
        - containerPort: 80

  volumes:
    - name: config-volume
      emptyDir: {}

The flow is deterministic: the config-fetcher init container runs, populates the shared config-volume (an emptyDir volume), and exits. Only then does the nginx container start with the configuration already in place.

Sequential Init Containers for Complex Setups

When you need multiple ordered setup steps, Kubernetes runs init containers in the order they appear in the spec. Each must complete before the next begins:

apiVersion: v1
kind: Pod
metadata:
  name: multi-step-init
spec:
  initContainers:
    # Step 1: Wait for a dependent service to be reachable
    - name: wait-for-redis
      image: busybox:1.36
      command:
        - sh
        - -c
        - |
          until nslookup redis-service.default.svc.cluster.local; do
            echo "Waiting for Redis DNS resolution..."
            sleep 2
          done
          echo "Redis is resolvable"

    # Step 2: Run schema migrations
    - name: db-migrate
      image: your-migrations-tool:latest
      command: ["/migrate", "up"]
      env:
        - name: DB_HOST
          value: postgres-service
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password

    # Step 3: Warm up cache by preloading data
    - name: cache-warmer
      image: your-data-loader:latest
      command: ["/load-hot-data.sh"]

  containers:
    - name: main-app
      image: your-app:latest
      ports:
        - containerPort: 8080

Each init container has access to the same pod network and volumes but runs in its own isolated filesystem. This sequential execution gives you a powerful pipeline for complex bootstrapping.

Production-Grade Init Container Patterns

Database Initialization and Migrations

One of the most common production patterns is running database migrations before application startup. This prevents the application from serving requests against a schema that hasn't been updated.

# Docker Compose production setup with retry logic
services:
  db-migrate:
    image: ghcr.io/myorg/migrations:v2.4.1
    command: >
      sh -c '
        set -e;
        max_retries=30;
        count=0;
        until pg_isready -h postgres -U appuser -d appdb || [ $count -gt $max_retries ]; do
          echo "Waiting for PostgreSQL... attempt $count/$max_retries";
          sleep 2;
          count=$((count + 1));
        done;
        if [ $count -gt $max_retries ]; then
          echo "PostgreSQL never became ready"; exit 1;
        fi;
        echo "Running migrations...";
        atlas migrate apply --dir file:///migrations --url "$DATABASE_URL";
        echo "Migrations completed successfully";
      '
    environment:
      DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@postgres:5432/appdb?sslmode=disable
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"
    volumes:
      - migrations-data:/migrations:ro

volumes:
  migrations-data:

Key production considerations for migration init containers:

Fetching Configuration from Remote Sources

Modern applications often pull configuration from HashiCorp Vault, AWS Parameter Store, or Azure Key Vault. An init container handles this securely, writing configs to a shared volume that the application container reads at startup.

# Kubernetes deployment with Vault config fetching
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: secure-app
  template:
    metadata:
      labels:
        app: secure-app
    spec:
      serviceAccountName: vault-reader
      initContainers:
        - name: vault-config-loader
          image: vault:1.15
          command:
            - sh
            - -c
            - |
              # Authenticate using Kubernetes service account
              VAULT_TOKEN=$(vault write -field=token \
                auth/kubernetes/login \
                role=app-role \
                jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)")

              # Fetch secrets and write to shared volume
              vault read -format=json secret/data/production/app > /secrets/config.json

              echo "Configuration loaded from Vault"
          volumeMounts:
            - name: secrets-volume
              mountPath: /secrets
            - name: sa-token
              mountPath: /var/run/secrets/kubernetes.io/serviceaccount
              readOnly: true
      containers:
        - name: application
          image: myapp:production-v3
          args: ["--config", "/secrets/config.json"]
          volumeMounts:
            - name: secrets-volume
              mountPath: /secrets
              readOnly: true
      volumes:
        - name: secrets-volume
          emptyDir:
            medium: Memory  # Keep secrets in memory, not on disk
        - name: sa-token
          projected:
            sources:
              - serviceAccountToken:
                  path: token

Using emptyDir with medium: Memory ensures secrets never touch disk. The init container writes them to a tmpfs volume that exists only for the pod's lifetime.

Volume Permission Fixing

When running containers as non-root users, volumes mounted from the host or cloud storage may have incorrect ownership. An init container running as root can fix permissions before handing control to the unprivileged application.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-static
spec:
  template:
    spec:
      # Init container runs as root to fix volume permissions
      initContainers:
        - name: fix-volume-permissions
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              chown -R 101:101 /static-data
              chmod -R 755 /static-data
              echo "Permissions fixed for uid 101 (nginx user)"
          securityContext:
            runAsUser: 0  # Root
          volumeMounts:
            - name: static-files
              mountPath: /static-data
      containers:
        - name: nginx
          image: nginx:1.25-alpine
          # Runs as non-root nginx user (uid 101)
          securityContext:
            runAsUser: 101
            runAsGroup: 101
            readOnlyRootFilesystem: true
          volumeMounts:
            - name: static-files
              mountPath: /usr/share/nginx/html
              readOnly: true
      volumes:
        - name: static-files
          persistentVolumeClaim:
            claimName: nginx-static-pvc

This pattern is essential for security-conscious deployments where the main container must run with minimal privileges but persistent volumes require specific ownership.

Waiting for Service Dependencies

In microservice architectures, your application may depend on multiple upstream services. A dedicated init container can perform comprehensive readiness checks before the main container starts.

# A robust service-waiting init container in Docker Compose
services:
  service-waiter:
    image: busybox:1.36
    command:
      - sh
      - -c
      - |
        set -e;

        # Wait for PostgreSQL
        until nc -z postgres 5432; do
          echo "Waiting for postgres:5432..."; sleep 1;
        done;
        echo "PostgreSQL ready";

        # Wait for Redis
        until nc -z redis 6379; do
          echo "Waiting for redis:6379..."; sleep 1;
        done;
        echo "Redis ready";

        # Wait for an HTTP endpoint to return 200
        until wget -q -O- http://auth-service:8080/health | grep -q "ok"; do
          echo "Waiting for auth-service health endpoint..."; sleep 2;
        done;
        echo "Auth service healthy";

        echo "All dependencies ready, signaling completion"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: "no"

  app:
    image: myapp:latest
    depends_on:
      service-waiter:
        condition: service_completed_successfully
    restart: always

Best Practices for Production Init Containers

Keep Init Containers Minimal and Focused

Each init container should do exactly one thing. Resist the temptation to bundle multiple setup steps into a single init container — you lose the ability to retry individual steps and debugging becomes harder. Instead, chain multiple init containers in sequence.

# Good: Separate, focused init containers
initContainers:
  - name: fetch-config      # Single responsibility: get config
    image: aws-cli:latest
    command: ["aws", "s3", "cp", "s3://config/app.yaml", "/config/app.yaml"]
  - name: run-migrations    # Single responsibility: schema updates
    image: migrations:v2
    command: ["/migrate", "up"]
  - name: seed-data         # Single responsibility: initial data
    image: data-seeder:v1
    command: ["/seed.sh"]

Use Lightweight Images

Init containers don't need the same images as your application. Choose minimal images to reduce attack surface and pull time:

Implement Proper Error Handling

Init containers must fail loudly and clearly. Use set -e in shell scripts so any non-zero exit code propagates. Log meaningful messages that help operators diagnose issues without diving into container internals.

# Robust error handling in an init container script
command:
  - sh
  - -c
  - |
    set -euo pipefail;

    log() {
      echo "[$(date -Iseconds)] $1" >&2;
    }

    log "Starting configuration fetch from S3...";
    if ! aws s3 cp s3://config-bucket/prod/config.json /app/config.json; then
      log "FATAL: Failed to fetch configuration from S3";
      log "Check AWS credentials and bucket accessibility";
      exit 1;
    fi;

    if ! jq '.' /app/config.json > /dev/null; then
      log "FATAL: Downloaded config is not valid JSON";
      exit 1;
    fi;

    log "Configuration successfully fetched and validated";

Set Resource Limits

In Kubernetes, always define resource requests and limits for init containers. While they're short-lived, an init container with unbounded memory could cause pod scheduling issues or node instability.

initContainers:
  - name: db-migrate
    image: migrations:v2
    resources:
      requests:
        cpu: 250m
        memory: 128Mi
      limits:
        cpu: 500m
        memory: 256Mi

Make Init Containers Idempotent

Init containers may run multiple times during pod restarts or retries. Design them to be safely re-runnable:

Secure Secrets Properly

Init containers often need access to secrets (database passwords, API keys, cloud credentials). Never hardcode them. Use the orchestrator's secret management:

# Kubernetes: Mount secrets from Secret objects
initContainers:
  - name: db-migrate
    env:
      - name: DB_PASSWORD
        valueFrom:
          secretKeyRef:
            name: database-credentials
            key: password
      - name: DB_USERNAME
        valueFrom:
          secretKeyRef:
            name: database-credentials
            key: username

Set Appropriate Security Contexts

Use the principle of least privilege. If an init container only needs to write to a specific volume, don't give it root access to the entire node. In Kubernetes, use securityContext to drop capabilities and set read-only root filesystems where possible.

initContainers:
  - name: volume-setup
    securityContext:
      runAsUser: 1000
      runAsGroup: 3000
      capabilities:
        drop:
          - ALL
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false

Common Pitfalls and Troubleshooting

Init Container Stuck in Retry Loop

If an init container keeps failing and the pod is stuck in Init:Error or Init:CrashLoopBackOff, check the init container logs directly:

# View init container logs in Kubernetes
kubectl logs pod-name -c init-container-name

# If the init container has already terminated and the pod is re-scheduled
kubectl logs pod-name -c init-container-name --previous

Common causes include incorrect secret references, network policies blocking outbound connections, or misconfigured service discovery.

Docker Compose Init Service Restarting Indefinitely

If you forget restart: "no" on a Docker Compose init service, it may restart after completion if it exits with code 0. Always explicitly set the restart policy for one-shot containers:

# Correct: init container runs once and stops
db-init:
  restart: "no"

Volume Not Populated When Main Container Starts

If your main container can't find files that the init container was supposed to create, verify that both containers mount the same volume at compatible paths. Remember that emptyDir volumes in Kubernetes start empty and are populated by init containers.

Race Conditions with External Dependencies

Even with proper ordering, an external service might accept connections but not be fully ready to serve requests. Use health checks and application-level readiness probes rather than simple TCP checks. For HTTP services, query a dedicated readiness endpoint that validates database connectivity and cache availability.

Conclusion

Init containers transform chaotic, timing-dependent startup sequences into deterministic, reliable pipelines. By extracting setup logic into focused, short-lived containers, you gain reproducibility, security isolation, and clear failure semantics — all qualities that distinguish production-grade deployments from prototypes. Whether you're using Docker Compose's depends_on with completion conditions or Kubernetes' native initContainers, the pattern remains the same: run prerequisite tasks to completion first, then start your application only when the environment is fully prepared. Adopt minimal images, enforce idempotency, set resource boundaries, and never skip error handling. With these practices in place, your containerized applications will start predictably every time, even in the most complex microservice topologies.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles