Aurora Libros works: four containers on a network of their own, the catalog safe in a named volume, the site served through a reverse proxy. But two risks remain, both of which appeared in earlier lessons and both of which, on a real server, end in a three-in-the-morning phone call.

You saw the first one in docker stats: the LIMIT column showed 7.628 GiB on every container, that is, all the machine's RAM. A runaway query in PostgreSQL or a memory leak in Node can leave the entire host out of memory and drag the other three services down with it. The second one is simpler: if a container dies at three in the morning, there it stays until somebody brings it back up.

This lesson closes both fronts. You are going to set memory, CPU, disk and process-count limits, you are going to trigger an OOM on purpose to see it with your own eyes, and you are going to configure restart policies with their subtle differences. And by the end, you will have in front of you the complete list of commands needed to bring the platform up from scratch... and you will understand perfectly why module 4 exists.

Contents

  1. Why a container with no limits is a problem
  2. Memory limits
  3. The OOM killer, triggered and diagnosed
  4. CPU limits
  5. Disk I/O limits
  6. --pids-limit: defense against a fork bomb
  7. Verifying and changing things on the fly
  8. Restart policies
  9. always versus unless-stopped
  10. Why a restart policy does not replace a healthcheck
  11. Final hands-on: the Aurora Libros limits table
  12. The complete script, and why it cannot go on like this

  1. Why a container with no limits is a problem

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT     CPU %   PIDS
aurora-web     3.44MiB / 7.628GiB    0.00%   3
aurora-api     52.17MiB / 7.628GiB   0.13%   11
aurora-cache   9.91MiB / 7.628GiB    0.18%   6
aurora-db      41.28MiB / 7.628GiB   0.02%   7

All four have the same limit: all of the host's memory. By default, a container can consume as much RAM and as much CPU as there is available. Remember from lesson 01-01 that a container is not a virtual machine with preassigned resources, but a host process isolated with namespaces; what distributes resources is the cgroups, and by default they are wide open.

The consequences, all of them real:

Scenario What happens with no limits
Memory leak in the API Node grows until it exhausts the RAM. The kernel starts killing processes, and not necessarily the guilty one
Heavy query in PostgreSQL An ORDER BY over a huge table eats several GB and evicts the other services from memory
Traffic spike One container hogs all the CPU and the rest stop responding
Compromised container A cryptocurrency miner uses 100% of every core
A bug with fork() Thousands of processes exhaust the host's process table, not just the container's

The idea that holds this whole section together: limits are not an optimization, they are a containment mechanism. They are there so that one service's failure does not become the whole machine's failure.

  1. Memory limits

Option What it does
-m, --memory Hard limit. On exceeding it, the kernel kills the process
--memory-swap The combined memory + swap limit
--memory-reservation Soft limit: under pressure, the kernel tries to push it down to this value
--memory-swappiness From 0 to 100: how inclined it is to use swap
--oom-kill-disable Do not kill the container when it runs out of memory

The units are b, k, m and g: -m 512m, -m 1g. The minimum accepted is 6 MB.

The relationship between --memory and --memory-swap

This is the part that confuses everybody, because --memory-swap is not the amount of swap: it is the total of memory plus swap.

--memory --memory-swap RAM available Swap available Total
512m (unspecified) 512 MB 512 MB (same as the RAM) 1 GB
512m 1g 512 MB 512 MB 1 GB
512m 512m 512 MB 0 (swap disabled) 512 MB
512m -1 512 MB Unlimited Unlimited
(unspecified) 1g Unlimited Error: --memory-swap requires --memory

The most important row is the third: --memory and --memory-swap with the same value disables swap. And that is usually the right configuration on servers, because a process that starts swapping to disk is not working, it is dying: response times shoot up until the service is useless, but since it is technically still alive, healthchecks may never notice. A clean, fast failure is preferable to slow, silent degradation.

docker run -d --name limit-demo -m 256m --memory-swap 256m alpine:3.20 sleep 300
docker inspect -f 'Memory: {{.HostConfig.Memory}} bytes | MemorySwap: {{.HostConfig.MemorySwap}} bytes' limit-demo
docker stats --no-stream limit-demo --format "{{.Name}}: {{.MemUsage}}"
docker rm -f limit-demo
Memory: 268435456 bytes | MemorySwap: 268435456 bytes
limit-demo: 1.02MiB / 256MiB

Now the LIMIT column says 256 MiB instead of the host's 7.628 GiB.

--memory-reservation: the soft limit

docker run -d --name soft-demo -m 512m --memory-reservation 256m redis:7-alpine
docker inspect -f '{{.HostConfig.MemoryReservation}}' soft-demo
docker rm -f soft-demo
268435456

The difference between the two:

--memory (hard) --memory-reservation (soft)
Can it be exceeded? No, never Yes, if there is free memory on the host
What happens when exceeded The container dies (OOM) The kernel pushes it back down
What it is for Absolute containment Prioritizing who gives up memory when it is scarce

