DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    DockerLesson 9 of 14
    Your progress · 0%
    On this page
    1. Run it like a service
    2. Configuration from outside
    3. Docker Compose
    4. Runtime guardrails: restart, limits, logs and PID 1
    ↖ Course roadmap
    Lesson 9 · Running containers

    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.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 6 complete

    Run 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 containers
    • runstart a new container
    • -drun in the background
    • --name flakyname the container
    • --restart on-failure:3when Docker restarts it
    • alpine:3.22the image to start
    • sh -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 JSON
    • flakythe container or image
    • --format 'restarts={{.RestartCount}} status={{.State.Status}} exit={{.State.ExitCode}}'print only these fields (Go template)
    docker logs flaky
    • logsshow a container’s output
    • flakythe container

    One run plus three restarts, then Docker gives up. The policies:

    --restartRestarts when
    no (default)never
    on-failure[:N]it exits with a non-zero code, up to N times
    unless-stoppedit exits for any reason, and after a reboot, unless you stopped it
    alwayslike 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 containers
    • runstart a new container
    • --rmdelete the container when it exits
    • --cpus 0.5CPU limit, in cores
    • --memory 128mmemory limit
    • --pids-limit 100maximum number of processes
    • alpine: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 limit
    • sh -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 JSON
    • hogthe 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 /x
    • dockerbuild and run containers
    • runstart a new container
    • --rmdelete the container when it exits
    • --read-onlymake the container’s filesystem read-only
    • alpine:3.22the image to start
    • touch /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 only
    • sh -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 signals
    • ps -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:multi
    • dockerbuild and run containers
    • runstart a new container
    • -e APP_VERSION=1.2.0set an environment variable inside
    • price-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 file
    • alpine:3.22the image to start
    • sh -c 'env | grep -E "APP_|LOG_"'the command run inside, instead of the image’s default
    docker run --rm alpine:3.22 echo override
    • echo 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 ENTRYPOINT
    • price-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.

    Order from depends_on, readiness from the healthcheck. migrate never runs against a database that isn't ready.© Animation: DevOps Academy

    Order from depends_on, readiness from the healthcheck. migrate never runs against a database that isn't ready.© Animation: DevOps Academy
    $ printf 'devpass' > db_password.txt
    $ docker compose up -d
    
    printf 'devpass' > db_password.txt
    • >write output to a file, replacing it
    docker compose up -d
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • upcreate and start every service
    • -din the background

    migrate started only once db reported Healthy, then exited 0 after creating the table.© Screenshot: DevOps Academy

    migrate started only once db reported Healthy, then exited 0 after creating the table.© Screenshot: DevOps Academy
    $ 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/health
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker compose logs migrate
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • logsshow a service’s output
    • migratethe service
    docker compose exec db psql -h 127.0.0.1 -U postgres -c '\dt'
    • execrun a command in a running service
    • dbthe service
    • psql -h 127.0.0.1 -U postgres -c '\dt'the command to run inside it

    The everyday commands:

    CommandDoes
    docker compose up -dcreate or update everything; only changed services are recreated
    docker compose up -d --buildrebuild images with a build: first
    docker compose ps -aservices and their state, including finished ones
    docker compose logs -f apifollow one service's logs
    docker compose exec db sha shell in a running service
    docker compose configthe final file after variables are filled in: check it before you blame Compose
    docker compose downremove containers and the network, keep volumes
    docker compose down -valso delete the named volumes: the database is gone
    Started is not ready

    The short form depends_on: [db] only waits for the db container to start. Postgres needs several seconds more before it accepts connections. Anything that must talk to the database at startup needs a healthcheck on db and condition: service_healthy, or its own retry loop.

    In production

    Compose is a good fit for one server: pin image tags, set restart: unless-stopped, memory limits (mem_limit or deploy.resources.limits) and health checks, keep secrets in files outside the repository, and back up the volumes (lesson 6). Across several servers, use an orchestrator (lesson 14).

    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 containers
    • runstart 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 JSON
    • workerthe container or image
    • --format 'restart={{.HostConfig.RestartPolicy.Name}} memory={{.HostConfig.Memory}} init={{.Config.Init}}'print only these fields (Go template)
    docker rm -f worker
    • rmdelete a container
    • -fstop it first if running
    • workerthe 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.

    ⌘
    Mini mission

    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.

    Input: Docker with the Compose plugin, the price-api folder from lesson 7, and nginx.confOutput: migrate Exited (0), curl returning status ok, and shop_pgdata surviving docker compose down
    Reveal 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 worked
    • echoprint text
    • >>append output to a file
    docker compose up -d
    • dockerbuild and run containers
    • composerun a multi-container app from compose.yaml
    • upcreate 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/health
    • curlmake 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 service
    • dbthe service
    • psql -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=shop
    • downstop and remove the containers and network
    • volumemanage volumes
    • lslist 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    Which restart policy brings a container back after a crash and after a reboot, but not after you stop it yourself?

    Go deeper

    Official reading for this lesson

    • DocsDocker Docs: start containers automaticallyDocker Docs ↗
    • DocsDocker Docs: resource constraintsDocker Docs ↗
    • DocsDocker Docs: Compose file referenceDocker Docs ↗
    • DocsDocker Docs: control startup order in ComposeDocker Docs ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousRegistries: Docker Hub, GHCR, ECR and tags you can trustNext lesson →Networking: bridge networks, published ports and container DNS