Skip to content

10 - Security Basics

What this session is

About 45 minutes. Five things that make any container deployment notably safer. Not exhaustive - just the high-leverage moves.

1. Don't run as root

By default, the container's process runs as root inside the container. If an attacker escapes the container (rare but happens), they're root on your host (unless user namespaces are configured, which Docker doesn't do by default).

Always switch to a non-root user:

RUN useradd --create-home --shell /bin/bash --uid 1001 app
USER app

Or use a numeric UID:

USER 1001:1001

Some images do this for you (e.g. nginx switches to nginx user, postgres to postgres). Many don't. Verify with:

docker run --rm myimage id

If uid=0(root), you're running as root. Fix.

2. Read-only root filesystem

Most apps don't need to write to their root filesystem. Make it read-only:

docker run --read-only --tmpfs /tmp myapp

Anything that tries to write outside the explicit tmpfs mounts (or other mounted volumes) fails. This neutralizes a class of attacks where malware drops a binary into /usr/bin or similar.

For specific writable areas (a cache directory, /var/log), add --tmpfs PATH (in-memory) or -v VOL:PATH (named volume).

In compose:

services:
  web:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache

3. Drop unnecessary capabilities

Linux capabilities split root's powers into ~40 distinct privileges. By default Docker grants a subset (~14). For most apps, you can drop them all:

docker run --cap-drop=ALL myapp

If the app needs one specific capability (e.g. binding to port < 1024):

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

Most modern apps need no capabilities - they don't try to do privileged things. Drop ALL by default; add only when needed and justified.

In compose:

services:
  web:
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]

4. --security-opt no-new-privileges

Prevents the container from gaining privileges via setuid binaries (e.g. sudo, su):

docker run --security-opt=no-new-privileges myapp

Almost always safe to add. Pair with non-root user for defense-in-depth.

In compose:

services:
  web:
    security_opt:
      - "no-new-privileges:true"

5. Don't bake secrets into images

Never COPY a .env file. Never ENV PASSWORD=hunter2. The secret is in a layer forever - even if you delete it in a later layer, docker history reveals it.

Options:

  • Env vars at runtime: -e PASSWORD=... or --env-file secrets.env at docker run. Don't commit the env file.
  • Mounted secret files: mount a directory or file with secrets at runtime.
  • Secret managers: Docker Swarm secrets, Kubernetes secrets, HashiCorp Vault, AWS Secrets Manager, etc. For non-toy deployments.

For local dev, env files in .gitignore. For production, a real secret manager.

Quick audit: is this image clean?

Run an image vulnerability scanner:

# Trivy (Aqua Security - free, open source):
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image myapp:1.0

# Grype (Anchore):
grype myapp:1.0

Both produce a list of known CVEs in the image's packages. Triage: fix high/critical first. Many are inherited from the base image; updating the base often fixes batches.

docker scout (built into modern Docker) is another option:

docker scout cves myapp:1.0

Run scanners as part of your CI. Don't ship images with known critical CVEs without explicit acknowledgement.

Putting it together: a hardened Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# Non-root user with a known UID
RUN useradd --create-home --shell /bin/bash --uid 1001 app && \
    chown -R app:app /app
USER 1001:1001

EXPOSE 8000

# Don't put secrets here - pass at runtime.
CMD ["python", "app.py"]

Run it hardened:

docker run -d --rm \
  --name app \
  -p 8000:8000 \
  --read-only \
  --tmpfs /tmp \
  --cap-drop=ALL \
  --security-opt=no-new-privileges \
  -e DATABASE_URL=postgres://... \
  myapp:1.0

Compose form:

services:
  app:
    image: myapp:1.0
    ports: ["8000:8000"]
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    environment:
      DATABASE_URL: ${DATABASE_URL}

The big footguns

Things to never do (or do only with very deliberate awareness):

  • --privileged - disables most isolation. Equivalent to "this container IS the host." Used by Docker-in-Docker and a few other special cases; almost never appropriate.
  • -v /:/host or -v /var/run/docker.sock:... - mounting host paths into the container. Especially the Docker socket - anyone in the container can issue Docker commands, which means root on the host.
  • Running with --user 0 when the image has a non-root default. You're undoing the safety.
  • --network=host with untrusted images. The container has full access to your host's network stack.

What you're not doing here

This page is the basics. Real container security also includes: pod-level policies (in Kubernetes), seccomp profiles, AppArmor/SELinux, signed images, supply-chain attestations, runtime detection (Falco). Those are advanced topics; the "Container Internals" senior reference path covers them.

For a first deployment, the five basics on this page get you 80% of the value.

Exercise

  1. Identify which images run as root:

    for img in nginx postgres:16 redis alpine ubuntu python:3.12; do
      echo -n "$img: "
      docker run --rm $img id 2>/dev/null || echo "(no id command)"
    done
    

  2. Run an app hardened:

    docker run -d --rm \
      --name web \
      -p 8090:80 \
      --read-only \
      --tmpfs /var/cache/nginx \
      --tmpfs /var/run \
      --cap-drop=ALL \
      --cap-add=NET_BIND_SERVICE \
      --security-opt=no-new-privileges \
      nginx
    curl -s http://localhost:8090 | head
    docker stop web
    
    (nginx needs writable /var/cache/nginx and /var/run, hence the tmpfs mounts.)

  3. Scan an image:

    docker scout cves nginx:1.27       # if you have docker scout
    # or
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      aquasec/trivy:latest image nginx:1.27
    
    Read the report. Note how many CVEs - and how many are critical vs informational.

What you might wonder

"Should I rebase my images regularly?" Yes. Rebuild your images periodically with the same Dockerfile - the base image you depend on receives security updates that flow into your image only when you rebuild. Automate via CI (rebuild weekly, run image scans, alert on new vulns).

"What about supply-chain attacks?" Pin base images by digest (FROM nginx@sha256:abc...) not tag. Use signed images. Verify via cosign / sigstore. Way beyond beginner; mentioned for awareness.

"What's seccomp?" A Linux kernel feature that filters which syscalls a process can make. Docker has a default seccomp profile that blocks a handful of dangerous syscalls. You can customize. Beyond beginner.

Done

  • Run containers as non-root.
  • Make root filesystems read-only.
  • Drop unneeded Linux capabilities.
  • Use --security-opt=no-new-privileges.
  • Keep secrets out of images.
  • Run image scanners.

Next: Image registries →

Comments