Lesson 10 of 66 · 15 min read · Updated

Init Containers — Preparing a Pod Before Your App Starts

In the Pod articles you ran Pods with a single container. Real applications often need a little work done before they can start. The database schema must exist. A configuration file must be rendered. A dependency must be reachable. You could put all of that into the application's startup code, but then every image carries tools it does not need, and a slow dependency turns into a crashing application.

Kubernetes has a dedicated place for this work: init containers. They run to completion, one after the other, before any application container starts. This article shows how they behave, how they share data with the application, and how to debug them when they fail, using a .NET 10 API, a .NET migration tool and PostgreSQL on a multi-node kind cluster.

We will cover the following topics:

  1. What init containers are and how they differ from application containers
  2. Ordering and restart semantics
  3. Waiting for a dependency
  4. Running a .NET database migration as an init container
  5. Sharing files with the application through an emptyDir volume
  6. How init containers affect resource requests and scheduling
  7. Debugging a Pod stuck in Init:Error and Init:CrashLoopBackOff

Technical Requirements

  1. A Kubernetes cluster. The examples run on Docker Desktop's kind-based cluster with one control-plane node and three workers, Kubernetes v1.36.
  2. kubectl, Docker and the .NET 10 SDK.
  3. On a plain kind cluster, load the two images you build below with kind load docker-image. Docker Desktop serves locally built images to the nodes automatically.

Core Concepts

What an Init Container Is

An init container is declared in the initContainers list of a Pod spec. It looks exactly like a normal container: image, command, environment, volume mounts, resources. The difference is in its life cycle:

Init containerApplication container
Declared inspec.initContainersspec.containers
When it runsBefore every application containerAfter all init containers succeeded
How many at a timeOne, in the listed orderAll of them, in parallel
Must it exit?Yes, with exit code 0No, it runs for the life of the Pod
ProbesNot supported (except native sidecars, next article)Liveness, readiness, startup

The kubelet starts the first init container and waits. Exit code 0 means success, and the next one starts. When the last one has succeeded, the application containers start together.

Why Use Them

  1. Separation of concerns. The migration tool, pg_isready or a templating tool lives in its own image. The application image stays small.
  2. Different privileges. An init container can run with a Secret or permissions that the long-running application never sees.
  3. A clean startup contract. By the time your application starts, the dependency is reachable and the schema exists. Your startup code can assume it.
  4. Blocking without crashing. Waiting in an init container shows up as Init:0/3 in kubectl get pods, which is far clearer than an application in CrashLoopBackOff because the database is not there yet.

Restart Semantics

What happens when an init container fails depends on the Pod's restartPolicy:

Pod restartPolicyFailed init container
Always (Deployments, StatefulSets)Restarted with exponential back-off until it succeeds
OnFailureRestarted with back-off
Never (some Jobs and bare Pods)The whole Pod is marked Failed

Because a failing init container can be retried many times, init containers must be idempotent. Running the migration twice must not break anything. CREATE TABLE IF NOT EXISTS and inserting seed data only when the table is empty are typical examples.

Init containers run again whenever the Pod is recreated, for example on a rollout or after a node failure. They do not run again when an application container restarts inside the same Pod. The step-by-step example proves both.

Sharing Data with emptyDir

Init containers and application containers do not share a filesystem by default. To hand over a file, mount the same volume in both. An emptyDir volume is created empty when the Pod is scheduled, lives as long as the Pod, and is perfect for this.

Resource Accounting

The scheduler needs one number for a Pod's CPU and memory request. Because init containers run one at a time and before the application containers, Kubernetes computes the effective request like this:

  1. the largest request of any single init container, or
  2. the sum of the requests of all application containers,

whichever is higher. Limits are calculated the same way.

ContainersCPU requestEffective Pod request
Init migrate500m
App catalog-api200m
App log-agent100mmax(500m, 200m + 100m) = 500m

A heavy init container therefore makes the whole Pod harder to schedule, even if it only runs for a second.

