Docker Security and Operations

Why the default is not secure by default

Docker's defaults optimize for "it just works," not for "it's locked down." Three defaults specifically need overriding for anything beyond local experimentation:

  • Containers run as root unless a Dockerfile says otherwise (see Dockerfile Best Practices — run as non-root).
  • A container's root has no resource ceiling unless one is set — a single misbehaving container can consume all of the host's CPU or memory, starving every other container on the same host.
  • The Docker daemon itself runs as root, and anyone in the docker group can talk to it — which is equivalent to root on the host, because docker run -v /:/host ... lets a container mount the host's entire filesystem. Docker group membership is root-equivalent access, not a lesser privilege tier.

Resource limits: set them, don't assume defaults exist

docker run --memory=512m --memory-swap=512m --cpus=1.0 my-app

--memory=512m sets a hard ceiling — the kernel's cgroup OOM killer terminates the container's process if it exceeds this, the same mechanism described in Docker Fundamentals. Setting --memory-swap equal to --memory disables swap for the container entirely (by default, --memory-swap defaults to double --memory, silently allowing swap usage that can make an out-of-memory condition degrade into extreme slowness instead of a clean, fast OOM-kill — usually the worse outcome operationally, since a hung container is harder to detect than a killed one).

Without an explicit limit, a container can use however much memory the host has — there is no automatic per-container ceiling. On a shared host running several containers, one leaking process can OOM-kill other containers' processes too, not just its own, once the host's total memory is exhausted (the kernel's OOM killer picks a victim by score, not necessarily the actual offender).

Reduce the attack surface: minimal images, dropped capabilities

A container's root user, while confined by namespaces, still has the full set of Linux capabilities by default (CAP_NET_ADMIN, CAP_SYS_ADMIN, and dozens more) — most of which a typical web application never needs, and each of which is a potential escalation path if the application is compromised.

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-app

--cap-drop=ALL removes every capability, then --cap-add re-adds only the specific ones actually needed — here, NET_BIND_SERVICE (needed only to bind a port below 1024; a normal EXPOSE 8080-style app usually doesn't even need this one). This is the same "deny by default, allow explicitly" principle as running as non-root, applied one level deeper.

Scan images for known vulnerabilities

docker scout cves my-app:1.0

An image built FROM python:3.12-slim inherits every OS package and library in that base image — including any CVEs discovered in them after the image was built. A vulnerability scanner checks the image's installed package versions against a known-CVE database. This matters specifically because Docker layer caching (covered in Docker Fundamentals) means a base-image layer, once cached, can silently go stale — a docker build today can reuse a cached python:3.12-slim layer pulled weeks ago, missing security patches released since, unless the build explicitly does a docker pull / --no-cache refresh or a CI pipeline rebuilds base images on a schedule.

Never bake secrets into an image layer

# WRONG — this secret is now permanently in the image's layer history
RUN echo "API_KEY=abc123" > /app/.env

Even if a later layer deletes this file, the layer that created it still exists in the image and is fully extractabledocker history and docker save both expose every layer's contents, deleted-in-a-later- layer or not, exactly the same union-filesystem behavior described in Dockerfile Best Practices — combine RUN instructions. Secrets belong in runtime environment variables (docker run -e), mounted secret files (docker run -v, read at startup, never baked into a layer), or a dedicated secrets manager — never in a COPY or RUN instruction.

Logs, inspection, and debugging a running container

docker logs -f --tail 100 my-container      # follow recent logs
docker exec -it my-container sh              # shell into a running container
docker inspect my-container                  # full JSON: mounts, network, env, limits
docker stats                                 # live CPU/memory/network usage per container

docker logs reads from the container's captured stdout/stderr — which only works if the application actually logs to stdout/stderr rather than to an internal file the container never exposes. This is why containerized applications are conventionally written to log to stdout/stderr rather than to a log file: docker logs (and anything built on top of the same log driver, like a centralized logging agent) has nothing to read otherwise.

Common pitfall

Running long-lived, stateful debugging sessions via docker exec and then treating whatever state accumulates there as durable is a trap — anything created inside a running container that isn't in a mounted volume disappears the moment the container is removed, exactly like the writable-layer behavior in Docker Fundamentals. If a debug session installs a tool or downloads a file to investigate something, that installation doesn't survive the next docker compose up rebuild — it has to either go in the Dockerfile (if it should always be there) or be treated as genuinely throwaway.