An image that only exists on your laptop is not deployable. Getting it somewhere a cluster can pull it — and being able to prove which bytes you deployed, what is inside them, and who built them — is the subject of this post. It is also where container practice stops being about convenience and starts being about security: a registry is a distribution channel, and anything you pull runs on your infrastructure. This post covers how a registry actually stores things, why tags lie and digests do not, how to publish to Docker Hub, GHCR, and ECR, how to scan for vulnerabilities without drowning in CVE noise, what SBOMs and signatures buy you, how to harden a container at run time, and the secret-leak patterns that survive inside image layers forever.

Series map: 00 Containers from First Principles → 01 Images and Dockerfiles → 02 Networking, Volumes and Compose → 03 Registries, Supply Chain and Security (this post) → 04 Kubernetes Architecture and Core Objects → 05 Config, Storage and Stateful Workloads → 06 Scheduling, Resources, Probes and Rollouts → 07 Ingress, Observability, RBAC and GitOps.


Learning Outcome

  • Explain a registry in terms of manifests, blobs, and content-addressable digests, and inspect them directly.
  • Apply the rule "tags for humans, digests for machines", and read a multi-arch index.
  • Push to Docker Hub, GHCR, and Amazon ECR, and authenticate to a private registry.
  • Scan images with Trivy or Grype, triage the output, and apply the base-image strategy that actually reduces CVE counts.
  • Generate an SBOM and attach provenance and signatures with cosign / Sigstore keyless signing.
  • Harden a container at run time: non-root, read-only root filesystem, dropped capabilities, no --privileged.
  • Keep secrets out of image layers, and set a retention policy that stops a registry growing forever.

Chapter 1 — How a registry actually works

Terminology

  • Registry — an HTTP service implementing the OCI distribution spec (Docker Hub, GHCR, ECR, Harbor, Artifactory, or registry:2 run yourself).
  • Repository — a named collection of images inside a registry: ghcr.io/acme/api.
  • Blob — an immutable content-addressed object: a layer tar or a config JSON, stored under its sha256: digest.
  • Manifest — a JSON document listing one image's config blob and layer blobs, for one platform.
  • Index (manifest list) — a JSON document listing several manifests, one per platform, plus optional attestations.
  • Tag — a mutable, human-readable pointer to a manifest or index within a repository.
  • Digest — the SHA-256 of the manifest or blob bytes. Immutable by construction.

The data model

1
2
3
4
5
6
7
8
9
registry
└── repository ghcr.io/acme/api
├── tag "1.4.0" ──┐
├── tag "latest" ──┼──► index (sha256:aaa…)
│ │ ├── manifest linux/amd64 (sha256:bbb…) ─► config blob + layer blobs
│ │ ├── manifest linux/arm64 (sha256:ccc…) ─► config blob + layer blobs
│ │ └── attestation manifests (SBOM, provenance)
└── blobs/ sha256:… (layers and configs, shared across every tag that references them)

Everything below the tag is content-addressed. If two images share a base layer, the registry stores that blob once; a push only uploads blobs the registry does not already have, which is why the second push of a rebuilt image is usually fast.

The wire protocol, briefly

The distribution spec is a small REST API rooted at /v2/:

Operation Request
Check API / auth GET /v2/
List tags GET /v2/<name>/tags/list
Fetch manifest GET /v2/<name>/manifests/<tag-or-digest>
Fetch a blob GET /v2/<name>/blobs/sha256:…
Start an upload POST /v2/<name>/blobs/uploads/
Publish PUT /v2/<name>/manifests/<tag>

Run one locally and look at it:

1
2
3
4
5
6
7
8
9
10
docker run -d -p 5000:5000 --name reg registry:2
docker pull alpine:3.20
docker tag alpine:3.20 localhost:5000/alpine:3.20
docker push localhost:5000/alpine:3.20

