Contents
Docker Security Best Practices Tutorial: 10 Ways to Secure Containers
Containers provide some isolation, but they are not a security guarantee. Here are the basic protections you should apply from development through deployment, each with results from an actual run.
1. Don't run as root
$ docker run --rm alpine:3.23 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
$ docker run --rm -u 10001:10001 alpine:3.23 id
uid=10001 gid=10001 groups=10001
$ docker run --rm node:24-alpine id node
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
- By default, containers run as root (uid=0). If a vulnerable program is compromised, an attacker has the highest privileges inside the container and may be able to affect the host by exploiting other vulnerabilities.
-u 10001:10001specifies a user at runtime; you don't need to modify the image.- Many official images include a regular user, such as
node(uid 1000) in thenodeimage ornonrootin distroless images.
The best approach is to switch users in the Dockerfile:
RUN adduser -D -u 10001 app # Alpine
# RUN useradd --system --uid 10001 app # Debian / Ubuntu
USER app
See the Python application and Node.js application examples.
2. Use trusted base images and pin versions
- Prefer Docker Official Images (such as
python,node, andnginx) or images published by well-known organizations. - Don't use images from unknown sources, especially those with names that look like popular images (such as
ngnix). - Pin a version, such as
node:24-alpineor, more specifically,node:24.21-alpine, instead ofnodeorlatest. For a fully pinned version, add a digest:node:24-alpine@sha256:....
3. Keep images as small as possible
Every package in an image could have vulnerabilities. Use slim, alpine, distroless, or multi-stage builds to keep only what you need to run the application. The fewer unnecessary tools (compilers, curl, ssh), the fewer things attackers can exploit and the cleaner your vulnerability scan results will be.
4. Don't put secrets in images
Hands-on: secrets remain in images
The following Dockerfile makes three common mistakes:
FROM alpine:3.23
ARG NPM_TOKEN
ENV DB_PASSWORD=super-secret
COPY .env /app/.env
RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
CMD ["sh"]
The final line deletes .env after using it, which looks fine. Build and inspect the image:
$ docker build -q -f Dockerfile.bad --build-arg NPM_TOKEN=npm_abc123secret -t leaky .
sha256:c9523bb4d0484e3abf049d15dec57df3282ff8203574c03127a8dfc6ee37bf9b
$ docker run --rm leaky ls /app
$ docker inspect -f "{{.Config.Env}}" leaky
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=super-secret]
$ docker history --no-trunc --format "{{.CreatedBy}}" leaky | head -n 4
CMD ["sh"]
RUN |1 NPM_TOKEN=npm_abc123secret /bin/sh -c echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env # buildkit
COPY .env /app/.env # buildkit
ENV DB_PASSWORD=super-secret
ls /appis empty, so.envappears to have been deleted.- But
docker inspectdirectly shows theDB_PASSWORD=super-secretset byENV. docker historyshows theNPM_TOKEN=npm_abc123secretpassed at build time: the value ofARGis recorded in the layer where it is used.
What about .env? An image is made up of multiple layers, and rm only marks the file as "deleted" in a new layer; the contents in the COPY layer are still there. Export the image with docker save and find .env in its layers:
$ docker save leaky -o leaky.tar && mkdir x && tar -xf leaky.tar -C x && for l in x/blobs/sha256/*; do tar -tzf $l 2>/dev/null | grep -q "app/.env" && tar -xzOf $l app/.env; done; rm -rf x leaky.tar
API_KEY=sk-live-1234567890abcdef
The entire contents of .env were extracted. Anyone who can pull this image can do the same. Assume that everyone can see the contents of every layer in an image pushed to a registry.
Catch issues early with docker build --check
docker build --check uses variable names to determine whether ARG or ENV might contain secrets:
$ docker build --check -f Dockerfile.bad . 2>&1 | sed -n "/Check complete/,\$p"
Check complete, 2 warnings have been found!
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN")
Dockerfile.bad:2
--------------------
1 | FROM alpine:3.23
2 | >>> ARG NPM_TOKEN
3 | ENV DB_PASSWORD=super-secret
4 | COPY .env /app/.env
--------------------
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD")
Dockerfile.bad:3
--------------------
1 | FROM alpine:3.23
2 | ARG NPM_TOKEN
3 | >>> ENV DB_PASSWORD=super-secret
4 | COPY .env /app/.env
5 | RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
--------------------
It flagged NPM_TOKEN and DB_PASSWORD. But it only checks the names of ARG and ENV; it can't catch problems such as COPY .env copying a secret file into the image, so .dockerignore is still necessary.
The right approach
- Don't pass passwords or API keys with
ENVorARGin a Dockerfile. - Use
.dockerignoreto exclude.env, private keys, and.gitso they aren't copied in byCOPY . .. - Pass secrets needed at runtime using environment variables,
--env-file, or Compose secrets at runtime. See Environment variables and .env files. - For secrets needed at build time (such as a token for installing private packages), use BuildKit secret mounts:
FROM alpine:3.23
RUN --mount=type=secret,id=npm_token \
test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
CMD ["sh"]
$ NPM_TOKEN=npm_abc123secret docker build --progress plain --no-cache --secret id=npm_token,env=NPM_TOKEN -f Dockerfile.good -t safe . 2>&1 | grep "token length"
#5 [stage-0 2/2] RUN --mount=type=secret,id=npm_token test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
#5 0.261 token length: 16
$ docker history --no-trunc --format "{{.CreatedBy}}" safe | head -n 2
CMD ["sh"]
RUN /bin/sh -c test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)" # buildkit
$ docker run --rm safe ls /run/secrets
ls: /run/secrets: No such file or directory
- At build time,
RUNcan read the token from/run/secrets/npm_token(length 16). docker historycontains only the command itself, not the token value./run/secretsis not in the image either; the secret is mounted only while thatRUNinstruction executes.
5. Don't expose unnecessary ports
# Bad: the database is exposed to the world
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:18
# Good: only local connections are allowed
docker run -d -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=secret postgres:18
On Linux, Docker directly modifies iptables rules, so ports published with -p bypass firewall settings such as ufw and firewalld: external hosts can still connect even if ufw doesn't allow 5432. The safest approach is to avoid -p entirely for internal services such as databases and caches, and let only other containers connect over a Docker network. For example, in Nginx reverse proxy, only Nginx has ports.
6. Restrict container privileges
Read-only filesystem
$ docker run --rm --read-only alpine:3.23 touch /tmp/x
touch: /tmp/x: Read-only file system
$ docker run --rm --read-only --tmpfs /tmp alpine:3.23 sh -c "touch /tmp/x && echo ok"
ok
--read-only makes the container's filesystem read-only, so an attacker can't modify the application or write malicious files. For paths the application needs to write to (such as /tmp), mount a temporary filesystem in memory with --tmpfs, or mount a Volume.
Drop Linux capabilities
On Linux, root's privileges are divided into many capabilities, such as CHOWN (change file ownership) and NET_BIND_SERVICE (bind to ports below 1024). By default, Docker gives containers some capabilities:
$ docker run --rm alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
$ docker run --rm --cap-drop ALL alpine:3.23 chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted
$ docker run --rm --cap-drop ALL --cap-add CHOWN alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
- By default,
chowncan run. - After
--cap-drop ALLremoves all capabilities, even root getsOperation not permitted. - Use
--cap-addto add back only the capabilities you actually need.
Most web applications don't need any capabilities, so you can use --cap-drop ALL directly.
Prevent privilege escalation
$ docker run --rm alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs: 0
$ docker run --rm --security-opt no-new-privileges alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs: 1
NoNewPrivs: 1 means processes in the container can't gain higher privileges through setuid programs (such as sudo or su). It's recommended even when running as a regular user.
Put it all together:
docker run -d \
--user 10001:10001 \
--read-only --tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
myapp
In Compose:
services:
app:
image: myapp
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
Never use --privileged casually: it gives the container all capabilities and access to all host devices, effectively disabling nearly all isolation.
7. Limit resources
$ docker run --rm --pids-limit 50 alpine:3.23 sh -c "for i in \$(seq 100); do sleep 5 & done; wait" 2>&1 | sort | uniq -c
1 sh: can't fork: Resource temporarily unavailable
$ docker run -d --name limited --memory 512m --cpus 1 --pids-limit 200 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
2edbff0c81d73b83f0055b781d8d8ad20661b2b64ef1dfd5a6def334ef02723c
$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" limited
NAME CPU % MEM USAGE / LIMIT PIDS
limited 0.00% 3.406MiB / 512MiB 2
$ docker inspect -f "Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}" limited
Memory=536870912 NanoCpus=1000000000 PidsLimit=200
$ docker rm -f limited
limited
- With
--pids-limit 50, the container tries to create 100 processes. Once it hits the limit, it getscan't fork: Resource temporarily unavailable. This can prevent a fork bomb from bringing down the entire host. --memory 512m --cpus 1limits memory and CPU;LIMITindocker statsshows 512MiB. If a process exceeds the memory limit, the system terminates it (OOM) instead of letting it exhaust the host's memory.WARNING: Your kernel does not support swap limit capabilitiesmeans the host kernel doesn't have swap limits enabled. The memory limit still works, but swap can't be limited at the same time. You can ignore this warning.
In Compose:
services:
app:
image: myapp
mem_limit: 512m
cpus: 1
pids_limit: 200
8. Protect the Docker socket
/var/run/docker.sock is Docker's control interface. Anyone who can access it can create any container, including one that mounts the host's root directory. Here's a hands-on example that mounts the host's / into a container:
$ docker run --rm -v /:/host alpine:3.23 sh -c "head -n 2 /host/etc/os-release; ls /host/var/lib/docker | head -n 5"
NAME="Alpine Linux"
ID=alpine
buildkit
containerd
containers
engine-id
image
The container directly read the Docker host's /etc/os-release and /var/lib/docker (the test environment for this tutorial is Alpine Linux). The same method can read and write any file, including /etc/shadow and SSH keys. So:
- Being able to run
dockercommands is equivalent to having root privileges on the host. Adding a user to thedockergroup is equivalent to giving them root. - Don't casually mount
-v /var/run/docker.sock:/var/run/docker.sockinto a container unless you fully trust that image (for example, tools such as Traefik, Portainer, and Trivy that need it). - Don't expose the Docker daemon's TCP port (2375) to the network; that exposes root access to the host.
- If you need stronger isolation, look into Docker's rootless mode.
9. Scan images for vulnerabilities
The open-source tool Trivy scans operating system and programming language packages in images for known vulnerabilities. Run it in a container; no installation is needed:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx:1.30-alpine
Report Summary
┌───────────────────────────────────┬────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├───────────────────────────────────┼────────┼─────────────────┤
│ nginx:1.30-alpine (alpine 3.24.2) │ alpine │ 3 │
└───────────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
nginx:1.30-alpine (alpine 3.24.2)
=================================
Total: 3 (HIGH: 3, CRITICAL: 0)
┌──────────┬─────────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├──────────┼─────────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ libexpat │ CVE-2026-93990 │ HIGH │ fixed │ 2.8.4-r0 │ 2.8.5-r0 │ expat: Expat: XML Injection via Malformed UTF-16 Input │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-93990 │
├──────────┼─────────────────┤ │ ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ pcre2 │ CVE-2026-103111 │ │ │ 10.48-r0 │ 10.49-r0 │ pcre2: pcre2: Out-of-bounds write via crafted regular │
│ │ │ │ │ │ │ expression │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-103111 │
├──────────┼─────────────────┤ │ ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ tiff │ CVE-2026-4775 │ │ │ 4.7.1-r0 │ 4.7.2-r0 │ libtiff: libtiff: Arbitrary code execution or denial of │
│ │ │ │ │ │ │ service via signed integer... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-4775 │
└──────────┴─────────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────┘
--severity HIGH,CRITICALshows only high-severity and critical vulnerabilities;--quiethides download progress for the vulnerability database.- Mounting
docker.socklets Trivy read local images; thetrivy-cacheVolume stores the vulnerability database so it doesn't need to be downloaded again for the next scan. - The results show 3 HIGH vulnerabilities in the base Alpine image of
nginx:1.30-alpine. All haveStatusfixedand list aFixed Version: Alpine has released fixes, but this image wasn't built with them yet.
Scan results change over time: new vulnerabilities are discovered every day, and images are continually updated. Your results may look different when you run the scan.
Docker Desktop and newer CLI versions also include Docker Scout (docker scout quickview, docker scout cves), which you can use after signing in to a Docker account. It's a good idea to automate scans in your CI pipeline and block deployments when critical vulnerabilities are found.
10. Update regularly
Base images receive ongoing vulnerability fixes. Continuing from the scan above, upgrade packages in the Dockerfile and scan again:
$ cat Dockerfile
FROM nginx:1.30-alpine
RUN apk upgrade --no-cache
$ docker build -q -t nginx-patched .
sha256:c88ea56e7062e7703dccc5eb2fedea74c333f33982d9735753056a50858646f2
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx-patched
Report Summary
┌───────────────────────────────┬────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├───────────────────────────────┼────────┼─────────────────┤
│ nginx-patched (alpine 3.24.2) │ alpine │ 0 │
└───────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
After apk upgrade upgrades the vulnerable packages to fixed versions, the number of HIGH and CRITICAL vulnerabilities drops to 0.
In practice, a better approach is to rebuild regularly with docker build --pull (--pull downloads the latest version of the base image first) and redeploy. Tools such as Dependabot and Renovate can automatically alert you to new base image versions. Images built once and left unupdated for years will only accumulate more vulnerabilities.
Checklist
- The Dockerfile has
USER; the container doesn't run as root - The base image is from a trusted source and its version is pinned
- A minimal image or multi-stage build is used
- The image contains no passwords, keys, or
.env(checkdocker historyanddocker inspect) - Build-time secrets use
RUN --mount=type=secret - Internal services such as databases don't expose ports externally
- Use
--read-only,--cap-drop ALL, andno-new-privilegesas appropriate -
--privilegedisn't used and docker.sock isn't mounted indiscriminately - Memory, CPU, and PID limits are set
- Image vulnerability scans run in CI
- Rebuild regularly with
--pullto get security updates
Recap
- Add
USERto the Dockerfile and run as non-root. ARG,ENV, and deleted files all remain in the image; use--secretfor build-time secrets.- Don't use
-pfor services that don't need external access;-pbypasses ufw. - Access to docker.sock is equivalent to host root.
- Scan regularly and rebuild with
--pull.
Exercises
Exercise 1: Use docker history --no-trunc to inspect an image you built. Does it show any passwords or tokens?
Answer / Hint
Check the ARG, ENV, and RUN instructions in the CREATED BY column, as well as docker inspect -f "{{json .Config.Env}}" image. If you find any, switch to --secret or runtime environment variables, and revoke any exposed keys.
FAQ
Is root inside a container the same as root on the host?
In the default mode, it has the same uid, but is restricted by namespaces, capabilities, and seccomp. To make it different from host root, use [Rootless mode](/docker/rootless-podman/).
What should I do if an image scan finds lots of vulnerabilities?
Start with high-risk vulnerabilities that have a Fixed Version: rebuild (docker build --pull) or upgrade the package. For those without a fixed version, assess whether you actually need that package.
What's the difference between Docker Scout and Trivy?
Both are vulnerability scanners. Scout is integrated into Docker Desktop and requires sign-in; Trivy is open source and can run in a container.
Updated October 14, 2026