Hands-On: Kubernetes Commands

See init progress at a glance

Init:1/3 means one of three init containers has completed.

kubectl get pods -n mc-demo

Read the logs of a specific init container

Use -c with the init container's name.

kubectl logs -n mc-demo deploy/catalog-api -c migrate

Read the logs of the previous attempt

After a restart, --previous shows what the failed attempt printed.

kubectl logs -n mc-demo catalog-api-broken -c migrate --previous

Show init container states and exit codes

describe has a separate "Init Containers" section.

kubectl describe pod -n mc-demo catalog-api-broken

Print each init container's result

A JSONPath query gives a compact table.

kubectl get pod -n mc-demo <pod> -o jsonpath='{range .status.initContainerStatuses[*]}{.name}{"\t"}{.state.terminated.reason}{"\t"}{.state.terminated.exitCode}{"\n"}{end}'

Watch the Pod move through its init phases

-w streams every status change.

kubectl get pods -n mc-demo -l app=catalog-api -w

Step-by-Step Example

The Scenario

A catalog API stores products in PostgreSQL. Before the API starts, three things must happen in order:

  1. wait-for-db: wait until PostgreSQL accepts connections.
  2. migrate: run a .NET console tool that creates the tables and seeds data.
  3. render-config: write a settings file into a shared volume.

We deploy the API before the database on purpose, so we can watch the Pod wait. Then we break the migration and debug it.

Step 1 — The Migration Tool

Create a console app with Npgsql:

dotnet new console -n CatalogMigrator
cd CatalogMigrator
dotnet add package Npgsql

Replace Program.cs:

using Npgsql;

string? connectionString = Environment.GetEnvironmentVariable("CATALOG_DB");
if (string.IsNullOrWhiteSpace(connectionString))
{
Console.Error.WriteLine("Migrator: CATALOG_DB is not set.");
return 2;
}

const string sql = """
CREATE TABLE IF NOT EXISTS schema_version (version int PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS products (id serial PRIMARY KEY, name text NOT NULL, price numeric(10,2) NOT NULL);
INSERT INTO schema_version (version) VALUES (1) ON CONFLICT DO NOTHING;
INSERT INTO products (name, price)
SELECT * FROM (VALUES ('Keyboard', 49.90), ('Mouse', 19.90), ('Monitor', 219.00)) AS seed(name, price)
WHERE NOT EXISTS (SELECT 1 FROM products);
""";

try
{
Console.WriteLine("Migrator: connecting to the catalog database...");
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();

await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();

Console.WriteLine("Migrator: schema version 1 is in place.");
return 0;
}
catch (Exception ex)
{
// A non-zero exit code is the only signal Kubernetes reads from an init container.
Console.Error.WriteLine($"Migrator: migration failed: {ex.Message}");
return 1;
}

Two details matter here:

  1. Every statement is idempotent, so a retry or a second Pod running the same migration is harmless.
  2. The tool catches the exception and returns a non-zero exit code. Do not let an unhandled exception decide the outcome. In our first version the migrator let the exception escape, and the process, running as PID 1 in its container, printed the stack trace and then simply kept running. The Pod sat in Init:0/1 with the init container Running forever, and Kubernetes never retried. An explicit return 1 turns the failure into something the kubelet can see.

In production, EF Core users would call context.Database.MigrateAsync() or run an EF Core migration bundle here instead of raw SQL. The init container pattern is the same.

The Dockerfile uses the small runtime image, because a console app does not need ASP.NET Core:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY CatalogMigrator/ .
RUN dotnet publish CatalogMigrator.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "CatalogMigrator.dll"]

Step 2 — The API

dotnet new web -n CatalogApi
cd CatalogApi
dotnet add package Npgsql

Program.cs reads the products and the file that the third init container writes:

using Npgsql;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(NpgsqlDataSource.Create(builder.Configuration["CATALOG_DB"]!));
var app = builder.Build();

string configPath = "/config/app-settings.txt";