The usual pattern is to use both: reserve the expected normal consumption and set the limit at the point where something is clearly wrong.

--oom-kill-disable

docker run -d --name no-oom -m 128m --oom-kill-disable alpine:3.20 sleep 300

With this option, when memory runs out the kernel does not kill the process: it freezes it indefinitely. It sounds better and it is almost always worse: you end up with an Up container that responds to nothing, with no new logs and no exit code to investigate. A dead process gets restarted; a frozen one has to be diagnosed by hand. Use it only if you know exactly why, and never without a memory limit (without -m, you would freeze the entire machine).

docker rm -f no-oom

  1. The OOM killer, triggered and diagnosed

When a container exceeds its memory limit, the Linux kernel's OOM killer (Out Of Memory killer) steps in. It is not Docker: it is the kernel, and it does not negotiate.

Let's trigger it. This container has 64 MB and is going to try to write 200 MB into shared memory, which counts against the same cgroup:

docker run -d --name oom-demo \
  -m 64m --memory-swap 64m --shm-size 512m \
  alpine:3.20 sh -c 'echo "starting to fill memory"; dd if=/dev/zero of=/dev/shm/filler bs=1M count=200; echo "I have finished"'

sleep 5
docker ps -a --filter name=oom-demo --format "table {{.Names}}\t{{.Status}}"
NAMES       STATUS
oom-demo    Exited (137) 2 seconds ago

Code 137. From lesson 03-02's table: 137 = 128 + 9 = SIGKILL. But that same 137 is also produced by a docker kill or by a docker stop that runs out of time, so one more piece of evidence is needed:

docker inspect -f 'OOMKilled: {{.State.OOMKilled}} | ExitCode: {{.State.ExitCode}} | Error: {{.State.Error}}' oom-demo
docker logs oom-demo
OOMKilled: true | ExitCode: 137 | Error:
starting to fill memory

OOMKilled: true is the unambiguous confirmation. And look at the logs: you can see the "starting to fill memory" but never the "I have finished". The process was eliminated mid-task, with no chance to write anything else, no catchable exception and no graceful shutdown. SIGKILL cannot be intercepted.

The event is also recorded in the daemon:

docker events --since 5m --filter container=oom-demo --filter event=oom
2026-08-04T23:14:07.442 container oom 8b2f1c9e... (image=alpine:3.20, name=oom-demo)

And on the host, the kernel leaves its own record:

sudo dmesg | grep -i "killed process" | tail -2
[189234.117] Memory cgroup out of memory: Killed process 41207 (dd) total-vm:213504kB, anon-rss:65112kB

How to diagnose a suspicious 137, in order:

Check If it is affirmative
docker inspect -f '{{.State.OOMKilled}}'true It was the OOM killer: raise the limit or fix the leak
docker events --filter event=oom shows the event Confirmation from the daemon, with the exact time
Somebody ran docker kill or docker stop It was a human action or a script
The process ignored SIGTERM for 10 s It is the shell-form case from lesson 03-02
docker rm oom-demo

  1. CPU limits

Three options that do different things and get confused constantly:

Option What it is Type When to use it
--cpus How many cores it can use at most (decimals allowed) Hard limit The default choice: predictable and easy to reason about
--cpu-shares Relative weight against other containers (1024 by default) Priority Sharing CPU under contention without wasting it when there is spare
--cpuset-cpus Which specific cores it can use Pinning Isolating latency-sensitive workloads or respecting NUMA
docker run -d --name cpu-limited --cpus 0.5 alpine:3.20 sh -c 'while true; do :; done'
sleep 3
docker stats --no-stream cpu-limited --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-limited
cpu-limited: 49.87%

An infinite loop that would devour an entire core stays at 50% of one, which is exactly what --cpus 0.5 asked for. Bear in mind that in docker stats 100% equals one core: a container with --cpus 2 can reach 200%.

--cpu-shares is not a limit

docker run -d --name weight-high --cpu-shares 1024 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name weight-low  --cpu-shares 256  alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream weight-high weight-low --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f weight-high weight-low
weight-high: 318.42%
weight-low: 79.61%

The ratio is roughly 4:1, exactly the relationship between 1024 and 256. But the key is elsewhere: if weight-low were alone on the machine, it would use all the available CPU. Shares only come into play when there is competition; they do not waste idle capacity.

--cpus 1 --cpu-shares 1024
With the machine free Uses at most 1 core Uses all there are
With the machine saturated Uses 1 core Uses its proportional share
Predictable Yes No, it depends on the neighbors
Typical use Production, billing by resources Relative priorities within a host

--cpuset-cpus

docker run -d --name pinned --cpuset-cpus "0,1" alpine:3.20 sh -c 'while true; do :; done'
docker inspect -f '{{.HostConfig.CpusetCpus}}' pinned
docker rm -f pinned
0,1

The container will only run on cores 0 and 1, whatever the load on the rest. It is rarely used, but it is valuable for latency-sensitive workloads (it stops the scheduler moving the process between cores and losing the caches) and for reserving cores for critical system processes.

  1. Disk I/O limits

