DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    DockerLesson 11 of 14
    Your progress · 0%
    On this page
    1. Four layers
    2. Image scanning
    3. Non-root users
    4. Runtime security
    5. Secrets
    6. Reduce the container's attack surface
    ↖ Course roadmap
    Lesson 11 · Security

    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.

    35 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 5 complete

    Four 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:

    LayerQuestionTool
    the imagedoes it contain known-vulnerable software?a scanner: Grype, Trivy, Docker Scout
    the userif the app is taken over, is the attacker root?USER, --user
    the runtimewhat can the process do to the kernel?capabilities, no-new-privileges, read-only, limits
    the secretscan 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.tar
    • dockerbuild and run containers
    • saveexport an image to a tar file
    • price-api:singlethe image
    • single.tarthe image
    docker run --rm -v "$PWD":/scan:ro anchore/grype:v0.119.0 docker-archive:/scan/single.tar
    • runstart a new container
    • --rmdelete the container when it exits
    • -v "$PWD":/scan:romount a volume or folder, source:path-inside
    • anchore/grype:v0.119.0the image to start
    • docker-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 id
    • dockerbuild and run containers
    • runstart a new container
    • --rmdelete the container when it exits
    • alpine:3.22the image to start
    • idthe 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:group
    • sh -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-ok
    • dockerbuild and run containers
    • runstart a new container
    • --rmdelete the container when it exits
    • alpine:3.22the image to start
    • chown nobody /etc/hostnamethe command run inside, instead of the image’s default
    • &&run the next command only if this one worked
    • echoprint 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 setting
    • grep -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 containers
    • runstart 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
    Flags that switch isolation off

    --privileged gives the container every capability and access to host devices. -v /var/run/docker.sock:/var/run/docker.sock lets it control Docker, which is control of the host. --pid host, --network host and mounting / remove whole namespaces. Each one needs a written reason, never "it made the error go away".

    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 containers
    • buildbuild 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 -1
    • historylist an image’s layers
    • leak:badthe image
    • --no-truncdon’t shorten long values
    • --format '{{.CreatedBy}}'print only these fields (Go template)
    • |pipe: send output to the next command
    • grepfind lines that match a pattern
    • -oprint only the matching part
    • headshow 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 containers
    • buildbuild 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 s3cr3t
    • historylist an image’s layers
    • leak:goodthe image
    • --no-truncdon’t shorten long values
    • |pipe: send output to the next command
    • grepfind lines that match a pattern
    • -ccount matching lines
    docker run --rm leak:good ls /run/secrets
    • runstart a new container
    • --rmdelete the container when it exits
    • leak:goodthe image to start
    • ls /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.

    In production

    Scan every image in CI and on a schedule, rebuild on patched bases, run as non-root with all capabilities dropped, a read-only filesystem and limits, and keep secrets in a secret manager. If a secret ever reached an image or a registry, rotate it: deleting the tag doesn't delete copies already pulled.

    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:multi
    • dockerbuild 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.

    ⌘
    Mini mission

    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.

    Input: Docker and the price-api:multi image from lesson 7Output: A healthy hardened container with CapEff 0, and docker history showing the leaked ARG but not the secret
    Reveal 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 containers
    • runstart 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/health
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker inspect api --format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}} {{.Config.User}}'
    • inspectshow every detail as JSON
    • apithe 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 s3cr3t
    • buildbuild 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 worked
    • historylist an image’s layers
    • leak:badthe image
    • --no-truncdon’t shorten long values
    • |pipe: send output to the next command
    • grepfind lines that match a pattern
    • -ccount matching lines
    docker rm -f api
    • rmdelete a container
    • -fstop it first if running
    • apithe 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    A scan reports a High CVE in libssl3 with a fixed version available. What is the usual fix?

    Go deeper

    Official reading for this lesson

    • DocsDocker Docs: Docker Engine securityDocker Docs ↗
    • DocsDocker Docs: build secretsDocker Docs ↗
    • DocsOWASP Docker Security Cheat SheetOWASP ↗
    • ToolGrype vulnerability scannerAnchore ↗
    • ToolTrivy vulnerability scannerAqua Security ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousNetworking: bridge networks, published ports and container DNSNext lesson →The Docker CLI day to day: inspect, filter, format and clean up