← Back to GuidesGUIDESelf-Hosting & Homelab

Operating Containers: Healthchecks, Resource Limits, Restart Policies, and Env Config

about 1 hour ago
❤️ 0 likes
💬 0 comments
dockerself-hostinglinux
Operating Containers: Healthchecks, Resource Limits, Restart Policies, and Env Config

A container that runs is not the same as a container that behaves. In development, "it started" is enough. In production you want more: the container should know when it is broken, stay within a memory and CPU budget, come back on its own after a crash, and get its config cleanly without secrets baked into the image. This post adds all four to a real stack, with commands and output captured on a live Docker Engine.

Tip

Key takeaways A healthcheck lets Docker report a container as healthy or unhealthy, and Compose can hold a dependent back until its dependency is healthy.Resource limits (--memory, --cpus, or deploy.resources.limits in Compose) cap what a container can use. Exceed memory and it is killed with exit code 137.A restart policy (restart: unless-stopped or on-failure) brings a crashed container back automatically.Load config from an env file so it stays out of the image, and know the precedence: a -e flag beats --env-file.

Prerequisites

Info

Get the code. The full stack for this post (compose file, health endpoint, env file) is in the docker-foundations repo, under `07-operating/`. Clone it to follow along.

Healthchecks: does the container actually work?

A running container is not necessarily a working one. A web server can be up while its process is deadlocked. A healthcheck is a command Docker runs inside the container on an interval; if it passes, the container is healthy, if it fails enough times, unhealthy.

Here is the smallest demonstration. Run a container whose health depends on a file existing, then create and remove that file to flip its state:

Bash
1docker run -d --name svc \2  --health-cmd="test -f /tmp/ok" --health-interval=2s --health-retries=2 \3  alpine sh -c "touch /tmp/ok; sleep 3600"4docker ps --format "{{.Names}}  {{.Status}}"
Plain Text
1svc  Up 5 seconds (healthy)

Now break the check by deleting the file, wait for the interval to run, and look again:

Bash
1docker exec svc rm /tmp/ok2docker ps --format "{{.Names}}  {{.Status}}"
Plain Text
1svc  Up 12 seconds (unhealthy)

Recreate the file and it recovers:

Bash
1docker exec svc touch /tmp/ok2docker ps --format "{{.Names}}  {{.Status}}"
Plain Text
1svc  Up 19 seconds (healthy)

The status column tracks the health state live. In a real image you set this once with a HEALTHCHECK instruction in the Dockerfile, or a healthcheck: block in Compose.

Health-gated startup in Compose

The real payoff is dependency ordering. In the Compose post we saw that depends_on waits for a container to start, not to be ready. A healthcheck fixes that: depends_on with condition: service_healthy holds a service back until its dependency reports healthy.

Our stack has a web service that depends on a Postgres db with a pg_isready healthcheck. Bring it up:

Bash
1docker compose up -d
Plain Text
1 Container op-demo-db-1   Started2 Container op-demo-db-1   Waiting3 Container op-demo-db-1   Healthy4 Container op-demo-web-1  Starting5 Container op-demo-web-1  Started

Read that order: Compose started db, then waited until it was healthy, and only then started web. No more racing a database that has not finished booting.

Bash
1docker compose ps --format "table {{.Service}}\t{{.Status}}"
Plain Text
1SERVICE   STATUS2db        Up 7 seconds (healthy)3web       Up 4 seconds (healthy)

Resource limits: stay in budget

By default a container can use all the host's memory and CPU. One runaway process can starve everything else on the box. Limits fix that.

Memory

Cap memory with --memory (and --memory-swap to also cap swap). When a container tries to exceed its memory limit, the kernel kills it. Watch it happen: this Python container asks for 200MB with a 64MB cap.

Bash
1docker run --name oom --memory=64m --memory-swap=64m \2  python:3-alpine python -c "bytearray(200*1024*1024)"3docker inspect oom --format 'OOMKilled={{.State.OOMKilled}}  ExitCode={{.State.ExitCode}}'
Plain Text
1OOMKilled=true  ExitCode=137

OOMKilled=true and exit code 137 are the signature of a container killed for exceeding its memory limit. If you ever see a container mysteriously exit with 137, this is almost always why.

CPU

Cap CPU with --cpus. This container runs a busy loop that would otherwise peg a whole core, limited to half a CPU:

Bash
1docker run -d --name cpuhog --cpus=0.5 alpine sh -c "while true; do :; done"2docker stats --no-stream --format "{{.Name}}  CPU={{.CPUPerc}}"
Plain Text
1cpuhog  CPU=49.71%

The busy loop is held right at its half-a-CPU ceiling instead of consuming everything.

Limits in Compose

In a Compose file you set the same limits per service under deploy.resources.limits, which docker compose up applies (you do not need Swarm). After bringing the stack up, you can confirm the limit landed on the container:

Bash
1docker inspect op-demo-web-1 --format 'memory={{.HostConfig.Memory}}  nanocpus={{.HostConfig.NanoCpus}}'
Plain Text
1memory=134217728  nanocpus=500000000

That is the 128MB (134217728 bytes) and half-CPU (500000000 nanocpus) budget from the compose file, enforced on the running container.

Restart policies: self-healing

Processes crash. A restart policy tells Docker to bring the container back automatically. unless-stopped restarts it on any exit except a deliberate docker stop, and on-failure restarts only on a non-zero exit, optionally up to a retry cap.

Watch a crashing container recover. This one runs for a few seconds, then exits with an error, with a cap of three retries:

Bash
1docker run -d --name crasher --restart on-failure:3 alpine sh -c "sleep 3; exit 1"2docker inspect crasher --format 'RestartCount={{.RestartCount}} Status={{.State.Status}}'3# ... a few crash-and-restart cycles later ...4docker inspect crasher --format 'RestartCount={{.RestartCount}} Status={{.State.Status}} ExitCode={{.State.ExitCode}}'
Plain Text
1RestartCount=0 Status=running2RestartCount=3 Status=exited ExitCode=1

Docker restarted the crashing container three times, then stopped because it hit the :3 cap. On a real service you would use restart: unless-stopped (no cap) so it keeps recovering, which is what our compose file sets on both services.

Info

A restart policy is not a healthcheck. A restart policy reacts to the container process exiting. A healthcheck reacts to the app being unresponsive while the process is still up. Production services usually want both: restart: unless-stopped to recover from crashes, and a healthcheck so orchestrators and depends_on know when the app is actually ready.

Env config without secrets in the image

Hardcoding config into an image is a mistake: it bakes environment-specific values (and often secrets) into an artifact you push to a registry. Load them at run time instead. Docker pulls environment values from three places (the image's own ENV, an --env-file, and -e flags), and the precedence matters: a -e flag beats --env-file, and both beat a value baked into the image with ENV:

Bash
1printf "GREETING=from_env_file\nONLY_IN_FILE=yes\n" > envfile2docker run --rm --env-file envfile -e GREETING=from_flag alpine env
Plain Text
1ONLY_IN_FILE=yes2GREETING=from_flag3# (PATH, HOSTNAME, HOME and other standard vars omitted)

GREETING came out as from_flag: the explicit -e won over the file. ONLY_IN_FILE passed through untouched. The same order holds in Compose: a service's environment: block overrides its env_file:. Keep the file (.env) out of git, commit an .env.example template, and your secrets never enter the image.

The whole thing in one Compose file

All four behaviors live together in the stack's docker-compose.yml:

YAML
1services:2  db:3    image: postgres:16-alpine4    environment:5      POSTGRES_USER: demo6      POSTGRES_PASSWORD: demo7      POSTGRES_DB: demo8    healthcheck:9      test: ["CMD-SHELL", "pg_isready -U demo"]10      interval: 3s11      timeout: 3s12      retries: 513    restart: unless-stopped14    deploy:15      resources:16        limits:17          memory: 256M18          cpus: "0.50"19 20  web:21    image: node:22-alpine22    working_dir: /app23    command: node server.js24    volumes:25      - ./app:/app26    env_file: .env27    ports:28      - "8080:3000"29    depends_on:30      db:31        condition: service_healthy   # web starts only once db reports healthy32    healthcheck:33      test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/health || exit 1"]34      interval: 3s35      timeout: 3s36      retries: 537    restart: unless-stopped38    deploy:39      resources:40        limits:41          memory: 128M42          cpus: "0.50"

docker compose up -d, and both services come up healthy, budgeted, self-healing, and configured from .env.

Common gotchas

Container exits with code 137

It was killed for exceeding its memory limit (or received a SIGKILL). Check docker inspect --format '{{.State.OOMKilled}}'. If true, raise the limit or fix the leak.

Healthcheck passes but the app is broken

Your check is too shallow. pg_isready proves Postgres accepts connections; a check that only pings the port proves less. Point the healthcheck at an endpoint that actually exercises the app, like a /health route that touches its dependencies.

depends_on: condition: service_healthy does nothing

The dependency has no healthcheck, so it can never report healthy. Add a healthcheck: block to the service you are waiting on.

The container keeps restarting forever

A restart policy plus a container that crashes instantly is a crash loop. Use on-failure:<n> to cap retries while you debug, check docker logs, and fix the underlying crash before switching back to unless-stopped.

My .env changes are ignored

Compose reads .env from the project directory for variable substitution, and env_file: for what a service sees. Make sure you edited the right one, and recreate the container (docker compose up -d again) so it picks up the new values.

Where to go next

Your containers now behave: they report health, respect limits, recover from crashes, and take config cleanly. The next step is making them safe.

  • Next in this series: Docker Security Basics, running as non-root, mounting the filesystem read-only, dropping capabilities, and keeping secrets out of images.

Verified on 2026-09-11 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0 and Compose v5.5.1. Captured: healthcheck transitions healthy/unhealthy/healthy; a Compose stack where db went Started to Waiting to Healthy before web started; a memory-limited container OOM-killed with ExitCode 137; a `--cpus=0.5` busy loop held at 49.71% in `docker stats`; a crashing container restarted to RestartCount 3 under `on-failure:3`; `-e` overriding `--env-file`; and `deploy.resources.limits` applied as 134217728 bytes / 500000000 nanocpus on the web container.

Join the discussion on Operating Containers: Healthchecks, Resource Limits, Restart Policies, and Env Config

Likes, comments, and replies are available for authenticated readers with verified email addresses.

Comments (0)

Loading discussion...

Related guides