curl -s localhost:5000/v2/_catalog
curl -s localhost:5000/v2/alpine/tags/list
curl -sI -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
localhost:5000/v2/alpine/manifests/3.20 | grep -i docker-content-digest

That Docker-Content-Digest header is the digest of the manifest — the identity you should be deploying by.

Higher-level tools do the same without curl:

1
2
3
4
5
docker buildx imagetools inspect alpine:3.20            # index and per-platform manifests
docker buildx imagetools inspect --raw alpine:3.20 # the raw JSON
crane manifest alpine:3.20 | python3 -m json.tool # google/go-containerregistry
skopeo inspect docker://alpine:3.20 # inspect without pulling

Trick: crane and skopeo talk to registries without a container runtime. skopeo copy docker://src docker://dst moves an image between registries without pulling it to disk — the cleanest way to mirror images into an air-gapped environment.


Chapter 2 — Tags lie, digests do not

A tag is a mutable pointer. Anyone with push access can move 1.4.0, and upstream maintainers routinely move latest, 3.12, and even patch tags to new builds. Two machines pulling myapp:1.4.0 an hour apart can therefore run different code, with no error anywhere.

A digest is the hash of the manifest bytes. myapp@sha256:9f2e… is either the exact image you tested or a failed pull. There is no third outcome.

1
2
3
4
5
6
docker pull ghcr.io/acme/api:1.4.0
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/acme/api:1.4.0
# ghcr.io/acme/api@sha256:9f2e...

docker pull ghcr.io/acme/api@sha256:9f2e... # exactly those bytes, forever

The rule: tags for humans, digests for machines.

Context Reference by
A person reading a README or docker run in a tutorial tag
FROM in a Dockerfile you must rebuild identically tag + digest
A Kubernetes manifest or Compose file in production digest (or a tag pinned by your GitOps tooling, Post 07)
A rollback the previous digest — tags may have moved
An audit trail ("what was running at 03:00?") digest, recorded at deploy time

Practical middle ground: keep readable tags in the repository and record the digest at deploy time. Kubernetes writes the resolved digest into the Pod status (imageID), and an admission policy can require digest references outright (Post 07).

Common pitfall: imagePullPolicy: IfNotPresent with a moving tag means nodes keep whatever they cached first. Half the fleet runs the old bytes, half the new, and both report the same tag. Digests remove the class of bug entirely.

Multi-arch images and the index

A modern public image tag usually resolves to an index rather than a manifest. The client picks the manifest matching its platform.

1
2
3
4
5
6
7
docker buildx imagetools inspect alpine:3.20
# Name: docker.io/library/alpine:3.20
# MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:...
# Manifests:
# Platform: linux/amd64 Digest: sha256:...
# Platform: linux/arm64 Digest: sha256:... (and the other supported platforms)

Build one yourself (Post 01 covered buildx):

1
2
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/api:1.4.0 --push .

Two consequences worth remembering:

  1. docker pull image@sha256:… of an index digest still works on every architecture — the index digest is platform-independent, while a per-platform manifest digest is not. Pin the index digest.
  2. Build attestations (SBOM, provenance) are stored as extra manifests inside that same index, which is how cosign and docker buildx imagetools inspect find them later.

Chapter 3 — Publishing: Docker Hub, GHCR, ECR

Authentication

docker login writes to ~/.docker/config.json. By default it stores a base64 (not encrypted) auth string, so on a shared machine configure a credential helper (docker-credential-osxkeychain, -secretservice, -wincred, or the cloud helpers).

1
2
3
4
docker login                          # Docker Hub
docker login ghcr.io -u <user> --password-stdin < token.txt
cat ~/.docker/config.json # check what actually got stored; docker logout to clear

Docker Hub

1
2
3
docker tag myapp:1.4.0 <dockerhub-user>/myapp:1.4.0
docker push <dockerhub-user>/myapp:1.4.0