Less common, but essential when a container saturates the disk and drags the others down:

Option What it limits
--blkio-weight Relative weight for I/O, from 10 to 1000 (500 by default). Like --cpu-shares, but for disk
--device-read-bps Bytes per second read from a device
--device-write-bps Bytes per second written
--device-read-iops / --device-write-iops Operations per second
docker run --rm --device-write-bps /dev/sda:1mb alpine:3.20 \
  dd if=/dev/zero of=/tmp/test bs=1M count=10 oflag=direct
10+0 records in
10+0 records out
10485760 bytes (10.0MB) copied, 10.031459 seconds, 1.0MB/s

Exactly 1 MB/s, ten seconds for ten megabytes. Two warnings: you have to give the host's real device (/dev/sda, /dev/nvme0n1; find it with lsblk), and these options only work with the right storage driver and file system —in many setups with overlay2 over ext4 they require oflag=direct to be noticeable, as in the example.

  1. --pids-limit: defense against a fork bomb

It limits the number of processes and threads a container can create. It is the cheapest and one of the most effective protections:

docker run --rm --pids-limit 20 alpine:3.20 sh -c \
  'for i in $(seq 1 50); do sleep 30 & done; echo "launched as many as possible"'
sh: can't fork: Resource temporarily unavailable
sh: can't fork: Resource temporarily unavailable
...
launched as many as possible

From process number 20 onwards, the kernel denies the fork(). Without this limit, a fork bomb —a bug or an attack that creates processes non-stop— exhausts the host's process table, and from that point on you cannot even launch a ps to diagnose it: you have to reboot the machine.

docker run -d --name pids-demo --pids-limit 100 nginx:alpine
docker inspect -f '{{.HostConfig.PidsLimit}}' pids-demo
docker stats --no-stream pids-demo --format "{{.Name}}: {{.PIDs}} processes"
docker rm -f pids-demo
100
pids-demo: 3 processes

Reasonable values: 100 for a simple web service, 200–500 for a database, and always measuring the normal consumption with docker stats beforehand, with a generous margin.

  1. Verifying and changing things on the fly

docker inspect -f 'Memory: {{.HostConfig.Memory}} | Swap: {{.HostConfig.MemorySwap}} | NanoCPUs: {{.HostConfig.NanoCpus}} | PIDs: {{.HostConfig.PidsLimit}} | Policy: {{.HostConfig.RestartPolicy.Name}}' aurora-api
Memory: 0 | Swap: 0 | NanoCPUs: 0 | PIDs: 0 | Policy: no

A 0 means "no limit", and NanoCPUs expresses --cpus in billionths: --cpus 1.5 is stored as 1500000000.

The great advantage of these options, compared with ports, networks and mounts, is that they can be changed without recreating the container, because they live in the cgroups and not in the namespaces:

docker update --memory 512m --memory-swap 512m --cpus 1.0 --pids-limit 200 aurora-db
docker inspect -f 'Memory: {{.HostConfig.Memory}} | NanoCPUs: {{.HostConfig.NanoCpus}}' aurora-db
docker stats --no-stream aurora-db --format "{{.Name}}: {{.MemUsage}}"
aurora-db
Memory: 536870912 | NanoCPUs: 1000000000
aurora-db: 41.83MiB / 512MiB

Without stopping PostgreSQL, without recreating the container and without losing a single connection. The LIMIT column now says 512 MiB. It is the exception to the "everything is frozen at creation" rule, and that is why docker update exists.

Two limitations of docker update: it cannot lower the memory below what is already in use (it would fail), and some options such as --memory-swap require you to give --memory in the same call.

  1. Restart policies

A container that dies does not come back on its own. The --restart option changes that:

Policy Restarts if the process exits with an error Restarts if it exits with 0 Restarts when the daemon starts After a manual docker stop
no (default) No No No
on-failure Yes, indefinitely No Yes, if it had failed No
on-failure:N Yes, up to N times No Yes No
always Yes Yes Yes, always Yes, when the daemon restarts
unless-stopped Yes Yes Yes, unless you stopped it No
docker run -d --name restart-demo --restart on-failure:3 \
  alpine:3.20 sh -c 'echo "attempt"; sleep 2; exit 1'
sleep 15
docker ps -a --filter name=restart-demo --format "table {{.Names}}\t{{.Status}}"
docker inspect -f 'Restarts: {{.RestartCount}} | State: {{.State.Status}} | Exit code: {{.State.ExitCode}}' restart-demo
docker logs restart-demo
NAMES           STATUS
restart-demo    Exited (1) 3 seconds ago
Restarts: 3 | State: exited | Exit code: 1
attempt
attempt
attempt
attempt

Four "attempt" lines in the logs: the original start plus three restarts, and then Docker gives up. RestartCount is the counter that confirms it and it is one of the first fields to look at when a service "comes and goes".

Exponential backoff

Docker does not restart in a tight loop: it waits longer and longer between attempts.

