Aurora Libros works, it is well organized and it is reproducible. What it is not is hardened. In this lesson you walk through the four attack surfaces of a container platform and apply the defenses one by one: an unprivileged user, a read-only filesystem, trimmed kernel capabilities, seccomp, vulnerability scanning and image signing.
Essential warning. Everything that follows consists of technical best practices, not a security policy. The specific decisions —what gets exposed, which CVE is accepted, which controls are mandatory— must be validated with the security or compliance officer in your organization. No configuration in this course replaces a professional audit or the regulatory compliance that applies to you.
Contents
- Threat model: what a container isolates and what it does not
- The daemon: the
dockergroup is root - Rootless Docker
--userns-remap, the middle-ground alternative- The image: a minimal base pinned by digest
distrolessandscratchversus Alpine- Secrets in layers: the proof with
docker history - Runtime:
--read-onlyand--tmpfs - Kernel capabilities
no-new-privileges, seccomp and AppArmor- Resources as a defense, and why
--privilegedis a mistake - Vulnerability scanning: Scout and Trivy
- Supply chain: SBOM, Cosign and provenance
- The hardened
compose.prod.yaml - Security checklist
- Threat model: what a container isolates and what it does not
A container is not a virtual machine. It shares the host's kernel, and that kernel is the only real boundary:
| A container does isolate | A container does NOT isolate |
|---|---|
| Processes (you cannot see the host's) | The kernel: a kernel flaw affects everybody |
| The filesystem (except what you mount) | Hardware, the clock and global parameters |
| Network, hostname, IPC, users | Whatever you hand it: sockets, mounts, capabilities |
flowchart TB
A["1 · IMAGE<br/>CVEs in the base, vulnerable<br/>dependencies, secrets in layers"]
B["2 · RUNTIME<br/>root inside, excess capabilities,<br/>free write access, no limits"]
C["3 · DAEMON<br/>docker group = root, exposed<br/>socket, --privileged"]
D["4 · REGISTRY<br/>impersonated image, no signature,<br/>mutable tag"]
A --> P(("Aurora<br/>Libros"))
B --> P
C --> P
D --> P
The four surfaces are defended with different tools and none of them covers the others: an image with no CVEs, run as root with --privileged, is still a disaster.
- The daemon: the
docker group is root
docker group is rootThis is the claim that meets the most resistance and the easiest one to demonstrate. Belonging to the docker group is equivalent to having root on the host, without sudo and without a trace in the sudo logs.
id | tr ',' '\n' | grep docker
docker run --rm -v /:/host alpine:3 sh -c 'cat /host/etc/shadow | head -2'You have just read the host's password hashes without being root. And you can go further: by mounting / in write mode, a user in the docker group can add themselves to /etc/sudoers, install an SSH key in /root/.ssh/authorized_keys or modify a systemd unit. The reason is structural: the docker client only sends requests to /var/run/docker.sock, and the daemon runs as root. Whoever can talk to that socket commands the daemon, and the daemon commands the host. Three operational consequences:
- Adding somebody to the
dockergroup is granting them root. Treat it that way in your access management. - Never mount
/var/run/docker.sockinside a container "for convenience". It is the most exploited escape route there is. If a tool needs it (a CI agent, Portainer), use a socket proxy that filters the allowed requests. - If the team does not need root on the host, the right answer is rootless mode.
- Rootless Docker
In rootless mode the daemon runs as your user, not as root, leaning on user namespaces (lesson 05-07). If somebody escapes the container, they show up as an unprivileged host user.
sudo apt-get install -y uidmap && dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
systemctl --user enable --now docker
docker info --format 'rootless={{.SecurityOptions}} | dir: {{.DockerRootDir}}'
docker run --rm -v /:/host alpine:3 sh -c 'cat /host/etc/shadow' 2>&1 | tail -1rootless=[name=seccomp,profile=builtin name=rootless name=cgroupns] | dir: /home/joan/.local/share/docker
cat: can't open '/host/etc/shadow': Permission deniedThe mount happens, but the file is unreadable: inside the container you are root, and that root is remapped to your real host UID, which cannot read /etc/shadow. The escalation ceases to exist.
| Rootless mode limitation | Detail |
|---|---|
| Ports < 1024 | Not published without CAP_NET_BIND_SERVICE or net.ipv4.ip_unprivileged_port_start |
| Network performance | Outbound traffic goes through slirp4netns/rootlesskit: somewhat slower |
| Storage driver | overlay2 requires kernel ≥ 5.13; otherwise fuse-overlayfs, which is slower |
| Resource limits | Require cgroups v2 with systemd delegation |
--network host, macvlan, AppArmor |
Unavailable or restricted: there are no privileges to touch the host's networking |
For Aurora Libros on a shared server, rootless is the default choice. For a dedicated server with restricted access, hardened classic mode is defensible; make that call with your security officer.
--userns-remap, the middle-ground alternative
--userns-remap, the middle-ground alternativeIf you cannot migrate to rootless, --userns-remap keeps the daemon running as root but remaps container users to an unprivileged range on the host. You enable it with {"userns-remap": "default"} in /etc/docker/daemon.json:
sudo systemctl restart docker
docker run -d --name t alpine:3 sleep 300 && docker exec t id -u
ps -o user,pid,cmd -C sleep --no-headersInside, the process is root (uid 0); on the host it runs as UID 165536, which has no privileges at all. The cost: existing volumes change effective owner, and it is incompatible with --network host and with containers that share user namespaces. It is a reasonable intermediate step, not a substitute for rootless.
- The image: a minimal base pinned by digest
Every package in the image is a potential CVE. The rules, in order of impact: an official, minimal base (node:22-alpine instead of node:22 goes from ~1.1 GB to ~142 MB, and with it hundreds of packages); pin by digest, because a tag is mutable and a digest is not; do not install what you do not need (no curl, vim, git or build-essential in the final image); an unprivileged USER (lesson 02-04); and update the base regularly, because pinning by digest without ever refreshing it is freezing vulnerabilities in place.
# Reproducible and verifiable: the digest identifies exact content
FROM node:22-alpine@sha256:3f1c8d9e7a4b2c5f6e0d1a8b7c4e2f9d0a3b6c5e8f1d2a7b4c9e0f3a6b5d8c1eYou get the digest with docker image inspect node:22-alpine --format '{{index .RepoDigests 0}}', and it is the only reference that guarantees today's image is exactly the one you audited. In development you can use tags; in compose.prod.yaml, digests.
distroless and scratch versus Alpine
distroless and scratch versus Alpine| Base | Size | Shell | Packages | Surface | Debugging |
|---|---|---|---|---|---|
node:22 (Debian) |
~1.1 GB | yes | apt | Very large | Comfortable |
node:22-slim |
~230 MB | yes | apt | Medium | Comfortable |
node:22-alpine |
~142 MB | yes (BusyBox) | apk | Small | Acceptable |
distroless/nodejs22 |
~110 MB | no | no | Minimal | Hard: :debug or nsenter |
scratch |
0 MB | no | no | None | Very hard; static binaries only |
distroless contains the interpreter and its libraries, and nothing else: no sh, no apk, no wget. An attacker who achieves command execution finds no tools to work with. The price is real: you cannot run docker exec ... sh, and aurora-api's HEALTHCHECK based on wget stops working (you would have to probe from outside or include a small health binary). scratch only works for static binaries —Go, Rust—, not for Node.js. For aurora-api, Alpine is the right balance between surface and operability; distroless is the next step up if your organization requires it.
- Secrets in layers: the proof with
docker history
docker historyA secret that passes through a layer stays in the image forever, even if you delete it afterwards.
# ❌ The three ways to leak a secret
ARG NPM_TOKEN
ENV DB_PASSWORD=aurora_pass_2026
RUN echo "$NPM_TOKEN" > /root/.npmrc && npm ci && rm /root/.npmrcdocker build --build-arg NPM_TOKEN=npm_s3cr3t0 -t leak:1.0 .
docker history --no-trunc leak:1.0 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
docker image inspect leak:1.0 --format '{{json .Config.Env}}'
# NPM_TOKEN=npm_s3cr3t0
# ["PATH=/usr/local/bin:...","DB_PASSWORD=aurora_pass_2026"]The three failures: the ARG stays in the build metadata, the ENV in the image configuration, and the deleted file is still in the layer where it was created, under a whiteout (lesson 05-02). Anybody who pulls the image gets them back.
The fixes, depending on the moment: during the build, BuildKit's RUN --mount=type=secret (lesson 05-05); at runtime, files mounted in /run/secrets/ with the _FILE pattern (lesson 04-05); inside the image, never.
Remember the pattern Aurora Libros already uses: DB_PASSWORD_FILE=/run/secrets/db_password, and the application reads the file at startup. The secret lives on the host, not in the image and not in the environment.
- Runtime:
--read-only and --tmpfs
--read-only and --tmpfsIf the container's filesystem is read-only, an attacker cannot drop a binary or modify the code.
Almost no application works without writing somewhere: you have to grant tmpfs for those specific paths.
docker run -d --name api-ro --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m auroralibros/aurora-api:1.2.0
docker exec api-ro sh -c 'touch /tmp/ok && echo "tmp is writable"; touch /app/x 2>&1'Note noexec and nosuid: even if the attacker manages to write a binary into /tmp, they will not be able to run it. To find out which paths an application needs to write to, start it read-only and read the errors; /tmp plus some cache directory is usually enough.
- Kernel capabilities
Being root inside a container is not being root on the host: Linux splits privileges into capabilities, and Docker grants a subset of about fourteen by default. The good practice is to drop them all and give back only the ones you need.
docker run --rm --cap-drop=ALL alpine:3 chown 1000 /tmp
docker run --rm --cap-drop=ALL --cap-add=CHOWN alpine:3 sh -c 'chown 1000 /tmp && echo "chown OK"'| Capability | What it allows | Does Aurora Libros need it? |
|---|---|---|
NET_BIND_SERVICE |
Listening on ports < 1024 | Only aurora-web (nginx on 80) |
CHOWN, FOWNER, DAC_OVERRIDE |
Changing owners and bypassing permissions | aurora-db when initializing the volume |
SETUID, SETGID |
Switching user (typical of su-exec) |
aurora-db and aurora-web at startup |
NET_RAW |
Raw sockets: ping, and also spoofing |
No. Always drop it |
SYS_ADMIN |
Mounting, changing namespaces… practically root | Never. It is equivalent to --privileged |
SYS_PTRACE |
Debugging other processes, reading their memory | Only for one-off debugging |
SYS_MODULE |
Loading kernel modules | Never. It compromises the whole host |
For aurora-api, which listens on 3000 and runs as the node user, the answer is clean: no capabilities at all, that is, cap_drop: [ALL] with no cap_add whatsoever.
no-new-privileges, seccomp and AppArmor
no-new-privileges, seccomp and AppArmorno-new-privileges:true in security_opt prevents a process from gaining privileges through setuid binaries, and cuts off an entire family of local escalations at the root.
seccomp filters system calls. Docker's default profile blocks around 40 of the 300-plus available, including the most dangerous ones: mount, reboot, kexec_load, init_module, ptrace (on older kernels), clone with new namespace flags, and bpf.
docker run --rm alpine:3 sh -c 'mount -t tmpfs none /mnt' 2>&1 | tail -1
docker run --rm --security-opt seccomp=unconfined --cap-add=SYS_ADMIN alpine:3 \
sh -c 'mount -t tmpfs none /mnt && echo "mounted: profile disabled"'A custom profile is a JSON file with a default action and a list of allowed calls:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [{
"names": ["read","write","openat","close","fstat","mmap","brk","epoll_wait",
"accept4","socket","bind","listen","futex","clock_gettime","exit_group"],
"action": "SCMP_ACT_ALLOW"
}]
}You apply it with security_opt: [seccomp:./security/aurora-api-seccomp.json]. Building a custom profile is delicate work: you have to trace the application (with strace or auditd) to discover which calls it really uses, and one forgotten call produces intermittent failures that are hard to diagnose. Start with the default profile —which is already a solid defense— and take on a custom one only if your context demands it.
AppArmor (Debian/Ubuntu) and SELinux (RHEL/Fedora) operate at a different level: they control which files and operations a process may touch. Docker applies the docker-default profile if AppArmor is active. On SELinux, the :z / :Z option on mounts relabels the content so the container can access it (-v data:/data:Z), and leaving it out is the cause of the classic Permission denied on Fedora with apparently correct permissions. What you must never do: seccomp=unconfined or apparmor=unconfined to "fix" an error.
- Resources as a defense, and why
--privileged is a mistake
--privileged is a mistakeThe limits from lesson 03-07 are not only about capacity: they are a defense against denial of service. A compromised container that mines cryptocurrency, exhausts memory or launches a fork bomb is contained by its pids_limit: 200 and its deploy.resources.limits.
And --privileged, in one line: it disables almost every protection at once. It grants all capabilities, disables seccomp and AppArmor, and gives access to every device on the host.
docker run --rm --privileged alpine:3 sh -c 'ls /dev/sda*'
# /dev/sda /dev/sda1 /dev/sda2 <- the host's disks, visible and mountableIf something needs a specific device, grant it with --device=/dev/xxx; if it needs a specific capability, with --cap-add. --privileged is the equivalent of removing the door because the key was hard to find.
- Vulnerability scanning: Scout and Trivy
✗ HIGH CVE-2025-31842 [Improper Input Validation]
Affected range : <4.19.3 Fixed version : 4.19.3
Package : pkg:npm/[email protected]
✗ HIGH CVE-2025-47101 [Out-of-bounds Write]
Affected range : <3.20.4-r2 Fixed version : 3.20.4-r2
Package : pkg:apk/alpine/[email protected]
2 vulnerabilities found in 2 packagesHow to read a report, in decision order:
| Field | What to do |
|---|---|
| Severity (CRITICAL/HIGH/MEDIUM/LOW) | Start with the first two |
| Fixed version | If a fix exists, update. This is the decisive piece of data |
| Package | Your own dependency: npm update. From the base: change base or digest |
| Attack vector | A CVE in code you never run is less urgent, not non-existent |
Trivy gives you the same thing from the command line and lends itself to automation:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest \
image --severity HIGH,CRITICAL --exit-code 1 auroralibros/aurora-api:1.2.0
# Total: 2 (HIGH: 2, CRITICAL: 0) -> the command exits with code 1--exit-code 1 is the key to integration: it makes the command fail and, with it, the pipeline (lesson 06-02). The sensible workflow has three checkpoints: when building locally, on every pull request, and periodically against the images already deployed. And a note about the socket: mounting docker.sock into the scanner is exactly what section 2 advises against; it is acceptable on your own machine, but in CI you should scan the image tarball or use a scanner without access to the daemon.
- Supply chain: SBOM, Cosign and provenance
An SBOM (Software Bill of Materials) is the inventory of everything inside an image. Without one, when a new CVE appears you cannot answer the most basic question: does it affect me?
docker sbom auroralibros/aurora-api:1.2.0 --format syft-json > sbom-aurora-1.2.0.json
docker sbom auroralibros/aurora-api:1.2.0 | head -4Cosign signs images cryptographically and verifies their origin:
cosign generate-key-pair # cosign.key (private) and cosign.pub
cosign sign --key cosign.key auroralibros/aurora-api:1.2.0
cosign verify --key cosign.pub auroralibros/aurora-api:1.2.0Verification for auroralibros/aurora-api:1.2.0 --
- The cosign claims were validated
- The signatures were verified against the specified public keyThe signature is stored as an additional artifact in the registry itself. With keyless signing you can sign using the pipeline's OIDC identity, without managing private keys —which, in practice, is the biggest advantage: the key nobody has to guard is the key nobody leaks.
Provenance attestations answer "who built this, from which commit and with which Dockerfile", and are generated during the build: docker buildx build --provenance=mode=max --sbom=true -t auroralibros/aurora-api:1.2.0 --push ./api, and can be queried with docker buildx imagetools inspect.
About the old mechanism: Docker Content Trust (DOCKER_CONTENT_TRUST=1) and Notary v1 were the first attempt at signing on Docker Hub. They still work, but they are on the way out: the industry has moved to Cosign and the Sigstore ecosystem. If you are starting today, start with Cosign.
- The hardened
compose.prod.yaml
compose.prod.yaml# compose.prod.yaml — Aurora Libros S.L., hardened server
services:
aurora-api:
# Digest, not tag: exact, audited content
image: auroralibros/aurora-api:${AURORA_API_VERSION:?set the version}@${AURORA_API_DIGEST:?set the digest}
build: !reset null # the server does not build: it only pulls
user: "1000:1000" # unprivileged, even though the image already sets it
read_only: true # immutable filesystem
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop: [ALL] # no capabilities: it listens on 3000
security_opt: [no-new-privileges:true] # no escalation via setuid binaries
pids_limit: 200 # containment against a fork bomb
environment:
NODE_ENV: production
LOG_LEVEL: warn
DB_PASSWORD_FILE: /run/secrets/db_password # never the value in the environment
secrets: [db_password]
ports: !reset [] # no direct access: everything goes through the proxy
restart: always
deploy:
resources: { limits: { memory: 512M, cpus: "2.0" }, reservations: { memory: 128M } }
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
aurora-db:
image: postgres:16-alpine@sha256:9c1f2b8e5a7d...
user: "999:999"
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m", "/run/postgresql:rw,nosuid,size=16m"]
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID] # the minimum postgres needs
security_opt: [no-new-privileges:true]
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
restart: always
deploy:
resources: { limits: { memory: 2G, cpus: "2.0" } }
aurora-cache:
image: redis:7-alpine@sha256:4b2e9c7f1a3d...
user: "999:999"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
command: ["redis-server","--maxmemory","200mb","--maxmemory-policy","allkeys-lru","--save",""]
restart: always
aurora-web:
image: nginx:alpine@sha256:7e3a1c9d0b5f...
read_only: true
tmpfs: ["/var/cache/nginx:rw,nosuid,size=64m", "/var/run:rw,nosuid,size=8m"]
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE, CHOWN, SETGID, SETUID] # nginx listens on 80
security_opt: [no-new-privileges:true]
ports: ["80:80"]
restart: always
secrets:
db_password:
file: ./secrets/db_password.txt # outside Git; permissions 0400Always verify the merge before deploying: docker compose -f compose.yaml -f compose.prod.yaml config | grep -E 'read_only|cap_drop|no-new'.
- Security checklist
| # | Control | How to verify it |
|---|---|---|
| 1 | The daemon is not reachable by untrusted users | getent group docker |
| 2 | Rootless mode evaluated or ruled out in writing | docker info | grep -i rootless |
| 3 | No container mounts docker.sock |
docker ps -q | xargs docker inspect | grep docker.sock |
| 4 | No --privileged in production |
grep -r privileged compose*.yaml |
| 5 | All images pinned by digest | docker compose config | grep image: |
| 6 | No container runs as root | docker inspect --format '{{.Config.User}}' |
| 7 | read_only: true with its minimal tmpfs |
{{.HostConfig.ReadonlyRootfs}} |
| 8 | cap_drop: [ALL] and justified additions |
Review of compose.prod.yaml |
| 9 | no-new-privileges, and seccomp/AppArmor without unconfined |
{{.HostConfig.SecurityOpt}} |
| 10 | Memory, CPU and PID limits on everything | docker stats --no-stream |
| 11 | Scan clean of critical CVEs that have a fix available | docker scout cves --only-severity critical |
| 12 | Periodic scanning of what is already deployed | Weekly scheduled job |
| 13 | Secrets via files, never in ENV or in layers |
docker history | grep -iE 'pass|token' |
| 14 | SBOM generated and archived per version | Pipeline artifact |
| 15 | Images signed and verified before deploying | cosign verify |
| 16 | Segmented networks and data with no route to the Internet | internal: true on backend |
| 17 | Only the strictly necessary ports published | docker compose ps, iptables -t nat -S DOCKER |
Go through this list before every deployment and review it with your security officer: it is a technical starting point, not a certificate of compliance.
Common Mistakes and Tips
Believing a container isolates like a VM. It shares the kernel: a kernel flaw affects everybody.
Adding people to the docker group as if it were a minor permission, or mounting /var/run/docker.sock into a container. The first is granting root with no trace in sudo; the second is the most exploited escape route there is.
Passing secrets with ARG or ENV in the Dockerfile. They stay in the layers and in the metadata. docker history reveals them.
Using --privileged to work around a permissions error, or disabling seccomp/AppArmor to make something work. Grant the specific thing that is missing (--cap-add, --device); the other approach is switching off the alarm instead of dealing with the fire.
Scanning once and forgetting about it, or pinning by digest and never refreshing it. CVEs appear after publication; an image that was clean in March can be vulnerable in June without anybody touching it.
Tip: apply hardening service by service and by measuring. Add read_only, start it, read the error, add exactly the tmpfs you need; then cap_drop: [ALL], start it, read the error, add exactly the capability you need. It is slower than copying a template, and it is the only way to know why each line is there.
Exercises
Exercise 1. Demonstrate privilege escalation from the docker group: read a host file your user cannot read and explain exactly why it works. Then reason out which two configurations would prevent it.
Exercise 2. Harden aurora-api incrementally: user, read_only, cap_drop: [ALL] and no-new-privileges. Document what breaks at each step, which tmpfs is needed, and verify at the end that /health and /books still respond.
Exercise 3. Prove that a secret passed with ARG stays in the image: build it, retrieve the secret with docker history even though the file has been deleted, and explain why Aurora Libros' _FILE pattern does not have that problem.
Solutions
Solution 1.
The same file, the same user, two opposite outcomes. The reason lies in who performs the operation: the docker command reads nothing, it only sends a request to the daemon's socket; the daemon runs as root and mounts / inside the container with its own privileges. Inside, the process is also root, so it reads whatever it likes. Your user never needed permission because your user was never the one reading the file.
The two configurations that prevent this are rootless mode (the daemon runs as your user and the root inside is remapped to your real UID, which cannot read /etc/shadow) and --userns-remap (the container's root is remapped to an unprivileged UID, with the same effect on the mount).
The management conclusion is the one that matters: usermod -aG docker somebody is a grant of root and must go through the same approval process as granting passwordless sudo.
Solution 2.
docker run -d --name h1 -u 1000:1000 auroralibros/aurora-api:1.2.0 && docker exec h1 id -u
docker run --rm --read-only auroralibros/aurora-api:1.2.0 2>&1 | tail -1 # step 2: it fails# Steps 3 and 4: minimal tmpfs, no capabilities and no escalation
docker run -d --name h4 -u 1000:1000 --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL --security-opt no-new-privileges:true \
--pids-limit 200 -p 3000:3000 auroralibros/aurora-api:1.2.0
sleep 3
curl -s http://localhost:3000/health | jq -c '{status, db, cache}'
docker inspect h4 --format 'ro={{.HostConfig.ReadonlyRootfs}} caps={{.HostConfig.CapDrop}} opts={{.HostConfig.SecurityOpt}}'
docker exec h4 sh -c 'touch /app/backdoor.js' 2>&1{"status":"ok","db":"connected","cache":"connected"}
ro=true caps=[ALL] opts=[no-new-privileges:true]
touch: /app/backdoor.js: Read-only file systemThe only step that failed was the second, for a specific reason: the application writes its PID file to /tmp. A 64 MB tmpfs with noexec,nosuid solves it without handing write access back to the rest of the filesystem. cap_drop: [ALL] broke nothing because aurora-api listens on 3000 (unprivileged) and was already running as the node user: this is where the decision you made back in lesson 02-04 pays off.
The result is a container in which an attacker with code execution cannot write a binary, cannot run it if they do write it to /tmp, has no kernel capabilities, cannot escalate through setuid and cannot spawn more than 200 processes. The application, meanwhile, responds exactly as before.
Solution 3.
mkdir -p /tmp/leak && cd /tmp/leak
printf 'FROM alpine:3\nARG API_TOKEN\nRUN echo "$API_TOKEN" > /token.txt && rm /token.txt\n' > Dockerfile
docker build --build-arg API_TOKEN=aur_tok_9f3c1 -t leak:1.0 . >/dev/null
docker run --rm leak:1.0 ls /token.txt 2>&1 # the file is gone
docker history --no-trunc leak:1.0 | grep -o 'API_TOKEN=[^ |]*' | head -1
docker save leak:1.0 | tar -xO --wildcards '*/layer.tar' 2>/dev/null | strings | grep -o 'aur_tok_[a-z0-9]*' | head -1Three linked facts: the file does not exist in the final filesystem, the token shows up in the build history, and it shows up as well inside the exported layer's data. The first explains why so many people believe deleting the file is enough; the second and third explain why it is not: the ARG stays in the metadata and the deleted content is still in its layer, hidden only by a whiteout (lesson 05-02).
Aurora Libros' _FILE pattern does not have that problem because the secret never enters the image. The image contains only DB_PASSWORD_FILE=/run/secrets/db_password, which is a path, not a value:
docker image inspect auroralibros/aurora-api:1.2.0 --format '{{json .Config.Env}}' | tr ',' '\n' | grep -iE 'pass|token'
docker compose exec aurora-api sh -c 'mount | grep run/secrets'"DB_PASSWORD_FILE=/run/secrets/db_password"
/dev/sda2 on /run/secrets/db_password type ext4 (ro,relatime)The metadata holds nothing but a path; the value arrives through a read-only mount that exists only while the container lives. If you publish that image on Docker Hub tomorrow, no secret travels inside it. For secrets needed during the build —a private npm token— the equivalent solution is BuildKit's RUN --mount=type=secret, and that is exactly what you will see in lesson 05-05.
Conclusion
Aurora Libros is hardened and, more importantly, you know why every line of that hardening is there. You started from the threat model: a container isolates processes, files and networking, but it shares the kernel, and the four surfaces —image, runtime, daemon and registry— demand different defenses that do not substitute for one another. You have demonstrated, by reading /etc/shadow without being root, that belonging to the docker group means having root on the host, and you know the two answers: rootless mode with its real limitations (low ports, slightly slower networking, no --network host) and --userns-remap as an intermediate step.
On the image you apply a minimal base pinned by digest, the comparison between Alpine, distroless and scratch with its cost in operability, and the irrefutable proof that a secret passed through ARG or ENV stays in the layers forever. At runtime, --read-only with noexec,nosuid tmpfs for just what is needed, cap_drop: [ALL] with the table of capabilities that really matter —and aurora-api with none at all—, no-new-privileges, the default seccomp profile and when to write your own, AppArmor and SELinux with its :Z, resource limits as denial-of-service containment, and why --privileged is almost always a mistake. You scan with Docker Scout and Trivy knowing how to read a report —the fixed version is the decisive piece of data— and how to integrate it with --exit-code 1; and you secure the supply chain with SBOMs, Cosign signing and provenance attestations, leaving Content Trust as the legacy mechanism it is. All of it condensed into a compose.prod.yaml commented line by line and a checklist of seventeen verifiable controls. And the opening warning still stands: this is a solid technical floor, not a policy, so validate every decision with the security or compliance officer in your organization.
In lesson 05-04 we change targets and go back to the image, this time with the scales in front of us: why size matters (download time, cost, attack surface, scaling speed), how to measure with history and dive to find the fat layer, which base to choose with real numbers, and above all multi-stage builds, with which those 142 MB of aurora-api will come down to a specific figure without losing any of what you have just hardened.
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