Docker Hub applies pull-rate limits to anonymous and free-tier pulls, counted per IP for anonymous clients — a real problem for CI runners and Kubernetes nodes behind shared NAT. Authenticate your CI, or mirror the images you depend on into your own registry (skopeo copy, or a pull-through cache). Check the current published limits rather than assuming a number; they have changed several times.

GitHub Container Registry (GHCR)

1
2
3
4
echo "$CR_PAT" | docker login ghcr.io -u <github-user> --password-stdin
docker tag myapp:1.4.0 ghcr.io/<org>/myapp:1.4.0
docker push ghcr.io/<org>/myapp:1.4.0

Inside GitHub Actions, use the built-in token instead of a PAT:

1
2
3
4
5
6
7
8
9
10
11
12
permissions:
contents: read
packages: write # required to push to GHCR
id-token: write # required for cosign keyless signing (Chapter 5)

steps:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

Set LABEL org.opencontainers.image.source=https://github.com/<org>/<repo> in the Dockerfile so the package page links back to the repository and inherits its visibility settings.

Amazon ECR

ECR issues a short-lived token rather than accepting a long-lived password. The AWS series covers the IAM model this depends on (AWS Course Post 00) and the Terraform that provisions repositories (AWS Course Post 08).

1
2
3
4
5
6
7
8
9
10
ACCOUNT=123456789012; REGION=ap-east-1
aws ecr create-repository --repository-name myapp --region $REGION \
--image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true

aws ecr get-login-password --region $REGION \
| docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com

docker tag myapp:1.4.0 $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/myapp:1.4.0
docker push $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/myapp:1.4.0

Three ECR specifics worth knowing: the repository must exist before the first push; IMMUTABLE tag mutability makes a tag un-reusable, which removes the "someone moved 1.4.0" failure mode at the registry level; and the login token expires (12 hours), so CI must fetch it each run rather than caching it.

Private registry auth from a cluster

Nodes need credentials of their own — your laptop's docker login is irrelevant to them. In Kubernetes that is an image pull secret:

1
2
3
kubectl create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=<user> --docker-password=<token>

referenced from a Pod spec (or attached to a ServiceAccount) as imagePullSecrets. Post 05 covers Secrets properly; on managed clusters, prefer the cloud-native path — an IAM role attached to the node or workload for ECR, workload identity for the other clouds — so no long-lived registry password exists at all.


Chapter 4 — Vulnerability scanning without drowning

Terminology

  • CVE — a public identifier for one vulnerability (CVE-2025-12345).
  • Scanner — a tool that inventories the packages in an image and matches them against vulnerability databases.
  • Fixed / unfixed — whether the distribution has published a patched version yet.
  • VEX — a machine-readable statement that a given CVE is not exploitable in your product, so scanners can suppress it with a documented reason.

Running a scan

1
2
3
4
5
6
7
8
9
10
11
12
# Trivy
trivy image myapp:1.4.0
trivy image --severity HIGH,CRITICAL --ignore-unfixed myapp:1.4.0
trivy image --exit-code 1 --severity CRITICAL myapp:1.4.0 # gate a CI pipeline
trivy image --format json -o report.json myapp:1.4.0
trivy fs . # scan source and lockfiles

# Grype (Anchore)
grype myapp:1.4.0
grype myapp:1.4.0 --fail-on high
grype sbom:./sbom.spdx.json # scan an SBOM instead of an image

Registries also scan: ECR (scanOnPush, with the deeper Amazon Inspector integration), GHCR/Dependabot for some ecosystems, Docker Scout for Hub.

Reading the output without drowning

A first scan of a python:3.12 image can report hundreds of findings. Most of them are not action items. Triage in this order:

  1. Severity and fix availability first. --severity HIGH,CRITICAL --ignore-unfixed collapses the list to what you can actually act on today.
  2. Is the vulnerable component reachable? A CVE in a CLI tool your service never invokes is real but low-priority; a CVE in your TLS library is not.
  3. Is it in the base image or in your dependencies? Base-image CVEs are fixed by rebuilding on a newer base; dependency CVEs are fixed by bumping a lockfile.
  4. Suppress with a reason, never silently. .trivyignore (with an expiry date and a comment) or a VEX document records why something was accepted.
  5. Gate on a policy you can hold to. "No CRITICAL with a fix available" is enforceable; "zero CVEs" is not, and a gate everyone bypasses protects nothing.
1
2
3
# .trivyignore — every line needs a reason and a review date
CVE-2025-11111 # not reachable: /usr/bin/git is not invoked at runtime. Review 2026-09-01.

The base-image strategy that actually reduces CVE count

Scanning does not fix anything. What reduces findings, in order of effect:

Action Effect
Rebuild regularly on a fresh base most OS-package CVEs disappear because the base was patched upstream
Ship a smaller base (slim, distroless, scratch) fewer packages, fewer CVEs, less to patch — often a large drop
Remove build tooling from the runtime stage (Post 01) compilers and headers stop counting toward your attack surface
Automate base bumps (Renovate/Dependabot on the FROM digest) the pin stays current without a human remembering
Track your own dependency lockfiles application-level CVEs, which no base change fixes

Trick: a weekly scheduled rebuild-and-scan of the unchanged source is one of the highest-value pipelines you can add. Your code did not change; your base image did, and so did the vulnerability database.


Chapter 5 — SBOMs, provenance, and signing

SBOM

A Software Bill of Materials lists every component in an artifact — OS packages, language dependencies, versions, licences. Two mainstream formats: SPDX (ISO standard, licence-focused) and CycloneDX (OWASP, security-focused). Either is fine; pick one and be consistent.

1
2
3
4
5
6
7
8
syft myapp:1.4.0 -o spdx-json=sbom.spdx.json
syft myapp:1.4.0 -o cyclonedx-json=sbom.cdx.json
trivy image --format cyclonedx -o sbom.cdx.json myapp:1.4.0

# BuildKit can attach SBOM and provenance as attestations at build time
docker buildx build --sbom=true --provenance=mode=max -t ghcr.io/acme/api:1.4.0 --push .
docker buildx imagetools inspect ghcr.io/acme/api:1.4.0 --format '{{json .SBOM}}' | head

Why it matters: when the next widely-exploited library CVE lands, the question "are we affected, and where?" becomes a query over stored SBOMs instead of a week of archaeology. It is also increasingly a procurement and compliance requirement.

Provenance

Provenance answers how the artifact was built: which source commit, which builder, which parameters. BuildKit emits it as an in-toto attestation (--provenance=mode=max), and the SLSA framework defines levels for how tamper-resistant that claim is. Provenance produced by your CI, signed by your CI's identity, is what lets a policy say "only images built by this pipeline from this repository may run".

Signing with cosign

Classic key-based signing works, but key management is the hard part. Keyless signing (Sigstore) removes the long-lived key: your CI job authenticates with an OIDC token, Fulcio issues a short-lived certificate bound to that identity, the signature is recorded in the Rekor transparency log, and the certificate expires minutes later.

Keyless signing in five steps (OIDC token, Fulcio certificate, cosign sign by digest, Rekor log, certificate expiry), then the cosign verify command with its identity and issuer flags, a warning that verify without identity flags is theatre, and enforcement at admission time.

1
2
3
4
5
6
7
8
9
10
11
12
# sign by digest, never by tag — a tag can move after signing
cosign sign --yes ghcr.io/acme/api@sha256:9f2e...

# verify, asserting WHO signed it and via which OIDC issuer
cosign verify \
--certificate-identity-regexp '^https://github.com/acme/api/\.github/workflows/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/acme/api@sha256:9f2e... | python3 -m json.tool

# attach an SBOM as a signed attestation
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json ghcr.io/acme/api@sha256:9f2e...

Watch out: cosign verify with no identity flags checks only that a signature exists. The security comes from asserting the expected identity and issuer. A verification step that accepts any signer is theatre.

Enforcement belongs at admission time in the cluster — an admission controller (Kyverno, Sigstore Policy Controller, Gatekeeper) that rejects Pods whose images are unsigned, unscanned, or not from an allowed registry. That is Post 07 material; the point here is that signing is only useful if something checks it.


Chapter 6 — Least-privilege runtime hardening

Post 00 described the sandbox layers. These are the switches that use them.

1
2
3
4
5
6
7
8
9
10
docker run -d --name api \
--user 10001:10001 \
--read-only \
--tmpfs /tmp:rw,size=64m,mode=1777 \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--pids-limit 256 \
--memory 512m --cpus 1.0 \
-p 8080:8080 ghcr.io/acme/api@sha256:9f2e...
Control Flag Why
Non-root user --user 10001:10001 (and USER in the image) a container escape starts from an unprivileged UID
Read-only root filesystem --read-only + --tmpfs for writable paths attackers cannot drop tools or modify binaries
Drop capabilities --cap-drop=ALL --cap-add=… most apps need none; add back only what fails
No privilege escalation --security-opt no-new-privileges:true blocks setuid binaries from regaining privileges
Keep the default seccomp/AppArmor profiles (default — do not disable) blocks ~40 dangerous syscalls at no measurable cost
Never --privileged — it disables essentially all of the above
Never mount the Docker socket — /var/run/docker.sock in a container ≈ root on the host
Resource limits --memory, --cpus, --pids-limit a compromised or buggy container cannot starve the host

Finding the minimum capability set is empirical: start from --cap-drop=ALL, run your test suite, and add back only the capability each failure names. Most web services need none; something binding port 80 needs NET_BIND_SERVICE (or just listen on 8080 and let the load balancer map it).

Every one of these has a Kubernetes equivalent in a Pod's securityContext — runAsNonRoot, runAsUser, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: [ALL], seccompProfile.type: RuntimeDefault — enforced cluster-wide by Pod Security Admission. Posts 05 and 07 cover them.


Chapter 7 — Secrets and image lifecycle

The layer-history leak

Layers are additive (Post 01). A secret added in one instruction and deleted in the next is still in the earlier layer, and anyone who can pull the image can read it. Prove it:

1
2
3
4
5
# BROKEN — do not do this
FROM alpine:3.20
COPY .npmrc /root/.npmrc
RUN cat /root/.npmrc > /dev/null && rm /root/.npmrc # "cleaned up"
1
2
3
4
5
docker build -t leaky .
docker history --no-trunc leaky
docker save leaky -o leaky.tar && mkdir -p x && tar -xf leaky.tar -C x
grep -ra 'authToken' x | head # the token is still there, in the COPY layer

The same applies to ARG SECRET=… (visible in docker history), to ENV TOKEN=… (visible in docker inspect and in the environment of every process), and to a .env file swept into the image by COPY . . with no .dockerignore.

Doing it correctly

1
2
3
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
1
2
3
docker build --secret id=npmrc,src=$HOME/.npmrc -t safe .
docker history --no-trunc safe | grep -i authToken # nothing
Need Correct mechanism
Credential during build BuildKit --mount=type=secret (or an SSH agent mount for private Git)
Credential at run time environment variable injected by the orchestrator, or a mounted file
Credential in Kubernetes Secret → env or volume; better, an external secret store via CSI or the External Secrets Operator (Post 05)
Registry credential for a cluster imagePullSecrets, or cloud IAM with no stored password
Never ENV, ARG, a committed .env, or a file baked into a layer

ENV is not a secret store, even at run time: environment variables show up in docker inspect, in crash dumps, in /proc/<pid>/environ, and in many logging and APM agents' captured context. Files mounted at a path with restrictive permissions leak less.

Image lifecycle and retention

Registries grow without bound unless told otherwise. Every CI run of every branch pushes a tag; six months later the repository is thousands of images and a large bill.

A workable policy:

  • Immutable release tags (1.4.0) — never reused. ECR enforces this with IMMUTABLE; other registries have equivalents.
  • Semantic tags for humans, digests for deploys, as in Chapter 2.
  • Expire ephemeral tags: PR/branch/commit-SHA tags after 14–30 days.
  • Keep the last N releases (say 10) plus anything currently deployed, forever.
  • Delete untagged manifests older than a few days — these accumulate every time a tag moves.
  • Verify nothing running references what you delete before enabling any rule; a deleted digest breaks a Pod restart, not just a new deploy.
1
2
3
4
5
6
7
8
{ "rules": [ {
"rulePriority": 1,
"description": "Expire untagged images after 14 days",
"selection": { "tagStatus": "untagged", "countType": "sinceImagePushed",
"countUnit": "days", "countNumber": 14 },
"action": { "type": "expire" }
} ] }

(An ECR lifecycle policy; GHCR and Harbor express the same ideas through retention rules in their own UI/API.)

A checklist you can apply to your own image today

Run these against one image you own and record the result. Every step is reproducible and takes minutes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
IMG=ghcr.io/acme/api:1.4.0

# 1. identity: resolve the tag to a digest and use the digest from here on
docker pull "$IMG" && DIG=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMG") && echo "$DIG"

# 2. does it run as root?
docker run --rm --entrypoint id "$DIG" # expect uid != 0

# 3. does it need a writable root filesystem?
docker run --rm --read-only --tmpfs /tmp "$DIG" --version || echo "needs writable paths"

# 4. what is inside it, and what is vulnerable?
syft "$DIG" -o spdx-json=sbom.spdx.json
trivy image --severity HIGH,CRITICAL --ignore-unfixed "$DIG"

# 5. does it leak a secret in its history or layers?
docker history --no-trunc "$DIG" | grep -Ei 'token|password|secret|key' || echo "history clean"

# 6. is it multi-arch, and is the base current?
docker buildx imagetools inspect "$IMG"
# 7. is it signed, and by whom?
cosign verify --certificate-identity-regexp '^https://github.com/acme/api/\.github/workflows/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$DIG" || echo "unsigned"

Anything that fails is a concrete ticket: pin the digest, add a USER, drop the writable rootfs, bump the base, remove the secret, add signing.


Practice Exercises

Exercise 1 — Run your own registry

Start registry:2 locally, push an image to it, and use curl to list the tags and read the manifest digest. Then pull the image back by digest instead of tag.

Solution
1
2
3
4
5
6
7
8
9
docker run -d -p 5000:5000 --name reg registry:2
docker pull alpine:3.20 && docker tag alpine:3.20 localhost:5000/alpine:3.20
docker push localhost:5000/alpine:3.20
curl -s localhost:5000/v2/alpine/tags/list
D=$(curl -sI -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
localhost:5000/v2/alpine/manifests/3.20 | awk '/[Dd]ocker-[Cc]ontent-[Dd]igest/{print $2}' | tr -d '\r')
docker pull "localhost:5000/alpine@$D"
docker rm -f reg

Exercise 2 — Prove a tag can move

Push demo:1.0 built from one Dockerfile, record its digest, rebuild demo:1.0 from a different Dockerfile, push again, and show that the tag now resolves to a different digest while the old digest is still pullable.

Solution
1
2
3
4
5
6
7
8
echo -e 'FROM alpine:3.20\nRUN echo v1 > /v' > Dockerfile && docker build -t localhost:5000/demo:1.0 .
docker push localhost:5000/demo:1.0
crane digest localhost:5000/demo:1.0 # digest A
echo -e 'FROM alpine:3.20\nRUN echo v2 > /v' > Dockerfile && docker build -t localhost:5000/demo:1.0 .
docker push localhost:5000/demo:1.0
crane digest localhost:5000/demo:1.0 # digest B — same tag, different bytes
docker run --rm localhost:5000/demo@<digest A> cat /v # still v1