Attempt Approximate wait
1st 100 ms
2nd 200 ms
3rd 400 ms
4th 800 ms
Doubling up to a maximum of 1 minute

The counter is reset if the container manages to stay up for at least 10 seconds. That detail explains a baffling behavior: a service that starts, works for 30 seconds and dies can, with --restart always, stay restarting forever every few seconds without the backoff ever growing. You detect it like this:

docker ps --filter status=restarting
docker inspect -f '{{.RestartCount}}' <container>

A RestartCount of 847 is a service that has been failing for hours with nobody noticing.

  1. always versus unless-stopped

The two look identical and differ in a single scenario, which happens to be the most important one: when the Docker daemon or the machine restarts.

flowchart TD
    A["docker stop aurora-api<br/>(deliberate manual stop)"] --> B["Container exited"]
    B --> C["The host or the docker<br/>service is restarted"]
    C --> D{"Which policy<br/>did it have?"}
    D -- "always" --> E["Docker STARTS it<br/>Your manual stop is ignored"]
    D -- "unless-stopped" --> F["Docker LEAVES it stopped<br/>Your decision is respected"]
Situation always unless-stopped
The process fails Restarts Restarts
The process exits with code 0 Restarts Restarts
Manual docker stop Does not restart (at that moment) Does not restart
Daemon restart after a manual docker stop Starts it Leaves it stopped
Daemon restart while it is running Starts it Starts it

Why it matters: imagine you stop aurora-api to investigate a problem and go for lunch. With always, a daemon restart —an automatic update, a server reboot— brings it back up in the broken state you were investigating. With unless-stopped, your decision is respected.

General recommendation: unless-stopped for long-running services and on-failure:N for processes that are meant to finish. always only if you genuinely want nothing to be able to leave the container stopped.

And two operational nuances:

  • The policy can be changed on the fly: docker update --restart unless-stopped aurora-db.
  • With --rm, --restart is incompatible: Docker rejects it, because it cannot both delete and restart the container.
docker rm -f restart-demo

  1. Why a restart policy does not replace a healthcheck

This is the nuance that separates a configuration that looks robust from one that is.

docker run -d --name hung --restart unless-stopped nginx:alpine
docker exec hung nginx -s stop
sleep 5
docker ps --filter name=hung --format "{{.Names}}: {{.Status}}"
docker rm -f hung
hung: Up 12 seconds

Nginx is stopped inside the container and Docker says Up, perfectly happy, because PID 1 is still alive (the Nginx master has not fully terminated yet). Generalizing the problem:

Type of failure Does --restart detect it? Does HEALTHCHECK detect it?
The process dies Yes Yes
The process exits with an error Yes Yes
The process is alive but blocked No Yes
Connection pool exhausted No Yes
It returns 500 to every request No Yes
It has lost the database No Yes

And now the detail that surprises people: Docker Engine does not restart a container for being unhealthy. The restart policy reacts to the process dying, not to the healthcheck's verdict. You can have a container reading Up 3 days (unhealthy) with --restart always for three days without anyone doing anything.

So who acts on health?

Environment What it does with unhealthy
Docker Engine alone Marks the state and emits a health_status event. Nothing else
Docker Swarm Replaces the task with a new one (lesson 06-03)
Kubernetes The liveness probe restarts the container; the readiness one takes it out of load balancing (lesson 06-05)
You, with a script Whatever you write, watching docker events --filter event=health_status

The conclusion, worth being clear about as you leave this module: the restart policy and the healthcheck solve different problems and complement each other. The first brings back a dead process; the second detects a process that is alive but useless. And for that diagnosis to translate into action you need an orchestrator, which is where module 6 leads.

  1. Final hands-on: the Aurora Libros limits table

It is time to decide concrete numbers, and to decide them with judgment. Start from what they really consume:

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT     CPU %   PIDS
aurora-web     3.44MiB / 7.628GiB    0.00%   3
aurora-api     52.17MiB / 7.628GiB   0.13%   11
aurora-cache   9.91MiB / 7.628GiB    0.18%   6
aurora-db      41.83MiB / 512MiB     0.02%   7

The table, with its justification:

Service Memory Reservation CPU PIDs Policy Why
aurora-db 512m 256m 1.0 200 unless-stopped It uses 42 MB at rest, but PostgreSQL needs room for work_mem, caching and connections. It is the stateful service: it must always come back, and if you stop it, it must stay stopped
aurora-cache 256m 0.5 100 unless-stopped With Redis's --maxmemory 200mb aligned below the container's limit. It needs no CPU: it is nearly all network I/O
aurora-api 256m 128m 1.0 100 on-failure:3 Node with a 52 MB baseline; 256 MB gives the garbage collector room. on-failure:3 because if it fails three times in a row, the problem is configuration and restarting in a loop only hides it
aurora-web 128m 0.5 50 unless-stopped Nginx serving static files and acting as a proxy: 3.4 MB in reality. 128 MB is extremely generous

Two decisions deserve a separate explanation:

