Skip to main content

Docker Security — Image Scanning, Non-Root Users, and Secrets

· 8 min read
Goel Academy
DevOps & Cloud Learning Hub

A container is not a security boundary. It shares the host kernel, and a misconfigured container running as root with all capabilities is one exploit away from full host compromise. The good news is that Docker gives you layers of defense — most people just never enable them.

The Container Security Stack

Security in Docker is not a single switch. It is layers that stack on top of each other, and you want as many layers as possible between an attacker and your host.

LayerWhat It ProtectsDocker Feature
Image contentsNo known CVEsImage scanning
User privilegesNo root accessUSER directive
FilesystemNo tampering--read-only
System callsKernel surface areaseccomp profiles
Mandatory accessFile/network policiesAppArmor/SELinux
CapabilitiesGranular permissions--cap-drop / --cap-add
SecretsCredentials exposureDocker secrets
TrustImage authenticityDocker Content Trust

Running as Non-Root

By default, the process inside your container runs as root (UID 0). If an attacker exploits your application, they have root privileges inside the container — and with certain misconfigurations, they can escape to the host.

FROM node:20-alpine
WORKDIR /app

# Create a non-root user with a specific UID
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup

COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production

COPY --chown=appuser:appgroup . .

# Switch to non-root BEFORE CMD
USER appuser

CMD ["node", "server.js"]

Always use numeric UIDs in production — some security tools and Kubernetes policies validate UIDs, not usernames.

# Even better: use numeric UID
USER 1001:1001

Verify that your container actually runs as non-root:

docker run --rm myapp whoami
# appuser

docker run --rm myapp id
# uid=1001(appuser) gid=1001(appgroup) groups=1001(appgroup)

Read-Only Filesystem

If an attacker gets into your container, the first thing they do is download tools — curl a reverse shell, write a crypto miner to disk. A read-only filesystem stops this immediately.

# Run with read-only root filesystem
docker run -d --name api \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=100m \
--tmpfs /var/run:rw,noexec,nosuid \
myapp:latest

Your application might need to write to certain directories (temp files, PID files, logs). Use tmpfs mounts for those specific paths — they exist in memory only and disappear when the container stops.

# docker-compose.yml
services:
api:
image: myapp:latest
read_only: true
tmpfs:
- /tmp:size=100m
- /var/run

Image Scanning Tools

Image scanning finds known vulnerabilities (CVEs) in the packages installed in your image. Here is how the major tools compare.

ToolTypeCostCI IntegrationSpeedDatabase
Docker ScoutBuilt-inFree (limited)GitHub Actions, GitLabFastDocker advisory DB
TrivyOpen sourceFreeAll CI platformsVery fastNVD, GitHub Advisory
GrypeOpen sourceFreeAll CI platformsFastAnchore feeds
SnykCommercialFree tierAll CI platformsMediumSnyk Intel DB

Docker Scout

# Quick vulnerability overview
docker scout quickview myapp:latest

# Detailed CVE list
docker scout cves myapp:latest

# Compare two images
docker scout compare myapp:v2 --to myapp:v1

# Get recommendations for base image updates
docker scout recommendations myapp:latest

Trivy

Trivy is the most popular open-source scanner. It scans images, filesystems, git repos, and Kubernetes clusters.

# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Scan a local image
trivy image myapp:latest

# Only show HIGH and CRITICAL vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latest

# Output as JSON for CI parsing
trivy image --format json --output results.json myapp:latest

# Fail CI if critical CVEs found (exit code 1)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Scan a Dockerfile (misconfigurations)
trivy config Dockerfile

Grype

# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh

# Scan an image
grype myapp:latest

# Only critical and high
grype myapp:latest --only-fixed --fail-on high

Scanning in CI/CD

Here is a GitHub Actions workflow that blocks deployment if critical vulnerabilities are found:

# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]

jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build image
run: docker build -t myapp:${{ github.sha }} .

- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true

Docker Secrets vs Environment Variables

Environment variables are the most common way to pass configuration to containers. They are also the most common way to leak credentials.

# BAD — secrets visible in process listing, docker inspect, and logs
docker run -e DB_PASSWORD=supersecret myapp:latest

# Anyone with Docker access can see them
docker inspect myapp --format '{{json .Config.Env}}'
# ["DB_PASSWORD=supersecret", ...]

Docker Swarm secrets are mounted as files inside the container, never stored in environment variables or image layers.

# Create a secret
echo "supersecret" | docker secret create db_password -

# Use in a Swarm service
docker service create --name api \
--secret db_password \
myapp:latest

Inside the container, the secret is available at /run/secrets/db_password:

# In your application, read the file
cat /run/secrets/db_password
# supersecret

For Docker Compose (non-Swarm), use file-based secrets:

# docker-compose.yml
services:
api:
image: myapp:latest
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
db_password:
file: ./secrets/db_password.txt

For build-time secrets (like private NPM tokens), use BuildKit secret mounts:

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
CMD ["node", "server.js"]
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=.npmrc -t myapp .

The secret is available during the build step but never written to any image layer.

Dropping Capabilities

Linux capabilities split root privileges into granular permissions. By default, Docker containers get a subset, but even that is too much for most applications.

# Drop ALL capabilities, add back only what you need
docker run -d --name api \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
myapp:latest
CapabilityWhat It AllowsDrop It?
NET_RAWRaw sockets, pingYes, unless you need ping
SYS_ADMINMount, namespace opsAlways drop
NET_BIND_SERVICEBind to ports < 1024Keep if needed
CHOWNChange file ownershipUsually drop
SETUID/SETGIDChange user/groupDrop after USER switch
SYS_PTRACEDebug processesOnly for debugging
# See what capabilities a container has
docker run --rm myapp:latest cat /proc/1/status | grep Cap

# Decode capability bits (install libcap on host)
capsh --decode=00000000a80425fb

Seccomp Profiles

Seccomp (Secure Computing Mode) restricts which system calls a container can make. Docker ships with a default profile that blocks about 44 of the 300+ syscalls.

# Run with the default seccomp profile (already enabled)
docker run -d myapp:latest

# Run with a custom, stricter profile
docker run -d --security-opt seccomp=custom-profile.json myapp:latest

# Disable seccomp entirely (NEVER do this in production)
docker run -d --security-opt seccomp=unconfined myapp:latest

Generate a custom profile by logging which syscalls your app actually uses:

# Use strace to find syscalls (run in development)
strace -c -f -S name ./server 2>&1 | tail -20

# Or use OCI runtime with logging
docker run --security-opt seccomp=log-all.json myapp:latest

AppArmor Profiles

AppArmor provides mandatory access control — restricting file access, network access, and capability usage beyond what standard permissions allow.

# Check current AppArmor profile
docker inspect myapp --format '{{.AppArmorProfile}}'
# docker-default

# Load a custom profile
sudo apparmor_parser -r /etc/apparmor.d/docker-myapp

# Run with custom profile
docker run -d --security-opt apparmor=docker-myapp myapp:latest

Docker Content Trust

Docker Content Trust (DCT) ensures you only pull images that have been signed by trusted publishers. This prevents pulling tampered or man-in-the-middle images.

# Enable content trust globally
export DOCKER_CONTENT_TRUST=1

# Now pulling unsigned images fails
docker pull unsigned-image:latest
# Error: remote trust data does not exist

# Sign your images when pushing
docker trust sign myregistry/myapp:v1.0

# View trust data
docker trust inspect myregistry/myapp:v1.0

Docker Bench for Security

Docker Bench is an automated script that checks your Docker host and containers against CIS benchmarks — over 100 checks covering host configuration, daemon configuration, container runtime, and images.

# Run Docker Bench for Security
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /etc:/etc:ro \
docker/docker-bench-security

# Example output:
# [PASS] 4.1 - Ensure a user for the container has been created
# [WARN] 4.5 - Ensure Content trust for Docker is Enabled
# [PASS] 4.6 - Ensure HEALTHCHECK instructions have been added
# [WARN] 5.10 - Ensure memory usage for container is limited

Wrapping Up

Container security is not a single checkbox — it is a stack of defenses. Start with the highest impact and lowest effort: scan your images with Trivy, run as non-root, and stop passing secrets as environment variables. Then layer on read-only filesystems, capability dropping, and seccomp profiles. Run Docker Bench periodically to catch configuration drift.

In the next post, we will cover Docker Health Checks — how to tell Docker that your container is actually ready to serve traffic, not just that the process is alive.