Exercise 3 — Extract a leaked secret

Build the "BROKEN" Dockerfile from Chapter 7 with a fake token file, then recover the token from the saved image without running the container. Rebuild with a BuildKit secret mount and show the token is gone.

Solution
1
2
3
4
5
6
7
8
9
echo '//registry.example/:_authToken=hunter2' > .npmrc
docker build -t leaky . && docker save leaky -o leaky.tar
mkdir -p x && tar -xf leaky.tar -C x && grep -ra hunter2 x | head # found

# fixed version
docker build --secret id=npmrc,src=.npmrc -t safe -f Dockerfile.safe .
docker save safe -o safe.tar && mkdir -p y && tar -xf safe.tar -C y
grep -ra hunter2 y | head || echo "clean"

Exercise 4 — Scan and triage

Scan python:3.12 and python:3.12-slim with Trivy. Record the total findings and the HIGH/CRITICAL-with-fix counts for each. Then scan your own application image and write a one-paragraph triage: what you would fix this week, what you would suppress with a reason, and why.

Exercise 5 — Harden a container

Take any image you run and add, one flag at a time: --user, --read-only (plus the --tmpfs paths it turns out to need), --cap-drop=ALL, and --security-opt no-new-privileges:true. Record which flag first breaks the app and what the minimum capability set turned out to be.

Exercise 6 — SBOM and signature

Generate an SBOM for one of your images in both SPDX and CycloneDX form, and check whether the same image (or a public one you depend on) carries a cosign signature. If you have a GitHub Actions pipeline, add keyless signing of the pushed digest and verify it locally with an identity assertion.


Cheat-sheet

What How
Resolve a tag to a digest docker inspect --format '{{index .RepoDigests 0}}' img:tag
Inspect an index / manifest docker buildx imagetools inspect [--raw] img:tag
Registry-only inspection crane manifest img:tag, skopeo inspect docker://img:tag
Mirror between registries skopeo copy docker://src docker://dst
Local registry docker run -d -p 5000:5000 registry:2
Login docker login [registry] -u USER --password-stdin < token
ECR login aws ecr get-login-password --region R | docker login --username AWS --password-stdin <acct>.dkr.ecr.R.amazonaws.com
Cluster pull credentials kubectl create secret docker-registry … → imagePullSecrets
Multi-arch push docker buildx build --platform linux/amd64,linux/arm64 --push .
Scan (gate CI) trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 img
Scan an SBOM grype sbom:./sbom.spdx.json
Generate an SBOM syft img -o spdx-json=sbom.spdx.json
Build with attestations docker buildx build --sbom=true --provenance=mode=max --push .
Sign (keyless) cosign sign --yes repo@sha256:…
Verify with identity cosign verify --certificate-identity-regexp … --certificate-oidc-issuer … repo@sha256:…
Hardened run --user 10001:10001 --read-only --tmpfs /tmp --cap-drop=ALL --security-opt no-new-privileges:true
Build secret RUN --mount=type=secret,id=x … + --secret id=x,src=…
Find a leaked secret docker history --no-trunc img; docker save img | tar -x -O | grep -a …
Retention immutable release tags; expire untagged and ephemeral tags; keep last N

What's next

The image is now built, published, scanned, signed, and hardened. Post 04 moves to running it at scale: Kubernetes Architecture and Core Objects — the control plane (API server, etcd, scheduler, controller manager) and the node components (kubelet, kube-proxy, container runtime via CRI), the declarative reconciliation loop that makes the whole system work, and the core objects you will use every day: Pods, ReplicaSets, Deployments, Services, and Namespaces.


References

Note: this post is in progress. Planned additions: a full GitHub Actions workflow that builds, scans, SBOMs, signs, and pushes by digest; a Kyverno admission policy that enforces signature verification; notes on pull-through caches and air-gapped mirroring.