Why aurora-cache carries --maxmemory in addition to the container's limit. If you only set -m 256m, Redis would know nothing about that limit: it would keep accepting keys until the OOM killer struck it down, losing the whole cache. With --maxmemory 200mb --maxmemory-policy allkeys-lru, Redis handles the problem itself: on reaching 200 MB it starts evicting the least recently used keys and carries on working. The container's limit becomes the safety net, not the usual mechanism. When a service has its own memory control, configure it below the container's limit. The same applies to --max-old-space-size in Node and to shared_buffers in PostgreSQL.

Why aurora-api carries on-failure:3 and not unless-stopped. It is a stateless, replaceable service, but if it fails three times in a row it is almost never something transient: it is a badly set environment variable, a pending migration or a dependency failure. Restarting infinitely turns a visible error into a silent loop that fills the logs and hides the diagnosis.

Apply the table. aurora-db is already updated, so the policy is all that is needed:

docker update --restart unless-stopped --memory-reservation 256m aurora-db

The other three have to be recreated, because aurora-cache also changes its command and aurora-api and aurora-web had no limits:

docker rm -f aurora-cache aurora-api aurora-web

docker run -d --name aurora-cache --network aurora-net \
  --label project=aurora-libros --label component=cache \
  --memory 256m --memory-swap 256m --cpus 0.5 --pids-limit 100 \
  --restart unless-stopped \
  redis:7-alpine redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru

docker run -d --name aurora-api --network aurora-net \
  --label project=aurora-libros --label component=api \
  --env-file ~/aurora-libros/aurora.env \
  -p 3000:3000 \
  --memory 256m --memory-swap 256m --memory-reservation 128m \
  --cpus 1.0 --pids-limit 100 \
  --restart on-failure:3 \
  auroralibros/aurora-api:1.2.0

docker run -d --name aurora-web --network aurora-net \
  --label project=aurora-libros --label component=web \
  -p 8080:80 \
  --mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
  --mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
  --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 50 \
  --restart unless-stopped --stop-signal SIGQUIT \
  nginx:alpine

Verify:

sleep 8
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}"
NAME           MEM USAGE / LIMIT    MEM %   PIDS
aurora-web     3.51MiB / 128MiB     2.74%   3
aurora-api     53.02MiB / 256MiB    20.71%  11
aurora-cache   10.14MiB / 256MiB    3.96%   6
aurora-db      42.11MiB / 512MiB    8.22%   7

Four real limits instead of four times 7.628 GiB. And the percentages are healthy: between 3% and 21%, with plenty of headroom for spikes.

docker inspect -f '{{.Name}}: mem={{.HostConfig.Memory}} cpus={{.HostConfig.NanoCpus}} pol={{.HostConfig.RestartPolicy.Name}}{{if .HostConfig.RestartPolicy.MaximumRetryCount}}:{{.HostConfig.RestartPolicy.MaximumRetryCount}}{{end}}' \
  aurora-db aurora-cache aurora-api aurora-web
curl -s http://localhost:8080/api/books | jq -r '.books | length'
/aurora-db: mem=536870912 cpus=1000000000 pol=unless-stopped
/aurora-cache: mem=268435456 cpus=500000000 pol=unless-stopped
/aurora-api: mem=268435456 cpus=1000000000 pol=on-failure:3
/aurora-web: mem=134217728 cpus=500000000 pol=unless-stopped
9

All four with their limits, their policies, and the nine books were still there after destroying and recreating three containers. The aurora-data volume did its job.

A final resilience test: kill the API brutally and see what happens.

docker kill aurora-api
sleep 3
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
docker inspect -f 'Restarts: {{.RestartCount}}' aurora-api
curl -s http://localhost:8080/api/books | jq -r '.books | length'
aurora-api: Up 2 seconds
Restarts: 1
9

It came back on its own in under three seconds and the site started working again without anyone touching a thing. That is exactly the difference between a pile of containers and a platform.

  1. The complete script, and why it cannot go on like this

Let's recap. This is everything you have to write, in the right order, to bring Aurora Libros up on a clean machine:

#!/usr/bin/env bash
# start-aurora.sh — Aurora Libros S.L., the complete platform
set -euo pipefail

# ---------- 1. Network ----------
docker network create --label project=aurora-libros aurora-net

# ---------- 2. Volume ----------
docker volume create --label project=aurora-libros aurora-data

# ---------- 3. Database ----------
docker run -d --name aurora-db --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secret \
  -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-data,dst=/var/lib/postgresql/data \
  --mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
  --memory 512m --memory-swap 512m --memory-reservation 256m \
  --cpus 1.0 --pids-limit 200 \
  --restart unless-stopped --stop-timeout 30 \
  postgres:16-alpine

# ---------- 4. Wait for the database to accept connections ----------
until docker exec aurora-db pg_isready -U aurora -q; do sleep 1; done

# ---------- 5. Cache ----------
docker run -d --name aurora-cache --network aurora-net \
  --label project=aurora-libros --label component=cache \
  --memory 256m --memory-swap 256m --cpus 0.5 --pids-limit 100 \
  --restart unless-stopped \
  redis:7-alpine redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru

# ---------- 6. API ----------
docker run -d --name aurora-api --network aurora-net \
  --label project=aurora-libros --label component=api \
  --env-file "$HOME/aurora-libros/aurora.env" \
  -p 3000:3000 \
  --memory 256m --memory-swap 256m --memory-reservation 128m \
  --cpus 1.0 --pids-limit 100 \
  --restart on-failure:3 \
  auroralibros/aurora-api:1.2.0

# ---------- 7. Web and reverse proxy ----------
docker run -d --name aurora-web --network aurora-net \
  --label project=aurora-libros --label component=web \
  -p 8080:80 \
  --mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
  --mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
  --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 50 \
  --restart unless-stopped --stop-signal SIGQUIT \
  nginx:alpine

echo "Aurora Libros is up: http://localhost:8080"

Look at that script with a critical eye. It works, it is correct and it contains everything you have learned in seven lessons. And it is a problem:

Flaw Why it hurts
Fifty lines for four services And this is a small application. With ten services it is one hundred and thirty
The order is your responsibility You wrote that until pg_isready by hand. Skip it and the API starts before the database
There is no way to "stop everything" You need another script, with the names repeated and the order reversed
Constant repetition --network aurora-net and the labels appear four times. Changing the network's name means four edits
Fragile in the face of change Adding a variable to the API forces you to delete the container and paste its exact fourteen lines again, without getting the volume wrong
It is not declarative It describes how to reach the desired state, not what that state is. If somebody changed something by hand, the script does not detect it
Hard to share and review How do you review this in a pull request? How do you know what changed since last week?
Nothing guarantees it ran to the end If step 5 fails, you are left with half a platform running

And now picture the real scene: someone new joins the Aurora Libros team. You send them this file over chat, and you hope their $HOME has the same structure, that image 1.2.0 is the right one, that nobody changed a value without saying so. It is the modern version of those fifteen manual onboarding steps from lesson 01-07: you have reduced the problem enormously, but you have not eliminated it. You have turned it into a bash script nobody wants to maintain.

There is an answer to exactly this, and it is the module that comes next.

Common Mistakes and Tips

  • Setting no limits at all "because the machine has plenty of RAM". It does, until one container eats it all and the kernel starts killing processes at random, system ones included.
  • Confusing --memory-swap with "the amount of swap". It is the total of memory plus swap. -m 512m --memory-swap 512m is what disables swap.
  • Reading every 137 as a docker kill. Check .State.OOMKilled before drawing conclusions.
  • Using --cpu-shares expecting a hard limit. It only acts under contention. For a real ceiling, --cpus.
  • Setting a memory limit without adjusting the service's own. Redis with no --maxmemory, Node with no --max-old-space-size or the JVM with no -Xmx will crash into the container's limit and die outright instead of managing it.
  • Trusting --restart always as if it were high availability. It does not detect a process that is alive but hung, and it does not fix a configuration error: it just repeats it faster.
  • Expecting Docker to restart an unhealthy container. Docker Engine only marks it. Acting is an orchestrator's job.
  • Using always instead of unless-stopped. With always, a daemon restart brings back up containers you had deliberately stopped.
  • Tip: measure before you limit. Leave the service running under real load, look at docker stats, and set the limit with a margin of two or three times the observed peak.
  • Tip: docker update is your friend for adjusting limits and policies in production without recreating anything or dropping connections.

Exercises

Exercise 1: trigger and diagnose an OOM

Create a glutton container with 100 MB of memory and no swap that tries to occupy 300 MB in /dev/shm. Then:

  1. Check the state, the exit code and the value of OOMKilled.
  2. Locate the corresponding event with docker events.
  3. Repeat the experiment with a 512 MB limit and check that it now finishes properly.
  4. Repeat the first case but with --memory-swap 1g instead of no swap. Explain what changes and why that is rarely a good solution.

Exercise 2: compare --cpus with --cpu-shares

Launch two containers that consume CPU with an infinite loop and measure how it is shared with docker stats in three scenarios:

  1. container-a with --cpus 0.5 and container-b with no limit at all.
  2. Both with --cpu-shares, one with 1024 and the other with 512.
  3. Only the --cpu-shares 512 one, running alone.

Explain the three results and answer: which of the two options would you use to guarantee a client that their container will never exceed half a core? And to share a machine between a critical service and a testing one while taking advantage of all the available CPU?

Exercise 3: put the restart policies to the test

Design an experiment that demonstrates the difference between always and unless-stopped without rebooting your machine (hint: sudo systemctl restart docker restarts the daemon; on Docker Desktop, use "Restart" from the menu). Create two identical containers, one with each policy, stop both manually and then restart the daemon. Note which containers are running when you come back. Finally:

  1. Create a third container with --restart on-failure:2 that always fails and check RestartCount when it gives up.
  2. With the Aurora Libros platform, make aurora-api go unhealthy (stop aurora-db) and prove with commands that the restart policy does nothing about it. Then fix the situation.