app.MapGet("/products", async (NpgsqlDataSource db) =>
{
var products = new List<object>();
await using var cmd = db.CreateCommand("SELECT id, name, price FROM products ORDER BY id");
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
products.Add(new { id = reader.GetInt32(0), name = reader.GetString(1), price = reader.GetDecimal(2) });
return Results.Ok(products);
});

app.MapGet("/config", () =>
File.Exists(configPath) ? Results.Text(File.ReadAllText(configPath)) : Results.NotFound("no config file"));

app.Run();
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY CatalogApi/ .
RUN dotnet publish CatalogApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "CatalogApi.dll"]

Build both images from the folder that contains the two projects:

docker build -t shop-catalog-migrator:1.0 -f Dockerfile.migrator .
docker build -t shop-catalog-api:1.0 -f Dockerfile.api .

Step 3 — Namespace and Credentials

Both the migrator and the API read the connection string from the same Secret. GSS Encryption Mode=Disable stops Npgsql from probing for Kerberos libraries that the slim .NET images do not contain. Without it every connection prints a harmless but noisy libgssapi_krb5.so.2 error.

apiVersion: v1
kind: Namespace
metadata:
name: mc-demo
---
apiVersion: v1
kind: Secret
metadata:
name: catalog-db-credentials
namespace: mc-demo
type: Opaque
stringData:
POSTGRES_PASSWORD: Passw0rd!
CATALOG_DB: Host=catalog-db;Port=5432;Database=postgres;Username=postgres;Password=Passw0rd!;GSS Encryption Mode=Disable
kubectl apply -f 1-namespace-db.yaml

Step 4 — The Deployment with Three Init Containers

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
namespace: mc-demo
spec:
replicas: 1
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
initContainers:
- name: wait-for-db
image: postgres:17
command: ["sh", "-c", "until pg_isready -h catalog-db -p 5432; do echo waiting for catalog-db; sleep 2; done"]
- name: migrate
image: shop-catalog-migrator:1.0
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: catalog-db-credentials
- name: render-config
image: busybox:1.37
command: ["sh", "-c", "echo \"rendered-by=$(hostname) at=$(date -u +%FT%TZ)\" > /config/app-settings.txt"]
volumeMounts:
- name: config
mountPath: /config
containers:
- name: catalog-api
image: shop-catalog-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: catalog-db-credentials
volumeMounts:
- name: config
mountPath: /config
readOnly: true
volumes:
- name: config
emptyDir: {}

The wait-for-db container reuses the postgres:17 image only for its pg_isready client. The render-config container and the API mount the same emptyDir, and the API mounts it read-only.

kubectl apply -f 2-catalog-api.yaml
kubectl get pods -n mc-demo
NAME READY STATUS RESTARTS AGE
catalog-api-78465668b6-lh9vr 0/1 Init:0/3 0 15s

kubectl logs -n mc-demo deploy/catalog-api -c wait-for-db --tail=3
waiting for catalog-db
catalog-db:5432 - no response
waiting for catalog-db

The Pod is not crashing. It is patiently waiting, and the status tells you exactly where: zero of three init containers are done.

Step 5 — Bring Up the Database and Watch the Init Chain

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-db
namespace: mc-demo
spec:
replicas: 1
selector:
matchLabels:
app: catalog-db
template:
metadata:
labels:
app: catalog-db
spec:
containers:
- name: postgres
image: postgres:17
ports:
- containerPort: 5432
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: catalog-db-credentials
key: POSTGRES_PASSWORD
---
apiVersion: v1
kind: Service
metadata:
name: catalog-db
namespace: mc-demo
spec:
selector:
app: catalog-db
ports:
- port: 5432
targetPort: 5432

This database has no persistent volume. That is fine for this demo and wrong for anything else; the storage and StatefulSet articles cover the real setup.

