DOCKER
DEVOPS FOUNDATIONS
Badr Tajini · ESILV · 2026–2027
⌘
My Laptop / local terminal
Execution guidance

Type in your project folder on your laptop. Open its local browser. Docker Desktop uses a local Linux VM; no SSH is needed.

Environment setup ↗

✓ visited · ● current · ○ unvisited
Navigation only, not assessment.

TD05 · My Laptop

01 / WHERE AM I?

TD5 — Harden and inspect the same application

Duration: 180 minutes. Assessment: TD5 contributes 8 course points.

Where are we in the project?

TD4 gave us a working proxy → API → PostgreSQL stack, a private database network, a persistent volume, and a known-good API digest. TD5 keeps that stack and digest while reducing what each process can do and recording the software inside the image. TD6 will release the next digest.

This stage adds: Least-privilege controls and reviewed supply-chain findings.

TD05 · My Laptop

02 / TODAY'S MISSION

Today's mission

Harden the same working application, prove allowed requests still work and a forbidden root-filesystem write fails for the intended reason, then produce an SBOM and vulnerability scan for the exact running API digest. Create and justify your own controls and a bounded risk decision. Keep the database's required writable data path.

TD05 · My Laptop

03 / FINAL ARCHITECTURE IMAGE

The same application receives service-specific privilege limits while required temporary and database writes remain possible. A protected secret file is separate from the image. SBOM and scan results are tied to digest D and require human triage.
TD5 / Useful work, limited authority

TD05 · My Laptop

04 / MY ENVIRONMENT

Before you start

Before you start

Work in your private project repository based on the student starter, where compose.yaml and scripts/ sit together. Keep the TD4 data and secret volumes and use the same Docker host. My Laptop: run Bash locally or in Ubuntu WSL with Docker Desktop integration. My Own Cloud: SSH to your Ubuntu host and run the same work there; use a laptop SSH tunnel to view the loopback proxy. Hades is an engineering example, not a student login.

First confirm TD4 smoke and persistence checks pass. Your non-secret release.env must name a real ghcr.io image digest. If your scanner or platform prerequisite is unavailable, record BLOCKED with the failed command and reason. Never mount the Docker socket, host root, devices or host namespaces into an application or scanner container. Keep secrets out of command output and Git.

The starter operational scripts stop with exit 64 until you implement their marked tasks. Complete your regression and runtime-contract checks from the published lab requirements; they must fail on a mismatched identity or a broken required behavior. A supplied stub is not an executed check.

My Laptop

Type in your local Bash terminal. Docker runs on your laptop or inside Docker Desktop’s Linux VM. Open browser checks on your laptop.

My Own Cloud

After SSH, type Docker commands in your own Ubuntu server’s terminal. For browser checks, use a separate laptop terminal for the SSH tunnel. The server’s localhost and your laptop’s localhost are different.

Check my setup and tunnel instructions

TD05 · My Laptop

05 / STEP-BY-STEP WORK

05 / STEP-BY-STEP WORK

Do the work, one step at a time.

Follow the commands in order. Keep the same terminal open so values from earlier steps remain available.

0/5steps self-marked
Stored only in this browser; no Docker or grading check runs here.
STEP 01 / 05

Step 1 — Name the threat and exact artifact · 30 minutes

Why: A security control needs an abuse case and a test. A scanner count alone is not a security decision.

Run this from your project root:

