Running containers well: restart policies, limits, config and Compose
Run containers like services: restart policies, memory and CPU limits, configuration from outside, and a whole stack of web, API, database and migration in one Compose file.
By the end of this lesson you'll be able to
0 of 6 completeRun it like a service
So far containers ran until you stopped them. A real service has to survive crashes and reboots, and must not take the whole host down with it.
Restart policies
$ docker run -d --name flaky --restart on-failure:3 alpine:3.22 sh -c 'echo starting; exit 1' $ docker inspect flaky --format 'restarts={{.RestartCount}} status={{.State.Status}} exit={{.State.ExitCode}}' restarts=3 status=exited exit=1 $ docker logs flaky starting starting starting starting
docker run -d --name flaky --restart on-failure:3 alpine:3.22 sh -c 'echo starting; exit 1'dockerbuild and run containersrunstart a new container-drun in the background--name flakyname the container--restart on-failure:3when Docker restarts italpine:3.22the image to startsh -c 'echo starting; exit 1'the command run inside, instead of the image’s default
docker inspect flaky --format 'restarts={{.RestartCount}} status={{.State.Status}} exit={{.State.ExitCode}}'inspectshow every detail as JSONflakythe container or image--format 'restarts={{.RestartCount}} status={{.State.Status}} exit={{.State.ExitCode}}'print only these fields (Go template)
docker logs flakylogsshow a container’s outputflakythe container
One run plus three restarts, then Docker gives up. The policies:
--restart | Restarts when |
|---|---|
no (default) | never |
on-failure[:N] | it exits with a non-zero code, up to N times |
unless-stopped | it exits for any reason, and after a reboot, unless you stopped it |
always | like unless-stopped, but also after a reboot when you had stopped it |
unless-stopped is the usual choice for a service on a single host. Docker waits longer between each
restart, so a crash loop doesn't spin the CPU.
Limits
Without limits a container can use all the host's memory and CPU. Limits are cgroup settings (lesson 2):
$ docker run --rm --cpus 0.5 --memory 128m --pids-limit 100 alpine:3.22 \ sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/pids.max' 134217728 50000 100000 100 $ docker run -d --name hog --memory 64m alpine:3.22 sh -c 'tail /dev/zero' $ docker inspect hog --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}' oom=true exit=137
docker run --rm --cpus 0.5 --memory 128m --pids-limit 100 alpine:3.22 \dockerbuild and run containersrunstart a new container--rmdelete the container when it exits--cpus 0.5CPU limit, in cores--memory 128mmemory limit--pids-limit 100maximum number of processesalpine:3.22the image to start\the command run inside, instead of the image’s default
docker run -d --name hog --memory 64m alpine:3.22 sh -c 'tail /dev/zero'-drun in the background--name hogname the container--memory 64mmemory limitsh -c 'tail /dev/zero'the command run inside, instead of the image’s default
docker inspect hog --format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'inspectshow every detail as JSONhogthe container or image--format 'oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'print only these fields (Go template)
cpu.max of 50000 100000 means 50ms of CPU in every 100ms: half a core. tail /dev/zero keeps reading
into memory until it hits 64MB and the kernel kills it. Exit code 137 (128 + signal 9) with
OOMKilled=true is the signature to look for when a container keeps dying. --pids-limit stops a fork bomb.
docker stats shows live usage against each limit.
Hardening at run time
$ docker run --rm --read-only alpine:3.22 touch /x touch: /x: Read-only file system $ docker run --rm --read-only --tmpfs /tmp alpine:3.22 sh -c 'touch /tmp/x && echo tmp-ok' tmp-ok $ docker run --rm --init alpine:3.22 ps -o pid,comm PID COMMAND 1 docker-init 7 ps
docker run --rm --read-only alpine:3.22 touch /xdockerbuild and run containersrunstart a new container--rmdelete the container when it exits--read-onlymake the container’s filesystem read-onlyalpine:3.22the image to starttouch /xthe command run inside, instead of the image’s default
docker run --rm --read-only --tmpfs /tmp alpine:3.22 sh -c 'touch /tmp/x && echo tmp-ok'--tmpfs /tmpa folder kept in memory onlysh -c 'touch /tmp/x && echo tmp-ok'the command run inside, instead of the image’s default
docker run --rm --init alpine:3.22 ps -o pid,comm--inita tiny init as PID 1 that forwards signalsps -o pid,commthe command run inside, instead of the image’s default
--read-only makes the image's filesystem unwritable, so an attacker can't drop files into it; give the app
a --tmpfs or a volume for the places it must write. --init runs a tiny init as PID 1 that forwards
signals and reaps zombie processes, for apps that don't do that themselves. Lesson 11 adds more.
Configuration from outside
Build one image, and configure it per environment when you run it. Never bake settings into the image:
$ docker run -e APP_VERSION=1.2.0 price-api:multi # one variable $ printf 'APP_VERSION=1.2.0\nLOG_LEVEL=debug\n' > app.env $ docker run --rm --env-file app.env alpine:3.22 sh -c 'env | grep -E "APP_|LOG_"' LOG_LEVEL=debug APP_VERSION=1.2.0 $ docker run --rm alpine:3.22 echo override # anything after the image replaces CMD $ docker run --rm --entrypoint sh price-api:single -c 'ls /src' # replace the ENTRYPOINT
docker run -e APP_VERSION=1.2.0 price-api:multidockerbuild and run containersrunstart a new container-e APP_VERSION=1.2.0set an environment variable insideprice-api:multithe image to start
printf 'APP_VERSION=1.2.0\nLOG_LEVEL=debug\n' > app.env>write output to a file, replacing it
docker run --rm --env-file app.env alpine:3.22 sh -c 'env | grep -E "APP_|LOG_"'--rmdelete the container when it exits--env-file app.envread environment variables from a filealpine:3.22the image to startsh -c 'env | grep -E "APP_|LOG_"'the command run inside, instead of the image’s default
docker run --rm alpine:3.22 echo overrideecho overridethe command run inside, instead of the image’s default
docker run --rm --entrypoint sh price-api:single -c 'ls /src'--entrypoint shreplace the image’s ENTRYPOINTprice-api:singlethe image to start'ls /src'the command run inside, instead of the image’s default
Environment variables are visible to anyone who can run docker inspect, so they suit settings, not
secrets. For passwords, use files mounted from a secret store, as the Compose example below does.
Docker Compose
A real app is several containers: here a web front, the API from lesson 7, a database and a one-off migration.
Typing all their docker run flags is error-prone. Compose describes them in one file, compose.yaml:
name: shop services: web: image: nginx:1.30-alpine ports: - "127.0.0.1:8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - api api: build: . image: price-api:dev environment: APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped db: image: postgres:17-alpine environment: POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "postgres"] interval: 2s retries: 15 migrate: image: postgres:17-alpine depends_on: db: condition: service_healthy secrets: - db_password command: ["sh", "-c", "PGPASSWORD=$$(cat /run/secrets/db_password) psql -h db -U postgres -c 'create table if not exists prices(item text primary key, cents int)'"] volumes: pgdata: secrets: db_password: file: ./db_password.txt
nginx.conf sends /api/ to http://api:8080/: services reach each other by name. ${APP_VERSION:-dev}
is read from your shell or a .env file next to compose.yaml, with dev as the default. $$ is a literal
$ for the shell inside the container. The secret is mounted as a file at /run/secrets/db_password and
never appears in docker inspect.
$ printf 'devpass' > db_password.txt $ docker compose up -d
printf 'devpass' > db_password.txt>write output to a file, replacing it
docker compose up -ddockerbuild and run containerscomposerun a multi-container app from compose.yamlupcreate and start every service-din the background
$ curl -s localhost:8080/api/health {"status":"ok","version":"dev"} $ docker compose logs migrate migrate-1 | CREATE TABLE $ docker compose exec db psql -h 127.0.0.1 -U postgres -c '\dt' public | prices | table | postgres
curl -s localhost:8080/api/healthcurlmake an HTTP request-ssilent: no progress bar
docker compose logs migratedockerbuild and run containerscomposerun a multi-container app from compose.yamllogsshow a service’s outputmigratethe service
docker compose exec db psql -h 127.0.0.1 -U postgres -c '\dt'execrun a command in a running servicedbthe servicepsql -h 127.0.0.1 -U postgres -c '\dt'the command to run inside it
The everyday commands:
| Command | Does |
|---|---|
docker compose up -d | create or update everything; only changed services are recreated |
docker compose up -d --build | rebuild images with a build: first |
docker compose ps -a | services and their state, including finished ones |
docker compose logs -f api | follow one service's logs |
docker compose exec db sh | a shell in a running service |
docker compose config | the final file after variables are filled in: check it before you blame Compose |
docker compose down | remove containers and the network, keep volumes |
docker compose down -v | also delete the named volumes: the database is gone |
Runtime guardrails: restart, limits, logs and PID 1
on-failure:3 retries crashes; unless-stopped survives daemon restarts. Cgroup limits prevent one container
consuming the host. Rotate logs, and use --init so PID 1 reaps child processes.
$ docker run -d --name worker --restart unless-stopped --memory 256m --cpus 0.50 --pids-limit 100 \ --init --log-opt max-size=10m --log-opt max-file=3 alpine:3.22 sleep 300 $ docker inspect worker --format 'restart={{.HostConfig.RestartPolicy.Name}} memory={{.HostConfig.Memory}} init={{.Config.Init}}' restart=unless-stopped memory=268435456 init=true $ docker rm -f worker
docker run -d --name worker --restart unless-stopped --memory 256m --cpus 0.50 --pids-limit 100 \dockerbuild and run containersrunstart a new container-drun in the background--name workername the container--restart unless-stoppedwhen Docker restarts it--memory 256mmemory limit--cpus 0.50CPU limit, in cores--pids-limit 100maximum number of processes\the image to start
docker inspect worker --format 'restart={{.HostConfig.RestartPolicy.Name}} memory={{.HostConfig.Memory}} init={{.Config.Init}}'inspectshow every detail as JSONworkerthe container or image--format 'restart={{.HostConfig.RestartPolicy.Name}} memory={{.HostConfig.Memory}} init={{.Config.Init}}'print only these fields (Go template)
docker rm -f workerrmdelete a container-fstop it first if runningworkerthe container
Compose profiles and override files keep optional tools and local ports out of production. depends_on with
service_healthy waits for a health check; it does not restart a failed service.
One command, four services
Write the compose.yaml from this lesson with web, api, db and migrate. Bring it up, prove migrate waited for the database and created the table, reach the API through nginx, then tear it down while keeping the data, and finally delete it.
migrate Exited (0), curl returning status ok, and shop_pgdata surviving docker compose downReveal one safe solution
$ printf 'server {\n listen 80;\n location / { return 200 "shop front\\n"; }\n location /api/ { proxy_pass http://api:8080/; }\n}\n' > nginx.conf $ printf 'devpass' > db_password.txt && echo db_password.txt >> .gitignore $ docker compose up -d $ docker compose ps -a --format 'table {{.Service}}\t{{.Status}}' $ curl -s localhost:8080/api/health {"status":"ok","version":"dev"} $ docker compose exec db psql -h 127.0.0.1 -U postgres -tAc "select count(*) from information_schema.tables where table_name='prices'" 1 $ docker compose down && docker volume ls --filter name=shop DRIVER VOLUME NAME local shop_pgdata $ docker compose down -v
printf 'server {\n listen 80;\n location / { return 200 "shop front\\n"; }\n location /api/ { proxy_pass http://api:8080/; }\n}\n' > nginx.conf>write output to a file, replacing it
printf 'devpass' > db_password.txt && echo db_password.txt >> .gitignore&&run the next command only if this one workedechoprint text>>append output to a file
docker compose up -ddockerbuild and run containerscomposerun a multi-container app from compose.yamlupcreate and start every service-din the background
docker compose ps -a --format 'table {{.Service}}\t{{.Status}}'pslist the services and their state-ainclude stopped ones too--format 'table {{.Service}}\t{{.Status}}'print only these fields (Go template)
curl -s localhost:8080/api/healthcurlmake an HTTP request-ssilent: no progress bar
docker compose exec db psql -h 127.0.0.1 -U postgres -tAc "select count(*) from information_schema.tables where table_name='prices'"execrun a command in a running servicedbthe servicepsql -h 127.0.0.1 -U postgres -tAc "select count(*) from information_schema.tables where table_name='prices'"the command to run inside it
docker compose down && docker volume ls --filter name=shopdownstop and remove the containers and networkvolumemanage volumeslslist them--filter name=shopshow only matching items
docker compose down -v-valso delete volumes
down kept the volume, so the next up finds the table already there (create table if not exists makes
the migration safe to run twice). down -v is the clean slate.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
Which restart policy brings a container back after a crash and after a reboot, but not after you stop it yourself?
Official reading for this lesson
Or get every quiz answer right and it completes itself.