kubectl apply -f 3-catalog-db.yaml
kubectl get pods -n mc-demo -l app=catalog-api -w
NAME READY STATUS RESTARTS AGE
catalog-api-78465668b6-lh9vr 0/1 Init:0/3 0 21s
catalog-api-78465668b6-lh9vr 0/1 Init:1/3 0 30s
catalog-api-78465668b6-lh9vr 0/1 Init:2/3 0 31s
catalog-api-78465668b6-lh9vr 0/1 PodInitializing 0 32s
catalog-api-78465668b6-lh9vr 1/1 Running 0 32s

The chain ran in order: waiting ended, the migration ran, the config was rendered, and only then did the API start. Read each step's logs:

kubectl logs -n mc-demo deploy/catalog-api -c wait-for-db --tail=2
waiting for catalog-db
catalog-db:5432 - accepting connections

kubectl logs -n mc-demo deploy/catalog-api -c migrate
Migrator: connecting to the catalog database...
Migrator: schema version 1 is in place.

And check the final state of every init container:

kubectl get pod -n mc-demo <pod> -o jsonpath='{range .status.initContainerStatuses[*]}{.name}{"\t"}{.state.terminated.reason}{"\t"}{.state.terminated.exitCode}{"\n"}{end}'
wait-for-db Completed 0
migrate Completed 0
render-config Completed 0

Step 6 — Call the API

kubectl port-forward -n mc-demo deploy/catalog-api 8080:8080

In a second terminal:

curl localhost:8080/products
[{"id":1,"name":"Keyboard","price":49.90},{"id":2,"name":"Mouse","price":19.90},{"id":3,"name":"Monitor","price":219.00}]

curl localhost:8080/config
rendered-by=catalog-api-7f58868896-2dsk4 at=2026-10-21T08:27:56Z

The products came from the tables the migrator created, and the config file was written by a container that no longer exists. It lives on in the shared emptyDir.

Step 7 — App Restarts Do Not Re-Run Init Containers

Stop the application process inside the running Pod. Here we kill PID 1 in the API container:

kubectl exec -n mc-demo <pod> -c catalog-api -- sh -c 'kill 1'
kubectl get pod -n mc-demo <pod>
NAME READY STATUS RESTARTS AGE
catalog-api-7f58868896-2dsk4 1/1 Running 1 (8s ago) 5m39s
kubectl get pod -n mc-demo <pod> -o jsonpath='{range .status.initContainerStatuses[*]}{.name}{" restarts="}{.restartCount}{"\n"}{end}{range .status.containerStatuses[*]}{.name}{" restarts="}{.restartCount}{"\n"}{end}'
wait-for-db restarts=0
migrate restarts=0
render-config restarts=0
catalog-api restarts=1

kubectl exec -n mc-demo <pod> -c catalog-api -- cat /config/app-settings.txt
rendered-by=catalog-api-7f58868896-2dsk4 at=2026-10-21T08:27:56Z

The API restarted once. The init containers did not run again, and the config file still carries the original timestamp. Init containers run once per Pod, not once per container start. A kubectl rollout restart, by contrast, creates new Pods and runs the whole chain again.

Step 8 — Break the Migration

Create a Pod whose migrator uses a wrong password:

apiVersion: v1
kind: Pod
metadata:
name: catalog-api-broken
namespace: mc-demo
spec:
initContainers:
- name: migrate
image: shop-catalog-migrator:1.0
imagePullPolicy: IfNotPresent
env:
- name: CATALOG_DB
value: Host=catalog-db;Port=5432;Database=postgres;Username=postgres;Password=wrong-password;GSS Encryption Mode=Disable
containers:
- name: catalog-api
image: shop-catalog-api:1.0
imagePullPolicy: IfNotPresent
kubectl apply -f 4-broken-migration.yaml
kubectl get pod catalog-api-broken -n mc-demo -w
NAME READY STATUS RESTARTS AGE
catalog-api-broken 0/1 Init:Error 1 (4s ago) 5s
catalog-api-broken 0/1 Init:Error 2 (18s ago) 20s
catalog-api-broken 0/1 Init:0/1 3 (29s ago) 46s
catalog-api-broken 0/1 Init:Error 3 (34s ago) 51s

