Docker & ContainersXXXVIII Β· CapstoneCapstone
Capstone stage 1 β the host under the environment
What you'll learn
- Size and lay out storage for the capstone workload from the capacity model
- Configure the daemon and the host for the stack that will run on it
- Pass a stage gate made of commands rather than of opinions
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The capstone overview showed the finished environment: Caddy at the
edge, web, api and worker on app-net, Postgres and Redis on
db-net, and the observability stack on obs-net. That lesson stated
what the environment is and how it is accepted.
These five stages build it. Each one ends in a gate β a short list of commands with expected output β and you do not proceed to the next stage until the gate passes. That discipline is the point: the reason production environments end up undocumented is that they are built in one long session and the intermediate states are never recorded.
Stage 1 is the host. Nothing in the stack is more reliable than the machine underneath it, and two of the four capstone failure modes we will drill in stage 5 are host-level.
Sizing, from the capacity model
The stack has eleven containers. Apply the memory model to each and sum the result rather than picking a host size and hoping:
| Service | Working set | Limit | Reasoning |
|---|---|---|---|
caddy | ~40 MB | 128M | Small Go binary, cert cache |
web | ~120 MB | 256M | Static assets from page cache |
api | ~300 MB | 512M | Connection pool and request buffers |
worker x2 | ~180 MB each | 256M each | Job payloads |
db (Postgres) | ~900 MB | 2G | shared_buffers plus per-connection |
cache (Redis) | ~200 MB | 512M | maxmemory set below the limit |
prometheus | ~700 MB | 1G | Head block and WAL |
grafana | ~180 MB | 512M | |
loki | ~300 MB | 512M | |
tempo | ~250 MB | 512M |
sum of limits = 128 + 256 + 512 + (2 x 256) + 2048 + 512
+ 1024 + 512 + 512 + 512 = 6784 MB
host reserve = 1 GiB + 1% of RAM
required RAM (at 70%) = 6784 / 0.70 = 9691 MB
-> 12 GB minimum,
16 GB comfortable
CPU, using the same approach: the quota sum should land near the core count on a single-host capstone, because there is no second host for the load to fail over to.
sum of quotas = 0.25 + 0.5 + 1.0 + (2 x 0.5) + 2.0 + 0.5
+ 1.0 + 0.5 + 0.5 + 0.5 = 8.25 cores
-> 8 cores, ratio 1.03
Disk, from the four growth terms:
base images (11 services, much shared) ~ 4 GB
image churn: 2 deploys/day x 80 MB x 14 days retention = 2.2 GB
volumes: db 1.5 GB/month x 12 + prometheus 15d retention = 24 GB
logs: 11 containers x 50 MB x 3 files = 1.7 GB
build cache ceiling = 10 GB
-------
subtotal 41.9 GB
with 20% headroom 52.4 GB
-> 64 GB for /var/lib/docker
Storage layout
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
findmnt -no SOURCE,FSTYPE,OPTIONS /var/lib/dockerNAME SIZE FSTYPE MOUNTPOINT
sda 256G
ββsda1 1G vfat /boot/efi
ββsda2 32G ext4 /
ββsda3 64G ext4 /var/lib/docker
ββsda4 32G ext4 /srv/backups
/dev/sda3 ext4 rw,relatimeIllustrative output
Four filesystems, each with a job:
/β the OS. Never grows unexpectedly once Docker is elsewhere./var/lib/dockerβ images, volumes, logs, build cache. Sized above./srv/backupsβ the local staging area for backups, which stage 5 copies off-host. Separate so that a runaway backup cannot fill the Docker filesystem./boot/efiβ as installed.
The daemon configuration
{
"icc": false,
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3",
"labels": "com.docker.compose.service"
},
"default-ulimits": {
"nofile": { "Name": "nofile", "Hard": 8192, "Soft": 4096 }
},
"metrics-addr": "127.0.0.1:9323"
}
Each line is a decision the capstone will be graded on:
live-restorekeeps the eleven containers running across a daemon restart, which stage 5 tests deliberately.userland-proxy: falseremoves onedocker-proxyprocess per published port. The capstone publishes only 80 and 443, so this is a small win β take it anyway, and verify the hairpin case in stage 3.log-opts.labelsattaches the Compose service name to every log entry, which is what makes the Loki labels in stage 4 work.metrics-addron loopback exposes the daemonβs own Prometheus metrics for stage 4 to scrape, without exposing them to the network.no-new-privilegessets the default so that a service someone adds later without the flag still gets it.
sudo python3 -c 'import json; json.load(open("/etc/docker/daemon.json")); print("valid")'
sudo systemctl restart docker
systemctl is-active docker
docker info --format 'live-restore={{.LiveRestoreEnabled}} logging={{.LoggingDriver}}'valid
active
live-restore=true logging=json-fileIllustrative output
Host-level prerequisites
- Time synchronisation. TLS certificate validation, Prometheus sample ordering and log correlation all depend on it.
timedatectl show -p NTPSynchronized --valuemust beyes. - DNS for the two hostnames.
app.example.comandapi.example.commust resolve to this host from the internet before Caddy can obtain a certificate in stage 3. - Firewall. Inbound 80 and 443 from anywhere, 22 from the management range only. Everything else denied, and remember that published ports bypass the INPUT chain.
- Unattended security updates enabled, with a reboot window that stage 5 assumes the stack survives.
- **The
dockergroup reviewed.** Its membership is a root grant; the capstone host should have the operators and the CI runner in it, and nothing else. - A non-root operator account with sudo, and SSH key authentication only.
echo '--- filesystem'
findmnt -T /var/lib/docker -no TARGET,SOURCE,SIZE,USE%
echo '--- daemon'
docker info --format 'live-restore={{.LiveRestoreEnabled}} logging={{.LoggingDriver}}'
docker info --format '{{json .SecurityOptions}}' | grep -q unconfined && echo 'FAIL seccomp' || echo 'OK seccomp'
echo "--- userland proxy processes: $(pgrep -c docker-proxy || echo 0)"
echo '--- time'
timedatectl show -p NTPSynchronized --value
echo '--- dns'
getent hosts app.example.com api.example.com
echo '--- access'
getent group docker--- filesystem
/var/lib/docker /dev/sda3 64G 2%
--- daemon
live-restore=true logging=json-file
OK seccomp
--- userland proxy processes: 0
--- time
yes
--- dns
203.0.113.24 app.example.com
203.0.113.24 api.example.com
--- access
docker:x:988:ops,ci-runnerIllustrative output
The findmnt -T form is important: it reports the filesystem
containing the path, so a host where /var/lib/docker was never
split reports / and /dev/sda2 instead of failing silently. A bare
findmnt /var/lib/docker exits non-zero with no output when the path
is not itself a mount point, which is exactly the case you are trying
to detect.
docker-proxy process count is the evidence for
userland-proxy: false β there is no docker info field for it. Zero
processes while ports are published means the setting took effect; one
per published port means it did not.
Sanity check
Knowledge check Β· 4 questions
Q1. With container memory limits summing to 6784 MB and a 70% utilisation target, what is the minimum sensible host RAM?
Q2. Which of these does `live-restore` NOT preserve while the daemon is stopped?
Q3. Why is a separate /var/lib/docker filesystem worth the provisioning effort? Select all that apply.
Q4. Setting `no-new-privileges` in daemon.json applies it to containers that are already running.
Passing score: 75%. Answers are checked in this browser.