← Back to DevBytes

Docker Init Containers: Best Practices and Common Pitfalls

What Are Docker Init Containers?

Init containers are specialized containers that run to completion before your main application container starts. They handle setup tasks such as downloading dependencies, waiting for external services, populating configuration files, running database migrations, or adjusting file permissions. Once the init container exits successfully, the main container launches into a fully prepared environment. This pattern originated in Kubernetes but has become widely adopted across container orchestration platforms, including Docker Compose and custom Docker-based deployments.

It is important not to confuse init containers with the docker run --init flag, which inserts a lightweight init process (like tini) as PID 1 inside a container to handle signal forwarding and zombie process reaping. Init containers are entirely separate containers that run sequentially before the primary workload begins.

The Core Concept

An init container follows a strict lifecycle:

Why Init Containers Matter

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Modern containerized applications often require pre-flight operations. Without init containers, developers frequently embed setup logic directly into the application's entrypoint script, which introduces several problems:

Init containers solve these issues by decoupling initialization from execution. They keep your application image lean, allow you to leverage the exact tooling you need for setup, and fail fast before the application even attempts to start, improving debuggability.

Using Init Containers in Docker Compose

Docker Compose does not have a native initContainers field like Kubernetes. However, you can achieve the same pattern using a combination of depends_on, service health checks, and one-shot services that exit after completing their work. The key is to create a service that runs to completion before the main service starts.

Example: Waiting for a Database and Running Migrations

Suppose you have a web application that requires a PostgreSQL database to be ready and migrations to be applied before it starts. You can model the migration step as an init container using a short-lived service.

# docker-compose.yml
version: '3.8'

services:
  # The "init container" – runs migrations and exits
  db-init:
    image: your-app-migration-image:latest
    command: >
      sh -c "
        echo 'Waiting for DB...';
        while ! pg_isready -h db -U postgres; do sleep 1; done;
        echo 'Running migrations...';
        npm run migrate;
        echo 'Migrations complete.'
      "
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - migration-lock:/var/lock  # optional: share state with app
    restart: "no"  # critical: do not restart after exit

  # The main application
  app:
    image: your-app-image:latest
    command: ["node", "server.js"]
    depends_on:
      db-init:
        condition: service_completed_successfully
    ports:
      - "3000:3000"
    volumes:
      - migration-lock:/var/lock

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: securepass
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 2s
      timeout: 3s
      retries: 5

volumes:
  migration-lock:

Here, db-init acts as the init container. It waits for the database to become healthy, runs migrations, and exits. The app service uses depends_on with condition: service_completed_successfully, which ensures it only starts after db-init exits with code 0. The restart: "no" on the init service prevents Compose from restarting it, preserving the init-container semantics.

Example: Pre-populating a Cache Volume

Another common pattern is downloading assets or warming a cache before the application starts.

# docker-compose.yml
services:
  cache-warmer:
    image: alpine:3.19
    command: >
      sh -c "
        apk add --no-cache curl;
        curl -sS https://cdn.example.com/static-assets.tar.gz | tar xz -C /cache;
        echo 'Cache populated with' $(ls /cache | wc -l) 'files.'
      "
    volumes:
      - cache-data:/cache
    restart: "no"

  web:
    image: nginx:alpine
    depends_on:
      cache-warmer:
        condition: service_completed_successfully
    volumes:
      - cache-data:/usr/share/nginx/html/cache:ro
    ports:
      - "80:80"

volumes:
  cache-data:

The cache-warmer service downloads assets into a named volume. The main web service then mounts that volume read-only. If the download fails, the web service never starts, preventing it from serving incomplete content.

Using Init Containers in Kubernetes

Kubernetes offers first-class support for init containers via the spec.initContainers field in a Pod specification. Init containers run sequentially, each must complete successfully, and they can use different images and resource limits from the main containers.

Basic Kubernetes Init Container

apiVersion: v1
kind: Pod
metadata:
  name: app-with-init
spec:
  # Init containers run first, in order
  initContainers:
    - name: wait-for-db
      image: postgres:16-alpine
      command: ['sh', '-c',
        'until pg_isready -h db-service -U postgres; do echo waiting; sleep 2; done']
      env:
        - name: PGPASSWORD
          value: securepass
    - name: run-migrations
      image: your-migration-tool:latest
      command: ['sh', '-c', 'npm run migrate && echo done']
      envFrom:
        - secretRef:
            name: db-credentials
  # Main containers start only after all init containers succeed
  containers:
    - name: app
      image: your-app:latest
      ports:
        - containerPort: 3000
      envFrom:
        - secretRef:
            name: db-credentials
  restartPolicy: Always

In this Pod, wait-for-db runs first. Only when it exits successfully does run-migrations start. After migrations succeed, the app container launches. If pg_isready fails indefinitely, the Pod stays in the Init:Error or Init:CrashLoopBackOff state, making the failure visible in kubectl describe pod.

Init Containers with Shared Volumes

Init containers often write configuration or secrets into a shared volume that the main container consumes.

apiVersion: v1
kind: Pod
metadata:
  name: config-from-vault
spec:
  volumes:
    - name: config-volume
      emptyDir: {}
  initContainers:
    - name: fetch-config
      image: alpine:3.19
      command: ['sh', '-c', '
        apk add --no-cache curl;
        curl -sS --header "X-Vault-Token: $VAULT_TOKEN"
          https://vault.internal/v1/secret/app-config | jq .data > /config/app.json
      ']
      env:
        - name: VAULT_TOKEN
          valueFrom:
            secretKeyRef:
              name: vault-token
              key: token
      volumeMounts:
        - name: config-volume
          mountPath: /config
  containers:
    - name: app
      image: your-app:latest
      volumeMounts:
        - name: config-volume
          mountPath: /etc/app/config
          readOnly: true

The init container fetches secrets from HashiCorp Vault and writes them as a JSON file into an emptyDir volume. The main application container then mounts that volume read-only, receiving the configuration without any Vault logic baked into its image.

Best Practices for Docker Init Containers

1. Keep Init Containers Focused and Minimal

Each init container should perform exactly one logical task. Resist the temptation to combine waiting, downloading, and migrating into a single script. Separate concerns allow you to pinpoint failures quickly and reuse init containers across different applications.

# Good: separate init containers
initContainers:
  - name: check-dependencies
    image: busybox
    command: ['sh', '-c', 'nslookup db.internal && echo ok']
  - name: seed-data
    image: data-seeder:1.2
    command: ['python', 'seed.py']

# Avoid: monolithic init script that does everything
initContainers:
  - name: monolithic-setup
    image: custom-tools:latest
    command: ['bash', '-c', '
      nslookup db.internal &&
      curl -o /data/seed.json https://... &&
      mysql -h db < /data/schema.sql &&
      python migrate.py
    ']

2. Use Lightweight Images for Init Tasks

Init containers should use the smallest possible image that contains the required tool. Alpine, BusyBox, or a dedicated tool image (like appropriate/curl for HTTP requests) are excellent choices. Avoid using your full application image for init tasks—it slows down Pod startup and wastes resources.

# Prefer
initContainers:
  - name: wait-for-service
    image: busybox:1.36
    command: ['sh', '-c', 'for i in $(seq 1 30); do nc -z myservice 8080 && exit 0; sleep 1; done; exit 1']

# Over
initContainers:
  - name: wait-for-service
    image: your-fat-app-image:latest
    command: ['/app/wait-for-it.sh', 'myservice:8080']

3. Set Explicit Resource Limits

Init containers should declare CPU and memory requests and limits just like main containers. A runaway init container can consume excessive resources and prevent the scheduler from placing the Pod. Be especially careful with data-processing init containers that might load large datasets into memory.

initContainers:
  - name: download-assets
    image: alpine:3.19
    command: ['sh', '-c', 'wget -O /data/assets.zip https://cdn.example.com/large-file.zip']
    resources:
      requests:
        memory: "128Mi"
        cpu: "250m"
      limits:
        memory: "256Mi"
        cpu: "500m"
    volumeMounts:
      - name: assets
        mountPath: /data

4. Implement Proper Timeouts and Retry Logic

Init containers that wait for external dependencies should include timeout mechanisms. An infinite loop without a deadline can mask infrastructure problems indefinitely.

# Docker Compose: wait-for-it with timeout
db-init:
  image: alpine:3.19
  command: >
    sh -c "
      timeout=60;
      start=\$(date +%s);
      while ! pg_isready -h db -U postgres; do
        now=\$(date +%s);
        if [ \$((now - start)) -gt \$timeout ]; then
          echo 'Timeout waiting for database' >&2;
          exit 1;
        fi;
        sleep 1;
      done;
      echo 'Database ready.';
      npm run migrate
    "

# Kubernetes: equivalent pattern
initContainers:
  - name: wait-for-db
    image: postgres:16-alpine
    command:
      - sh
      - -c
      - |
        timeout=60
        start=$(date +%s)
        while ! pg_isready -h db-service -U postgres; do
          now=$(date +%s)
          if [ $((now - start)) -gt $timeout ]; then
            echo "Timeout after ${timeout}s" >&2
            exit 1
          fi
          sleep 1
        done
        echo "Database ready"

5. Leverage Shared Volumes for Data Handoff

When an init container generates artifacts (configuration files, certificates, downloaded assets), store them in a shared volume rather than trying to communicate via network or external storage. This keeps the data flow local to the Pod or Compose stack and avoids race conditions.

volumes:
  - name: tls-certs
    emptyDir: {}

initContainers:
  - name: generate-certs
    image: alpine:3.19
    command:
      - sh
      - -c
      - |
        apk add --no-cache openssl;
        openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
          -keyout /certs/tls.key -out /certs/tls.crt \
          -subj "/CN=localhost";
        chmod 600 /certs/tls.key
    volumeMounts:
      - name: tls-certs
        mountPath: /certs

containers:
  - name: nginx
    image: nginx:alpine
    volumeMounts:
      - name: tls-certs
        mountPath: /etc/nginx/certs
        readOnly: true

6. Make Init Containers Idempotent

Init containers may be retried if a Pod restarts (depending on restartPolicy). They should produce the same outcome on repeated execution. For example, a migration init container should check whether migrations have already been applied before attempting to re-apply them.

initContainers:
  - name: migrate-db
    image: your-migration-tool:latest
    command:
      - sh
      - -c
      - |
        # Check if migrations table exists (idempotency guard)
        if psql $DATABASE_URL -c "SELECT 1 FROM schema_migrations LIMIT 1" 2>/dev/null; then
          echo "Migrations already applied, skipping."
          exit 0
        fi
        echo "Running migrations..."
        npm run migrate

7. Log Extensively from Init Containers

Init container logs are critical for debugging startup failures. Emit structured, timestamped output so you can trace exactly where an initialization sequence failed. Use kubectl logs -c init-container-name to access these logs even after the init container has exited.

initContainers:
  - name: setup
    image: alpine:3.19
    command:
      - sh
      - -c
      - |
        echo "[$(date -Iseconds)] Starting setup..."
        echo "[$(date -Iseconds)] Fetching configuration..."
        curl -sS -o /config/app.yaml https://config-service/internal/config
        echo "[$(date -Iseconds)] Configuration written."
        echo "[$(date -Iseconds)] Setup complete."

Common Pitfalls

Pitfall 1: Confusing Init Containers with Sidecar Containers

Init containers run to completion and exit before the main container starts. Sidecar containers run alongside the main container for the entire lifetime of the Pod. A common mistake is using an init container for a task that should run continuously (like log forwarding or metrics collection). If you need a persistent helper, use a sidecar container instead.

# Wrong: using an init container for continuous log shipping
# The container will exit and logs will stop being shipped.
initContainers:
  - name: log-shipper
    image: fluent-bit:latest
    # This will run and exit—logs stop shipping after init phase!

# Correct: use a regular container (sidecar)
containers:
  - name: log-shipper
    image: fluent-bit:latest
    # Runs continuously alongside the main app

Pitfall 2: Missing Dependency Conditions in Docker Compose

In Docker Compose, depends_on without a condition only waits for the dependent container to start, not to finish its work or become healthy. This is a subtle but critical difference. Always use condition: service_completed_successfully for init-like services and condition: service_healthy for persistent dependencies.

# Wrong: app starts as soon as db-init container starts (not completes)
app:
  depends_on:
    - db-init  # Only waits for start, not completion!

# Correct: app waits for db-init to exit successfully
app:
  depends_on:
    db-init:
      condition: service_completed_successfully

Pitfall 3: Not Handling Init Container Failures Gracefully

When an init container fails in Kubernetes, the Pod enters Init:Error or Init:CrashLoopBackOff state. If you don't monitor these states, you might think the Pod is still "starting" when it's actually stuck. Always check kubectl describe pod for init container exit codes and investigate promptly. In Docker Compose, a failing init service will cause the entire docker compose up to abort, but the error message may be buried—use docker compose logs db-init to inspect.

Pitfall 4: Large Images Causing Slow Startup

Init containers run sequentially, so pulling a large image for each init container adds cumulative delay. If you have three init containers each using a 500MB image, the Pod may take minutes to start on a cold node. Use minimal images and consider pre-caching images on nodes for critical workloads.

# Slow: three large images pulled sequentially
initContainers:
  - name: step1
    image: python:3.12  # ~1GB
  - name: step2
    image: node:20      # ~1GB
  - name: step3
    image: ubuntu:24.04 # ~100MB

# Faster: use slim/alpine variants
initContainers:
  - name: step1
    image: python:3.12-alpine  # ~50MB
  - name: step2
    image: node:20-alpine      # ~120MB
  - name: step3
    image: alpine:3.19         # ~7MB

Pitfall 5: Hardcoding Secrets in Init Container Commands

Init containers often need credentials to fetch configuration or access databases. Never hardcode secrets in the command field. Use environment variables from Secrets, ConfigMaps, or dedicated secret management tools.

# Dangerous: secret visible in Pod spec and logs
initContainers:
  - name: fetch-config
    image: alpine
    command: ['sh', '-c', 'curl -H "Authorization: Bearer abc123secret" https://api.example.com/config']

# Safe: secret injected via environment variable
initContainers:
  - name: fetch-config
    image: alpine
    command: ['sh', '-c', 'curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/config']
    env:
      - name: API_TOKEN
        valueFrom:
          secretKeyRef:
            name: api-credentials
            key: token

Pitfall 6: Ignoring Init Container Restart Policies

In Kubernetes, init containers follow the Pod's restartPolicy. With restartPolicy: Always (the default for Pods created by Deployments), a failing init container will be retried. This can mask intermittent failures. If you want the Pod to fail permanently on init failure, you need to handle this at the controller level. In Docker Compose, setting restart: "no" on an init service is essential; omitting it can cause the init container to restart endlessly if it fails.

# Docker Compose: always specify restart policy on init services
db-init:
  image: migration-tool:latest
  restart: "no"  # Critical: prevent restart loops
  depends_on:
    db:
      condition: service_healthy

Pitfall 7: Not Cleaning Up Shared Volumes

Init containers that write large files to shared volumes can exhaust disk space on the node if the volume is not properly sized or cleaned. Use emptyDir with a size limit in Kubernetes, and in Docker Compose, remember that named volumes persist across runs. For temporary data, consider using tmpfs mounts or explicitly cleaning up in a pre-stop hook.

# Kubernetes: limit emptyDir size
volumes:
  - name: scratch
    emptyDir:
      sizeLimit: "500Mi"

# Docker Compose: use tmpfs for truly temporary init data
volumes:
  - type: tmpfs
    target: /scratch
    tmpfs:
      size: 500M

Conclusion

Init containers are a powerful pattern that brings order and reliability to container startup sequences. By separating initialization logic from runtime execution, you build more secure, maintainable, and debuggable container deployments. Whether you're using Docker Compose with one-shot services and completion conditions or leveraging Kubernetes' native initContainers field, the principles remain the same: keep init containers small, focused, idempotent, and well-logged. Watch out for common pitfalls like missing dependency conditions, oversized images, and hardcoded secrets. When implemented thoughtfully, init containers transform fragile startup scripts into a robust, composable pipeline that fails fast and clearly—giving you confidence that your application starts in a known-good state every time.

🚀 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