Every so often I inherit an app whose startup script does five different things before the real process even begins — waiting for a database, running a migration, pulling a config file from somewhere, checking a feature flag service is reachable. Cramming all of that into the main container’s entrypoint script always ends up messy and hard to debug. Init containers give that pre-flight logic its own clean, observable stage in the pod lifecycle.
What Are Init Containers?
Init containers are containers that run and complete before any application containers in a pod start. They run sequentially, one at a time, in the order defined in the manifest, and each must exit successfully (exit code 0) before the next one starts. Only once all init containers have completed does Kubernetes start the regular containers.
This is fundamentally different from a sidecar — a sidecar runs alongside the main container for the pod’s lifetime; an init container runs, finishes, and disappears.
Why Not Just Put This Logic in the Main Container?
You could, but you’d lose several things init containers give you for free:
- Separation of concerns — setup logic isn’t tangled with application logic.
- Different images — an init container can use a completely different, often much smaller image than the app (e.g., a
busyboximage withcurlfor a readiness check, while your app image stays minimal). - Independent resource limits — init containers can have their own (typically lower) CPU/memory requests, since they don’t run concurrently with the app.
- Clear failure isolation —
kubectl describe podshows exactly which init container failed and why, rather than a generic app crash. - Security separation — init containers can run with elevated privileges needed only for setup (e.g., a
chownon a mounted volume) while the main container runs unprivileged.
Basic Example
apiVersion: v1
kind: Pod
metadata:
name: web-app
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c', 'until nc -z postgres-service 5432; do echo waiting for db; sleep 2; done']
containers:
- name: web
image: myregistry.io/web-app:2.1.0
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
kubectl apply -f web-app.yaml
kubectl get pod web-app
While the init container runs, pod status shows:
NAME READY STATUS RESTARTS AGE
web-app 0/1 Init:0/1 0 5s
Once it completes and the main container starts:
NAME READY STATUS RESTARTS AGE
web-app 1/1 Running 0 12s
Multiple Sequential Init Containers
apiVersion: v1
kind: Pod
metadata:
name: app-with-setup
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c', 'until nc -z postgres-service 5432; do sleep 2; done']
- name: run-migrations
image: myregistry.io/db-migrator:1.0.0
command: ['python', 'migrate.py']
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: fetch-config
image: myregistry.io/config-fetcher:1.0.0
command: ['sh', '-c', 'curl -o /config/app-config.json https://config-service/api/v1/config']
volumeMounts:
- name: config-volume
mountPath: /config
containers:
- name: app
image: myregistry.io/app:3.0.0
volumeMounts:
- name: config-volume
mountPath: /etc/app-config
readOnly: true
volumes:
- name: config-volume
emptyDir: {}
These run strictly in order: wait-for-db → run-migrations → fetch-config → then the main app container starts. If any step fails, the pod restarts from that failed init container (based on restartPolicy), not from the beginning of the whole sequence.
kubectl get pod app-with-setup
NAME READY STATUS RESTARTS AGE
app-with-setup 0/1 Init:2/3 0 8s
Init:2/3 tells you exactly how far along the init sequence is — extremely useful for debugging slow startups.
Sharing Data Between Init Containers and the App
The emptyDir volume pattern above is the standard way init containers hand off data — like fetched config, generated certificates, or compiled assets — to the main container.
apiVersion: v1
kind: Pod
metadata:
name: nginx-with-content
spec:
initContainers:
- name: fetch-content
image: alpine/git:2.45.2
command: ['git', 'clone', 'https://github.com/example/static-site.git', '/content']
volumeMounts:
- name: content-volume
mountPath: /content
containers:
- name: nginx
image: nginx:1.27
volumeMounts:
- name: content-volume
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: content-volume
emptyDir: {}
Real-World Use Case: Permissions Fix for Mounted Volumes
A very common production need — many storage backends mount volumes owned by root, but your app container runs as a non-root UID. An init container with elevated privileges can fix ownership before the unprivileged app container starts:
apiVersion: v1
kind: Pod
metadata:
name: app-with-volume-perms
spec:
initContainers:
- name: fix-permissions
image: busybox:1.36
command: ['sh', '-c', 'chown -R 1000:1000 /data']
securityContext:
runAsUser: 0
volumeMounts:
- name: data
mountPath: /data
containers:
- name: app
image: myregistry.io/app:1.0.0
securityContext:
runAsUser: 1000
runAsNonRoot: true
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data
This is a well-known and widely used pattern (the same one the official Bitnami charts use extensively) for reconciling storage backend ownership defaults with security-hardened application containers.
Resource Requests for Init Containers
Since init containers run sequentially and never concurrently with each other or the app containers, Kubernetes uses the highest individual init container resource request (not the sum) when computing the pod’s effective resource requirements — worth knowing when sizing nodes:
initContainers:
- name: step-one
resources:
requests:
cpu: "100m"
memory: "128Mi"
- name: step-two
resources:
requests:
cpu: "500m"
memory: "512Mi"
containers:
- name: app
resources:
requests:
cpu: "250m"
memory: "256Mi"
The pod’s effective request is max(100m, 500m, 250m) = 500m CPU and max(128Mi, 512Mi, 256Mi) = 512Mi memory (assuming a single app container) — not the sum of everything.
Init Containers vs Native Sidecars
Since Kubernetes 1.29 (stable), you can mark an init container as a native sidecar using restartPolicy: Always within the initContainers block — this makes it start before the main containers (like a normal init container) but keep running alongside them for the pod’s lifetime, and it gets terminated last on pod shutdown:
initContainers:
- name: log-shipper
image: myregistry.io/log-shipper:1.0.0
restartPolicy: Always
# This behaves like a sidecar: starts first, runs continuously
containers:
- name: app
image: myregistry.io/app:1.0.0
This solves a long-standing pain point — previously, genuine sidecars (like service mesh proxies or log shippers) had no clean way to start before the app and be guaranteed running throughout its lifecycle without hacky readiness-gate workarounds.
Troubleshooting
# Check pod status — look for Init:N/M
kubectl get pod <pod-name>
# View logs from a specific init container
kubectl logs <pod-name> -c <init-container-name>
# Full event history, including init container failures
kubectl describe pod <pod-name>
Common failure signature:
Init:CrashLoopBackOff
This means an init container is failing and being retried — check its logs specifically, since kubectl logs <pod-name> alone (without -c) defaults to the main container and won’t show you anything useful here.
Init Containers in Helm Charts
When packaging applications for reuse, exposing init container behavior as configurable Helm values keeps a single chart flexible across different deployment scenarios without forking it:
# values.yaml
initContainers:
waitForDependencies:
enabled: true
image: busybox:1.36
dependencies:
- name: postgres
port: 5432
# templates/deployment.yaml
spec:
template:
spec:
{{- if .Values.initContainers.waitForDependencies.enabled }}
initContainers:
{{- range .Values.initContainers.waitForDependencies.dependencies }}
- name: wait-for-{{ .name }}
image: {{ $.Values.initContainers.waitForDependencies.image }}
command: ['sh', '-c', 'until nc -z {{ .name }} {{ .port }}; do sleep 2; done']
{{- end }}
{{- end }}
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
This pattern is exactly how many widely-used community charts (Bitnami’s charts being a common example) implement dependency-waiting behavior — configurable per deployment without needing to touch the underlying template.
Failure Behavior and restartPolicy Interaction
An init container’s failure handling depends on the pod’s restartPolicy. With restartPolicy: Always (the default for most workloads) or OnFailure, a failed init container is retried by the kubelet according to the standard exponential backoff — the same backoff behavior used for regular container restarts. With restartPolicy: Never, a failed init container causes the entire pod to be marked Failed immediately, with no retry:
apiVersion: v1
kind: Pod
metadata:
name: strict-init-pod
spec:
restartPolicy: Never
initContainers:
- name: validate-config
image: myregistry.io/config-validator:1.0.0
command: ['python', 'validate.py', '/config/app.yaml']
containers:
- name: app
image: myregistry.io/app:1.0.0
For Jobs specifically, this interaction matters a lot — a Job’s own backoffLimit governs pod-level retries, while restartPolicy inside the pod template governs container-level restarts within a single pod attempt. Getting these two layers confused is a common source of “why did this retry twice as many times as I expected” debugging sessions.
Common Mistakes
- Forgetting
-c <container-name>when checking logs on a pod with multiple init containers — you’ll get an error or the wrong container’s output. - Using
emptyDirfor large data transfers without settingsizeLimit, risking node disk pressure. - Assuming init containers run in parallel — they don’t; they’re strictly sequential, so a slow init container directly adds to pod startup latency.
- Not setting resource requests on init containers, causing unpredictable scheduling since the effective pod request calculation depends on them.
- Overusing init containers for things that should be readiness/liveness probes — init containers are for one-time setup, not ongoing health checks.
- Running unnecessary privileged init containers — only elevate privileges (like
runAsUser: 0) when the specific setup task genuinely requires it, and drop back to unprivileged for the app container.
Summary
Init containers give you a clean, ordered, observable pre-flight stage for pod startup — waiting on dependencies, running migrations, fetching configuration, or fixing volume permissions — all isolated from your application container’s image and runtime. They run sequentially and must all succeed before the app starts, and as of Kubernetes 1.29, the native sidecar pattern (restartPolicy: Always on an init container) extends this same mechanism to cover long-running sidecars too. Use them to keep your application image lean and your startup logic debuggable.