set -euo pipefail
. scripts/course-env.sh
set -a; . ./release.env; set +a
case "$DELIVERY_API_IMAGE" in ghcr.io/*@sha256:*) ;; *) echo 'BLOCKED: exact GHCR digest required'; exit 1;; esac
mkdir -p evidence/TD05
printf 'captured_at_utc=%s\nimage=%s\n' "$(date -u +%FT%TZ)" "$DELIVERY_API_IMAGE" > evidence/TD05/image-under-review.txt
docker compose -p docker-delivery ps | tee evidence/TD05/before-ps.txt
bash scripts/smoke.sh | tee evidence/TD05/before-smoke.txt
bash scripts/regression.sh | tee evidence/TD05/before-regression.txt

Write evidence/TD05/THREAT_MODEL.md. For each asset, identify an attacker path, likely abuse, a specific control and a test that could disprove your claim. Cover the Docker daemon, database network, password file, API/proxy filesystem, image identity and one runtime dependency. For one non-assessed example: a public documentation page should have an owner and review date; test that the link still resolves. Add your actual host observations and justify at least one tradeoff.

Expected and verify: Baseline smoke and persistence regression pass, and the scanned image coordinate matches the running container. If they differ, stop before scanning.

STEP 02 / 05

Step 2 — Produce an SBOM and scan without a daemon socket · 30 minutes

Why: The SBOM and findings must describe exactly the immutable image from Step 1. Trivy receives a read-only exported image tar, never the daemon socket.

scan_dir="$(mktemp -d)"
trap 'rm -f -- "$scan_dir/api.tar"; rmdir -- "$scan_dir"' EXIT
docker image save --output "$scan_dir/api.tar" "$DELIVERY_API_IMAGE"
test -s "$scan_dir/api.tar"
docker run --rm --platform "$COURSE_IMAGE_PLATFORM" \
  --mount "type=bind,source=$scan_dir,target=/scan,readonly" \
  "$TRIVY_IMAGE" image --input /scan/api.tar --format cyclonedx \
  > evidence/TD05/sbom.cdx.json
docker run --rm --platform "$COURSE_IMAGE_PLATFORM" \
  --mount "type=bind,source=$scan_dir,target=/scan,readonly" \
  "$TRIVY_IMAGE" image --input /scan/api.tar --scanners vuln --format json \
  > evidence/TD05/vulnerabilities.json
python3 -m json.tool evidence/TD05/sbom.cdx.json >/dev/null
python3 -m json.tool evidence/TD05/vulnerabilities.json >/dev/null
printf '%s\n' "$DELIVERY_API_IMAGE" > evidence/TD05/scanned-digest.txt
sha256sum evidence/TD05/sbom.cdx.json evidence/TD05/vulnerabilities.json \
  > evidence/TD05/scanner-artifacts.sha256
rm -f -- "$scan_dir/api.tar"
rmdir -- "$scan_dir"
trap - EXIT

The scanner may download or refresh its vulnerability database. Record scanner image version, UTC time, database/update status shown in stderr, and any offline or update failure. A JSON parse alone does not prove database freshness. Keep the unfiltered JSON for review; TD6's release gate applies its own CRITICAL,HIGH and fixed-vulnerability policy. Do not silently reinterpret that filtered CI file as a complete inventory.

Expected and verify: Both JSON files are nonempty and parse; scanned-digest.txt equals the Step 1 digest; sha256sum -c evidence/TD05/scanner-artifacts.sha256 passes. Inspect sbom.cdx.json for bomFormat: CycloneDX and a components array. Finding counts vary with the actual image and database. Record what you saw, never a prewritten PASS or CVE count.

If it fails: Record BLOCKED: scan/database unavailable and the exact scanner error. Correct network/cache availability or rerun the pinned scanner. Do not mount /var/run/docker.sock to work around the failure.

STEP 03 / 05

Step 3 — Build your hardening and test the permitted path · 45 minutes

Why: Least privilege matters only when the real API, proxy and database path still works. Edit your own Dockerfile, compose.yaml and Nginx definition. Run the API as a non-root identity, drop unneeded capabilities, prevent privilege gain, make API/proxy roots read-only with only narrow writable temporary paths, and bound resources. Keep the proxy loopback-only and API/database private. PostgreSQL requires its writable named data volume and a documented runtime exception. Mount the password file read-only and give only the processes that need it read access; do not put its value in YAML, environment, image history or evidence.

docker compose -p docker-delivery config --quiet
git diff --check
docker compose -p docker-delivery up --detach --no-build --force-recreate --wait --wait-timeout 120
bash scripts/smoke.sh | tee evidence/TD05/smoke-hardened.txt
bash scripts/regression.sh | tee evidence/TD05/regression-hardened.txt
docker compose -p docker-delivery ps | tee evidence/TD05/hardened-ps.txt

Check healthz, readyz, version, persistence and publication. On your own cloud host, keep the provider firewall closed to application and database ports and use the SSH tunnel for browser access. If a service fails, inspect its bounded logs and the specific permission error; do not switch to root or use chmod 777 as a blanket repair.

STEP 04 / 05

Step 4 — Prove a forbidden action fails for the intended reason · 25 minutes

Why: Pair allowed behavior with a denied action, then inspect the control that caused the denial.

api_id="$(docker compose -p docker-delivery ps -q delivery-api)"
proxy_id="$(docker compose -p docker-delivery ps -q proxy)"
postgres_id="$(docker compose -p docker-delivery ps -q postgres)"
for id in "$api_id" "$proxy_id" "$postgres_id"; do
  docker inspect --format 'name={{.Name}} user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} capdrop={{json .HostConfig.CapDrop}} security={{json .HostConfig.SecurityOpt}} mounts={{json .Mounts}}' "$id"
done | tee evidence/TD05/runtime-controls.txt
docker exec "$api_id" sh -c 'id; grep -E "^(CapEff|NoNewPrivs):" /proc/1/status; test -r /run/secrets/postgres_password.txt' \
  | tee evidence/TD05/api-identity.txt
docker exec "$proxy_id" sh -c 'id; grep -E "^(CapEff|NoNewPrivs):" /proc/1/status' \
  | tee evidence/TD05/proxy-identity.txt
docker top "$postgres_id" -eo user,pid,ppid,args | tee evidence/TD05/postgres-process.txt
set +e
docker exec --user 0 "$api_id" sh -c 'touch /app/td05-denied' > evidence/TD05/root-write-denied.txt 2>&1
write_status=$?
set -e
printf 'exit_code=%s\n' "$write_status" >> evidence/TD05/root-write-denied.txt
test "$write_status" -ne 0
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$api_id")" = true
bash scripts/smoke.sh | tee evidence/TD05/allowed-after-denial.txt
docker run --rm --platform "$COURSE_IMAGE_PLATFORM" --network none --read-only \
  --user "$(id -u):$(id -g)" --group-add 20001 --cap-drop ALL \
  --mount type=volume,source=docker-delivery_course-secret,target=/run/secrets,readonly \
  --mount "type=bind,source=$PWD/evidence,target=/evidence,readonly" \
  "$PYTHON_IMAGE" python -c '
from pathlib import Path
secret = Path("/run/secrets/postgres_password.txt").read_bytes().strip()
if not secret:
    raise SystemExit("STOP: protected password file is empty")
leaks = [str(p.relative_to("/evidence")) for p in Path("/evidence").rglob("*")
         if p.is_file() and secret in p.read_bytes()]
if leaks:
    print("STOP: remove the password from these evidence files:", *leaks, sep="\n")
    raise SystemExit(1)
print("PASS: the current password value was not found in local evidence files")'

--user 0 is a one-off diagnostic: root must still receive a read-only file system error at /app. The running API process remains UID 10001. A generic “Permission denied” could be file ownership; it does not by itself prove a read-only mount. Check CapEff: 0000000000000000 and NoNewPrivs: 1 for API/proxy. The secret-file test checks readability only; never cat its value into evidence. Check the secret's name, not its value, with docker inspect ... .Config.Env if investigating a leak.

Expected and verify: Denied touch has a nonzero exit code and the read-only reason; API/proxy roots are read-only with ALL dropped; ordinary endpoint tests still pass. PostgreSQL's steady-state server process is non-root, with its data path writable.

If it fails: Preserve outputs, inspect the observed mount and process identity, then fix the owned definition and replay both allowed and denied tests. Do not report a denial caused only by an unrelated missing file or wrong path.

STEP 05 / 05

Step 5 — Triage, verify recovery and save · 50 minutes

Why: A scan is an input to a decision. A security claim must include its limits and show that recovery is still possible.

Create evidence/TD05/TRIAGE.md. For each High/Critical finding in your observed scan, record component/version, fix version or no fix, whether this workload reaches it, attacker path, disposition (rebuild/mitigate/time-bounded accept), owner, review date and residual risk. If there are no High/Critical findings, pick two Medium findings; if there are none, choose two significant SBOM components and explain why no selected CVE appeared. A representative decision is: if a vulnerable package has a fix and is reachable on the API request path, rebuild on a fixed pinned base, scan the new digest, and rerun tests before promotion. If no fix exists and the component is unreachable under this architecture, a dated, evidence-backed exception can be considered. Neither decision asserts that a specific CVE exists in your scan.

Replay the known-good TD4 definition from the recorded SHA, then return to your current definition. Compare the saved known-good definition with your hardened definition and explain any difference. This tests definition replay, not an image rollback. TD6 performs the actual digest rollback. Do not remove named data or secret volumes.

known_good_sha="$(cat evidence/TD04/known-good-sha.txt)"
test -n "$known_good_sha"
rollback_dir="$(mktemp -d)"
rmdir "$rollback_dir"
git worktree add --detach "$rollback_dir" "$known_good_sha"
docker compose -p docker-delivery -f "$rollback_dir/compose.yaml" config --quiet
docker compose -p docker-delivery -f "$rollback_dir/compose.yaml" up \
  --detach --no-build --force-recreate --wait --wait-timeout 120
bash scripts/smoke.sh | tee evidence/TD05/known-good-smoke.txt
docker compose -p docker-delivery up \
  --detach --no-build --force-recreate --wait --wait-timeout 120
bash scripts/smoke.sh | tee evidence/TD05/returned-hardened-smoke.txt
bash scripts/regression.sh | tee evidence/TD05/returned-hardened-regression.txt
git worktree remove "$rollback_dir"

Write evidence/TD05/OBSERVATIONS.md with the actual UTC time, Docker version, scanned/running digest, scanner database status, SBOM/scan hashes, one triage decision and expiry, API/proxy controls, allowed and denied outputs with exit codes, recovery outputs, and one remaining limitation. Use BLOCKED for any step not run. For a rootful Docker host, daemon-level authority remains a residual risk. Verify sha256sum -c evidence/TD05/scanner-artifacts.sha256 after copying evidence.

Expected and verify: The hardened definition is active again; smoke and persistence regression pass. git status --short shows only your intended files. A new base image or rebuild produces a new digest and requires a new scan and approval.

If it fails: Keep the last known-good coordinate and failure output. Restore the original definition and diagnose before claiming completion. Do not delete PostgreSQL data.

Open checkpoint →

TD05 · My Laptop

06 / LIVE CHECKPOINT

Run the source checks in your terminal, then compare your observations. Browser markers are your own record, not a verification result.

Check it

Run these checks on the same Docker host after Step 5. They make real requests and inspect the current stack; the JSON checks also verify the saved scan artifacts.

set -euo pipefail
. scripts/course-env.sh
set -a; . ./release.env; set +a
api_id="$(docker compose -p docker-delivery ps -q delivery-api)"
proxy_id="$(docker compose -p docker-delivery ps -q proxy)"
test "$(docker inspect --format '{{.Config.Image}}' "$api_id")" = "$(cat evidence/TD05/scanned-digest.txt)"
bash scripts/smoke.sh
bash scripts/regression.sh
bash scripts/verify_runtime_contract.sh
for container in "$api_id" "$proxy_id"; do
  # regression recreated the API: resolve its current ID again.
  if test "$container" = "$api_id"; then container="$(docker compose -p docker-delivery ps -q delivery-api)"; fi
  test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container")" = true
  docker inspect --format '{{json .HostConfig.CapDrop}}' "$container" | grep -q 'ALL'
done
api_id="$(docker compose -p docker-delivery ps -q delivery-api)"
test "$(docker exec "$api_id" id -u)" = 10001
docker exec "$api_id" test -r /run/secrets/postgres_password.txt
if docker exec --user 0 "$api_id" touch /app/td05-checkpoint-denied; then
  echo 'FAIL: forbidden write succeeded'; exit 1
fi
sha256sum --check evidence/TD05/scanner-artifacts.sha256
python3 - <<'CHECK'
import json
from pathlib import Path
p=Path('evidence/TD05')
sbom=json.loads((p/'sbom.cdx.json').read_text())
assert sbom['bomFormat']=='CycloneDX' and isinstance(sbom['components'],list)
assert isinstance(json.loads((p/'vulnerabilities.json').read_text()),dict)
assert (p/'TRIAGE.md').stat().st_size
CHECK
printf 'PASS: allowed behavior, denied write and saved scan checks agree\n'

Expected: positive checks pass; the denied write prints a read-only-filesystem error. Missing or stale scanner context is still BLOCKED, even if JSON parses. Step 4 contains the exact no-disclosure evidence scan; rerun it before sharing evidence.

TD05 · My Laptop

07 / WHAT CHANGED IN MY PROJECT

What did we add to the project?

Before: a working Compose stack and known-good definition. This TD added: tested least-privilege controls and digest-linked SBOM, scan and triage. After: the same healthy application with allowed and denied actions verified. Next: TD6 releases an exact digest and tests the path back.

See the evolving project architecture ↗

TD05 · My Laptop

08 / ENGINEER MODE

REAL VPS CONTEXT / NO STUDENT ACCESS TO HADES. See how this concept looks on a real VPS; perform and justify the assessed work on your own machine.

Engineer Mode

A real VPS changes where commands run and where evidence is stored; it does not change the threat model. Inspect the active image digest, effective container user, mounts and capabilities, then compare a permitted request with a denied write. A scanner result needs its image digest, database context, time and human triage. PostgreSQL still needs a writable data volume. Hades is an observational reference; no current Hades scan result is asserted.

TD05 · My Laptop

09 / HELP

I'm stuck

Symptom First check and correction Verify again
Trivy cannot update database Save stderr; check scanner network/cache, rerun the pinned command JSON validity and recorded update status
API cannot read secret Inspect file mode/group and the group_add/secret mount; provision the file through the course setup path docker exec "$api_id" test -r /run/secrets/postgres_password.txt
Proxy fails with read-only root Inspect docker compose logs --tail=80 proxy; use the supplied /tmp Nginx paths docker compose ... up --wait and smoke
Denied write says only permission denied Check root override, /app mount and ReadonlyRootfs; replay the diagnostic Explicit read-only error and allowed smoke

Share only the command, redacted error, and My Laptop or My Own Cloud. Never share a password, token, private key or raw database dump.

Assessment details

Assessment checklist (100 raw points; TD05 contributes 8 course points)

Criterion Core Depth Total Core evidence
Threat model and prioritization 16 4 20 scoped assets/boundaries/capabilities, six abuse cases and testable mitigations
SBOM and vulnerability reasoning 20 5 25 digest linkage, valid artifacts, defensible triage and residual risk
Least-privilege definition 24 6 30 non-root, caps dropped, NNP, read-only, narrow mounts/networks/resources
Positive/negative verification and recovery 20 5 25 contract passes, controls deny, secrets clean, rollback and peer challenge
Total 80 20 100

Any Docker-socket mount, privileged/host mode, host-root mount, public DB/API, embedded credential or fabricated scan is a core failure and is rejected before dynamic grading. Depth is zero for a criterion whose core gate fails.

Continue to TD6.

Open full-resolution image · pinch to zoom ↗