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
- Why a container with no limits is a problem
- Memory limits
- The OOM killer, triggered and diagnosed
- CPU limits
- Disk I/O limits
--pids-limit: defense against a fork bomb- Verifying and changing things on the fly
- Restart policies
alwaysversusunless-stopped- Why a restart policy does not replace a healthcheck
- Final hands-on: the Aurora Libros limits table
- The complete script, and why it cannot go on like this
- Why a container with no limits is a problem
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% 7All 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.
- 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-demoNow 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-demoThe 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
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).
- 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}}"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-demoOOMKilled: 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:
And on the host, the kernel leaves its own record:
[189234.117] Memory cgroup out of memory: Killed process 41207 (dd) total-vm:213504kB, anon-rss:65112kBHow 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 |
- 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-limitedAn 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-lowThe 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 pinnedThe 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.
- 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=directExactly 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.
--pids-limit: defense against a fork bomb
--pids-limit: defense against a fork bombIt 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 possibleFrom 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-demoReasonable 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.
- 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-apiA 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}}"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.
- 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-demoNAMES STATUS
restart-demo Exited (1) 3 seconds ago
Restarts: 3 | State: exited | Exit code: 1
attempt
attempt
attempt
attemptFour "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:
A RestartCount of 847 is a service that has been failing for hours with nobody noticing.
always versus unless-stopped
always versus unless-stoppedThe 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-stoppedfor long-running services andon-failure:Nfor processes that are meant to finish.alwaysonly 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,--restartis incompatible: Docker rejects it, because it cannot both delete and restart the container.
- 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 hungNginx 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.
- 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:
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% 7The 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:
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:alpineVerify:
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% 7Four 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
9All 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'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.
- 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-swapwith "the amount of swap". It is the total of memory plus swap.-m 512m --memory-swap 512mis what disables swap. - Reading every 137 as a
docker kill. Check.State.OOMKilledbefore drawing conclusions. - Using
--cpu-sharesexpecting 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-sizeor the JVM with no-Xmxwill crash into the container's limit and die outright instead of managing it. - Trusting
--restart alwaysas 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
unhealthycontainer. Docker Engine only marks it. Acting is an orchestrator's job. - Using
alwaysinstead ofunless-stopped. Withalways, 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 updateis 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:
- Check the state, the exit code and the value of
OOMKilled. - Locate the corresponding event with
docker events. - Repeat the experiment with a 512 MB limit and check that it now finishes properly.
- Repeat the first case but with
--memory-swap 1ginstead 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:
container-awith--cpus 0.5andcontainer-bwith no limit at all.- Both with
--cpu-shares, one with 1024 and the other with 512. - Only the
--cpu-shares 512one, 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:
- Create a third container with
--restart on-failure:2that always fails and checkRestartCountwhen it gives up. - With the Aurora Libros platform, make
aurora-apigounhealthy(stopaurora-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 gluttonThe logs are empty: it did not even get to print "FINISHED". SIGKILL gives no room for anything.
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}}' glutton4. 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 gluttonNow 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-b1. 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-low2. 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}}"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}}"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.
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-failRestartCount: 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/booksaurora-api: Up 12 minutes (unhealthy)
Health: unhealthy | Restarts: 1 | Policy: on-failure:3
HTTP 500The 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'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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
