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
--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
- Docker installed and running. See Install Docker on macOS, Windows (WSL2), and Linux.
- Comfort with Compose, from Docker Compose: Run a Multi-Service Stack.
Info
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:
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}}"1svc Up 5 seconds (healthy)Now break the check by deleting the file, wait for the interval to run, and look again:
1docker exec svc rm /tmp/ok2docker ps --format "{{.Names}} {{.Status}}"1svc Up 12 seconds (unhealthy)Recreate the file and it recovers:
1docker exec svc touch /tmp/ok2docker ps --format "{{.Names}} {{.Status}}"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:
1docker compose up -d1 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 StartedRead 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.
1docker compose ps --format "table {{.Service}}\t{{.Status}}"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.
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}}'1OOMKilled=true ExitCode=137OOMKilled=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:
1docker run -d --name cpuhog --cpus=0.5 alpine sh -c "while true; do :; done"2docker stats --no-stream --format "{{.Name}} CPU={{.CPUPerc}}"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:
1docker inspect op-demo-web-1 --format 'memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}'1memory=134217728 nanocpus=500000000That 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:
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}}'1RestartCount=0 Status=running2RestartCount=3 Status=exited ExitCode=1Docker 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
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:
1printf "GREETING=from_env_file\nONLY_IN_FILE=yes\n" > envfile2docker run --rm --env-file envfile -e GREETING=from_flag alpine env1ONLY_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:
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.