Solutions

Solution to exercise 1

docker run -d --name glutton -m 100m --memory-swap 100m --shm-size 512m alpine:3.20 \
  sh -c 'dd if=/dev/zero of=/dev/shm/filler bs=1M count=300; echo FINISHED'
sleep 5
docker inspect -f 'State: {{.State.Status}} | Exit code: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' glutton
docker logs glutton
State: exited | Exit code: 137 | OOMKilled: true

The logs are empty: it did not even get to print "FINISHED". SIGKILL gives no room for anything.

docker events --since 10m --filter container=glutton
2026-08-04T23:41:02.118 container create 6d2a...  (name=glutton)
2026-08-04T23:41:02.309 container start  6d2a...  (name=glutton)
2026-08-04T23:41:04.771 container oom    6d2a...  (name=glutton)
2026-08-04T23:41:04.883 container die    6d2a...  (exitCode=137, name=glutton)

The complete sequence in four lines: creation, start, oom and death with 137. That oom event is the proof from the daemon's side.

3. With enough headroom:

docker rm glutton
docker run --name glutton -m 512m --memory-swap 512m --shm-size 512m alpine:3.20 \
  sh -c 'dd if=/dev/zero of=/dev/shm/filler bs=1M count=300; echo FINISHED'
docker inspect -f 'Exit code: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' glutton
300+0 records in
300+0 records out
FINISHED
Exit code: 0 | OOMKilled: false

4. With swap:

docker rm glutton
docker run --name glutton -m 100m --memory-swap 1g --shm-size 512m alpine:3.20 \
  sh -c 'time dd if=/dev/zero of=/dev/shm/filler bs=1M count=300; echo FINISHED'
docker inspect -f 'Exit code: {{.State.ExitCode}} | OOMKilled: {{.State.OOMKilled}}' glutton
docker rm glutton
300+0 records in
300+0 records out
real    0m 11.84s
FINISHED
Exit code: 0 | OOMKilled: false

Now it survives, because it has 100 MB of RAM plus 924 MB of swap. But look at the time: 11.84 seconds versus less than one with real memory. That is why swap is rarely the answer: the container does not die, but it runs between ten and a hundred times slower. In a service with users waiting, that is indistinguishable from an outage, with the added problem that no alarm goes off: there is no OOM, no restart, no code 137. Just an unbearably slow service nobody can explain. A fast, noisy failure is preferable.

Solution to exercise 2

docker run -d --name cpu-a --cpus 0.5 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name cpu-b alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream cpu-a cpu-b --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-a cpu-b
cpu-a: 49.94%
cpu-b: 99.87%

1. cpu-a respects its half core even though the machine has capacity to spare; cpu-b, with no limit, takes up a whole core. The hard limit does not give way even when there is spare CPU.

docker run -d --name cpu-high --cpu-shares 1024 alpine:3.20 sh -c 'while true; do :; done'
docker run -d --name cpu-low  --cpu-shares 512  alpine:3.20 sh -c 'while true; do :; done'
sleep 5
docker stats --no-stream cpu-high cpu-low --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-high
sleep 5
docker stats --no-stream cpu-low --format "{{.Name}}: {{.CPUPerc}}"
docker rm -f cpu-low
cpu-high: 265.31%
cpu-low: 132.44%
cpu-low: 398.72%

2. The ratio is 2:1, exactly 1024 against 512. 3. And as soon as cpu-high disappears, cpu-low jumps to 398%: almost four cores. Shares never waste idle capacity.

The two answers:

  • To guarantee a client that it will never exceed half a core: --cpus 0.5. It is an absolute ceiling, verifiable and independent of what everyone else does. It is what gets billed and what you can promise in a contract.
  • To share a machine between a critical service and a testing one: --cpu-shares, for example 1024 and 256. When both compete, the critical one gets four times as much; when the critical one is idle, the testing one uses the whole machine instead of leaving it standing still. It is a distribution of priorities, not a ceiling.

Solution to exercise 3

docker run -d --name pol-always         --restart always         alpine:3.20 sleep 3600
docker run -d --name pol-unless-stopped --restart unless-stopped alpine:3.20 sleep 3600

docker stop pol-always pol-unless-stopped
docker ps -a --filter name=pol- --format "{{.Names}}: {{.Status}}"
pol-always: Exited (137) 2 seconds ago
pol-unless-stopped: Exited (137) 2 seconds ago

Both stopped by your decision. Now restart the daemon:

sudo systemctl restart docker
sleep 10
docker ps -a --filter name=pol- --format "table {{.Names}}\t{{.Status}}"
NAMES                 STATUS
pol-always            Up 8 seconds
pol-unless-stopped    Exited (137) 45 seconds ago

The difference, in two lines. always ignored your manual stop and started the container again; unless-stopped respected your decision and left it stopped. It is exactly the scenario from section 9: if you had stopped aurora-api to investigate an incident, with always you would have found it running again when you got back.

