Container security: scanning, non-root, runtime limits and secrets
Harden containers in four layers: scan images for vulnerabilities, run as non-root, drop capabilities, and keep secrets out of images, history and environment variables.
By the end of this lesson you'll be able to
0 of 5 completeFour layers
A container is isolated by namespaces and cgroups (lesson 2), but it shares the host's kernel. Security is about making each layer harder to break:
| Layer | Question | Tool |
|---|---|---|
| the image | does it contain known-vulnerable software? | a scanner: Grype, Trivy, Docker Scout |
| the user | if the app is taken over, is the attacker root? | USER, --user |
| the runtime | what can the process do to the kernel? | capabilities, no-new-privileges, read-only, limits |
| the secrets | can anyone read passwords from the image? | secret mounts and files, never ARG or ENV |
Image scanning
A scanner lists the packages in an image and matches them against vulnerability databases. Here is Grype on the single-stage image from lesson 7, scanning a saved copy so the scanner never touches the Docker socket:
$ docker save price-api:single -o single.tar $ docker run --rm -v "$PWD":/scan:ro anchore/grype:v0.119.0 docker-archive:/scan/single.tar NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK libssl3 3.5.8-r0 apk CVE-2026-84784 High 0.4% (32nd) 0.3 zlib 1.3.2-r0 1.3.2-r1 apk CVE-2026-85091 High 0.4% (27th) 0.3 libcrypto3 3.5.8-r0 apk CVE-2026-72897 High 0.3% (17th) 0.2 busybox 1.37.0-r31 apk CVE-2025-60876 Medium 0.3% (22nd) 0.2 …
docker save price-api:single -o single.tardockerbuild and run containerssaveexport an image to a tar fileprice-api:singlethe imagesingle.tarthe image
docker run --rm -v "$PWD":/scan:ro anchore/grype:v0.119.0 docker-archive:/scan/single.tarrunstart a new container--rmdelete the container when it exits-v "$PWD":/scan:romount a volume or folder, source:path-insideanchore/grype:v0.119.0the image to startdocker-archive:/scan/single.tarthe command run inside, instead of the image’s default
How to read it:
- Every finding here is in the base image (Alpine's OpenSSL, zlib, BusyBox), none in your code. A smaller base has fewer packages and fewer findings: run the same scan on the distroless image and compare.
- FIXED IN says a patched version exists: rebuild on an updated base (
docker build --pull) and rescan. Empty means no fix yet; decide whether the vulnerable code is reachable. - EPSS estimates how likely the CVE is to be exploited soon. Fix High and Critical with a fix first.
Scan in CI and fail the build on Critical findings that have a fix (grype --fail-on critical --only-fixed),
and rescan old images: new CVEs are published for software that hasn't changed.
Non-root users
By default the process in a container runs as root, the same user ID 0 as root on the host, held back only by namespaces and capabilities. Run as an ordinary user:
$ docker run --rm alpine:3.22 id uid=0(root) gid=0(root) groups=0(root),… $ docker run --rm --user 1000:1000 alpine:3.22 sh -c 'id; touch /etc/x' uid=1000 gid=1000 groups=1000 touch: /etc/x: Permission denied
docker run --rm alpine:3.22 iddockerbuild and run containersrunstart a new container--rmdelete the container when it exitsalpine:3.22the image to startidthe command run inside, instead of the image’s default
docker run --rm --user 1000:1000 alpine:3.22 sh -c 'id; touch /etc/x'--user 1000:1000run as this user:groupsh -c 'id; touch /etc/x'the command run inside, instead of the image’s default
Better, set it in the image so nobody forgets: USER nonroot in distroless, or in your own image:
RUN addgroup -S app && adduser -S -G app app USER app
A non-root process can't bind ports below 1024, so listen on 8080 and publish -p 80:8080.
Runtime security
Root inside a container gets a reduced set of Linux capabilities. Drop them all, and root can't even change a file's owner:
$ docker run --rm alpine:3.22 chown nobody /etc/hostname && echo chown-ok chown-ok $ docker run --rm --cap-drop ALL alpine:3.22 chown nobody /etc/hostname chown: /etc/hostname: Operation not permitted $ docker run --rm --cap-drop ALL --security-opt no-new-privileges alpine:3.22 grep -E 'NoNewPrivs|CapEff' /proc/self/status CapEff: 0000000000000000 NoNewPrivs: 1
docker run --rm alpine:3.22 chown nobody /etc/hostname && echo chown-okdockerbuild and run containersrunstart a new container--rmdelete the container when it exitsalpine:3.22the image to startchown nobody /etc/hostnamethe command run inside, instead of the image’s default&&run the next command only if this one workedechoprint text
docker run --rm --cap-drop ALL alpine:3.22 chown nobody /etc/hostname--cap-drop ALLremove Linux capabilities
docker run --rm --cap-drop ALL --security-opt no-new-privileges alpine:3.22 grep -E 'NoNewPrivs|CapEff' /proc/self/status--security-opt no-new-privilegesa security settinggrep -E 'NoNewPrivs|CapEff' /proc/self/statusthe command run inside, instead of the image’s default
CapEff 0 means no capabilities; NoNewPrivs 1 means a setuid binary can't raise privileges. A hardened
service combines them:
$ docker run -d --name api --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \ --read-only --tmpfs /tmp --memory 256m --pids-limit 100 -p 127.0.0.1:8080:8080 price-api:multi
docker run -d --name api --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \dockerbuild and run containersrunstart a new container-drun in the background--name apiname the container--user 65532:65532run as this user:group--cap-drop ALLremove Linux capabilities--security-opt no-new-privilegesa security setting\the image to start
Secrets
Anything in a layer, an ARG or an ENV can be read by anyone who has the image:
$ docker build --build-arg API_TOKEN=s3cr3t-token-123 -t leak:bad -f Dockerfile.bad . $ docker history leak:bad --no-trunc --format '{{.CreatedBy}}' | grep -o 'API_TOKEN=[^ ]*' | head -1 API_TOKEN=s3cr3t-token-123
docker build --build-arg API_TOKEN=s3cr3t-token-123 -t leak:bad -f Dockerfile.bad .dockerbuild and run containersbuildbuild an image from a Dockerfile--build-arg API_TOKEN=s3cr3t-token-123a build argument (visible in history)-t leak:badname and tag the image-fwhich Dockerfile.the build context: the files COPY can see
docker history leak:bad --no-trunc --format '{{.CreatedBy}}' | grep -o 'API_TOKEN=[^ ]*' | head -1historylist an image’s layersleak:badthe image--no-truncdon’t shorten long values--format '{{.CreatedBy}}'print only these fields (Go template)|pipe: send output to the next commandgrepfind lines that match a pattern-oprint only the matching partheadshow the first lines
For build time, use a BuildKit secret mount. The secret is a file only while that one RUN runs:
# syntax=docker/dockerfile:1 FROM alpine:3.22 RUN --mount=type=secret,id=api_token test -s /run/secrets/api_token && echo "token present at build time"
$ docker build --secret id=api_token,src=token.txt -t leak:good -f Dockerfile.good . #9 1.536 token present at build time $ docker history leak:good --no-trunc | grep -c s3cr3t 0 $ docker run --rm leak:good ls /run/secrets ls: /run/secrets: No such file or directory
docker build --secret id=api_token,src=token.txt -t leak:good -f Dockerfile.good .dockerbuild and run containersbuildbuild an image from a Dockerfile--secret id=api_token,src=token.txta secret available only during the build-t leak:goodname and tag the image-fwhich Dockerfile.the build context: the files COPY can see
docker history leak:good --no-trunc | grep -c s3cr3thistorylist an image’s layersleak:goodthe image--no-truncdon’t shorten long values|pipe: send output to the next commandgrepfind lines that match a pattern-ccount matching lines
docker run --rm leak:good ls /run/secretsrunstart a new container--rmdelete the container when it exitsleak:goodthe image to startls /run/secretsthe command run inside, instead of the image’s default
At run time, mount secrets as files: Compose secrets: (lesson 9), Docker Swarm secrets or Kubernetes
secrets, read by the app from /run/secrets/…. Many official images accept _FILE variables for this, like
POSTGRES_PASSWORD_FILE. And a .dockerignore keeps .env files out of COPY . . in the first place.
Reduce the container's attack surface
An SBOM lists installed components; a vulnerability scan prioritises fixes. Docker Scout can provide both, while Trivy or Grype are local alternatives. Signing with cosign only helps when verification is enforced at deployment.
$ docker scout sbom price-api:multi $ docker scout cves price-api:multi $ docker run --rm --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \ --read-only --tmpfs /tmp:rw,noexec,nosuid alpine:3.22 id
docker scout sbom price-api:multidockerbuild and run containers
docker run --rm --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \runstart a new container--rmdelete the container when it exits--user 65532:65532run as this user:group--cap-drop ALLremove Linux capabilities--security-opt no-new-privilegesa security setting\the image to start
Start with a non-root UID, all capabilities dropped, no-new-privileges and a read-only root. Seccomp and AppArmor
add syscall/profile boundaries; do not use --privileged to hide an unexplained failure.
Harden the price API
Run the price API with no capabilities, no privilege escalation, a read-only filesystem, a non-root user and limits; prove it still answers. Then prove a build argument leaks into history while a secret mount does not.
A healthy hardened container with CapEff 0, and docker history showing the leaked ARG but not the secretReveal one safe solution
$ docker run -d --name api --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 256m --pids-limit 100 -p 127.0.0.1:8090:8080 -e APP_VERSION=hardened price-api:multi $ curl -s localhost:8090/health {"status":"ok","version":"hardened"} $ docker inspect api --format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}} {{.Config.User}}' [ALL] true 65532:65532 $ printf 'FROM alpine:3.22\nARG API_TOKEN\nRUN test -n "$API_TOKEN"\n' > Dockerfile.bad $ docker build -q --build-arg API_TOKEN=s3cr3t -t leak:bad -f Dockerfile.bad . && docker history leak:bad --no-trunc | grep -c s3cr3t $ docker rm -f api
docker run -d --name api --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \dockerbuild and run containersrunstart a new container-drun in the background--name apiname the container--user 65532:65532run as this user:group--cap-drop ALLremove Linux capabilities--security-opt no-new-privilegesa security setting\the image to start
curl -s localhost:8090/healthcurlmake an HTTP request-ssilent: no progress bar
docker inspect api --format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}} {{.Config.User}}'inspectshow every detail as JSONapithe container or image--format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}} {{.Config.User}}'print only these fields (Go template)
printf 'FROM alpine:3.22\nARG API_TOKEN\nRUN test -n "$API_TOKEN"\n' > Dockerfile.bad>write output to a file, replacing it
docker build -q --build-arg API_TOKEN=s3cr3t -t leak:bad -f Dockerfile.bad . && docker history leak:bad --no-trunc | grep -c s3cr3tbuildbuild an image from a Dockerfile-qprint only the image ID--build-arg API_TOKEN=s3cr3ta build argument (visible in history)-t leak:badname and tag the image-fwhich Dockerfile.the build context: the files COPY can see&&run the next command only if this one workedhistorylist an image’s layersleak:badthe image--no-truncdon’t shorten long values|pipe: send output to the next commandgrepfind lines that match a pattern-ccount matching lines
docker rm -f apirmdelete a container-fstop it first if runningapithe container
The API needs none of the root powers it would get by default, and nothing breaks without them. That's the habit: start from nothing and add back only what fails for a reason you understand.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
A scan reports a High CVE in libssl3 with a fixed version available. What is the usual fix?
Official reading for this lesson
Or get every quiz answer right and it completes itself.