The status alternates between Init:Error (the last attempt failed) and Init:0/1 (a new attempt is running), while the restart count grows. Depending on timing and the Kubernetes version, kubectl may also show Init:CrashLoopBackOff during the back-off delay; the BackOff event below confirms the same thing. The application container never starts.

Step 9 — Debug It

Start with the logs of the failing init container:

kubectl logs catalog-api-broken -n mc-demo -c migrate
Migrator: connecting to the catalog database...
Migrator: migration failed: 28P01: password authentication failed for user "postgres"

Then confirm what Kubernetes saw:

kubectl describe pod catalog-api-broken -n mc-demo
...
Init Containers:
migrate:
State: Terminated
Reason: Error
Exit Code: 1
Last State: Terminated
Reason: Error
Exit Code: 1
Restart Count: 3
...

The events show the back-off:

kubectl events -n mc-demo --for pod/catalog-api-broken
29s (x4 over 70s) Normal Started Pod/catalog-api-broken Container started
28s (x3 over 69s) Warning BackOff Pod/catalog-api-broken Back-off restarting failed container migrate in pod catalog-api-broken_mc-demo(...)

A checklist for any Pod stuck in an init phase:

SymptomLook atTypical cause
Init:0/N for a long time, container Runningkubectl logs -c <init>Waiting for a dependency, or a process that never exits
Init:Error, Init:CrashLoopBackOffkubectl logs -c <init> and --previousBad credentials, wrong host, failing script
Init:ImagePullBackOffkubectl describe pod eventsWrong image name or tag, missing pull secret
Pending before any init runskubectl describe pod eventsRequests too large, often because of an init container

Fix the Secret or the manifest, then delete the Pod. A Deployment creates a fresh Pod that runs the whole init chain again.

kubectl delete pod catalog-api-broken -n mc-demo

Step 10 — See Init Requests Block Scheduling

An init container that asks for more than any node has makes the whole Pod unschedulable, even though the application itself is tiny:

apiVersion: v1
kind: Pod
metadata:
name: greedy-init
namespace: mc-demo
spec:
initContainers:
- name: warm-cache
image: busybox:1.37
command: ["sh", "-c", "echo warming; sleep 5"]
resources:
requests:
cpu: "12"
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "sleep 3600"]
resources:
requests:
cpu: 100m
kubectl apply -f 5-resource-accounting.yaml
kubectl get pod greedy-init -n mc-demo
NAME READY STATUS RESTARTS AGE
greedy-init 0/1 Pending 0 5s

kubectl events -n mc-demo --for pod/greedy-init
Warning FailedScheduling Pod/greedy-init 0/4 nodes are available: 1 node(s) had untolerated taint(s), 3 Insufficient cpu. ...

The nodes in this cluster have 8 CPUs. The effective request is max(12, 0.1) = 12 CPUs, so no worker fits, and the control-plane node is excluded by its taint. Size init container requests for what they really need.

Step 11 — Clean Up

kubectl delete namespace mc-demo

Summary

  1. Init containers run one at a time, in order, and must exit with code 0 before any application container starts.
  2. They are ideal for waiting for dependencies, database migrations and preparing files, and keep those tools out of the application image.
  3. Share their output with the application through a volume such as emptyDir.
  4. Failed init containers are retried with back-off under restartPolicy: Always, so they must be idempotent.
  5. They run once per Pod: an application container restart does not re-run them, a new Pod does.
  6. A .NET init tool should catch exceptions and return a non-zero exit code explicitly; we saw an unhandled exception leave the process running and the Pod stuck.
  7. The Pod's effective request is the larger of the biggest init request and the sum of the application requests.
  8. Debug with kubectl logs -c <init>, --previous, describe and the Pod's events.

In the next article we look at containers that start before your application but keep running next to it: native sidecar containers, and the classic multi-container patterns built on them.