docker rm -f pol-always pol-unless-stopped

1. The retry counter:

docker run -d --name pol-fail --restart on-failure:2 alpine:3.20 sh -c 'sleep 1; exit 7'
sleep 12
docker inspect -f 'State: {{.State.Status}} | Exit code: {{.State.ExitCode}} | RestartCount: {{.RestartCount}}' pol-fail
docker rm -f pol-fail
State: exited | Exit code: 7 | RestartCount: 2

RestartCount: 2: the original start plus two retries, and Docker gives up while preserving the real failure code (7), not a generic one. That code is what lets you diagnose.

2. The policy does not react to health:

docker stop aurora-db
sleep 45
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
docker inspect -f 'Health: {{.State.Health.Status}} | Restarts: {{.RestartCount}} | Policy: {{.HostConfig.RestartPolicy.Name}}' aurora-api
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8080/api/books
aurora-api: Up 12 minutes (unhealthy)
Health: unhealthy | Restarts: 1 | Policy: on-failure:3
HTTP 500

The demonstration is emphatic: the container has been unhealthy for twelve minutes, the API returns 500 to users, and RestartCount is still 1 —the restart from the docker kill in the hands-on section, not a new one. The restart policy has not lifted a finger, because the node process never died. It only lost its database.

docker start aurora-db
sleep 40
docker ps --filter name=aurora-api --format "{{.Names}}: {{.Status}}"
curl -s http://localhost:8080/api/books | jq -r '.books | length'
aurora-api: Up 25 minutes (healthy)
9

And as soon as aurora-db comes back, the healthcheck recovers on its own: /health returns 200 again, the state turns healthy and the nine books come back. Without restarting the API, because it was never needed. Note two things: the API survived its database going down thanks to the change you made in lesson 03-02, and the correct diagnosis —a sustained unhealthy with a frozen RestartCount— is the unmistakable signature of "the process is fine, its dependencies are not".

Conclusion

The module is closed and the platform is, at last, something you can defend. You know that by default a container can consume all the host's RAM and all its CPU, and you know how to fence it in: -m as the hard limit, --memory-reservation as the soft limit that decides who gives up memory under pressure, and --memory-swap with the trap that confuses everybody —it is the total, and with the same value as --memory swap is disabled, which is almost always the right thing. You have triggered an OOM on purpose and diagnosed it with the signature that leaves no doubt: OOMKilled: true, code 137 and an oom event in the daemon, with the logs cut off mid-sentence because SIGKILL cannot be caught.

You distinguish the three ways of sharing CPU: --cpus as an absolute, predictable ceiling, --cpu-shares as a relative weight that only acts under contention and never wastes idle capacity, and --cpuset-cpus for pinning specific cores. You know how to throttle disk I/O with --device-write-bps and, above all, you know that --pids-limit is the cheapest defense there is against a fork bomb that, without it, takes down the entire host's process table. And you know how to verify it all with docker stats and inspect, and change it on the fly with docker update without recreating a single container, because resources live in the cgroups and not in the namespaces.

You know the four restart policies and the difference that only shows up when the daemon restarts: always ignores your manual stop and unless-stopped respects it. You know how to read RestartCount, recognize the exponential backoff and spot a container that has racked up 847 restarts with nobody noticing. And you take away the distinction that prevents the most confusion: a restart policy brings back a dead process, a healthcheck detects a process that is alive but useless, and Docker Engine restarts nothing for being unhealthy; for that you need an orchestrator. Aurora Libros has it all applied with judgment: aurora-db with 512 MB and unless-stopped, aurora-cache with 256 MB and a Redis --maxmemory 200mb aligned below so it evicts keys instead of dying, aurora-api with 256 MB and on-failure:3 so a configuration error does not hide in an infinite loop, and aurora-web with 128 MB to spare. One docker kill aurora-api and the service came back on its own in under three seconds.

And yet, look again at that script in section 12. Fifty lines of bash for four containers: one network, one volume, four docker run commands of fourteen lines each, a wait loop written by hand so the API does not start before PostgreSQL, --network aurora-net repeated four times, no command to stop the whole platform and no way of knowing whether what is running matches what the file says. Adding an environment variable to the API means deleting the container and pasting its exact fourteen lines again without getting the volume wrong. It is correct, it is fragile, and it is impossible to review in a pull request. You have come an enormous way from the fifteen manual steps of lesson 01-07, but the last stretch is still a script nobody wants to maintain.

That is exactly the problem solved by module 4, Docker Compose. Those fifty lines of imperative commands become a single compose.yaml file of about forty declarative lines, versioned in Git alongside the code, describing what the desired state of the platform is —four services with their images, variables, networks, volumes, limits, dependencies and policies— instead of the steps for getting there. The network and the volume create themselves; the startup order is declared with depends_on and its health condition instead of an until loop; and the whole platform comes up with docker compose up -d and goes down with docker compose down. One command, one file, and a new colleague who clones the repository and has Aurora Libros running on their machine in under a minute. The fifteen onboarding steps will become one.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved