Jump to: the Docker socket, capabilities, read-only filesystems, resource limits or a hardened Compose service.
Before you start
You need Docker Engine on Linux, installed as in the Docker Installation Guide. Each setting below is independent, so you can apply them one at a time and test the service after each change. That's the safest way to work: a container that won't start with every option enabled is much harder to debug than one that broke after a single change.
It helps to be clear about what a container is. It isn't a virtual machine. Every container on a host shares the host's kernel, and isolation comes from kernel features: namespaces, control groups, capabilities, seccomp and an LSM such as AppArmor or SELinux. Hardening a container means tightening those controls, so that a compromised application has as little as possible to work with.
docker info --format '{{.SecurityOptions}}' # e.g. [name=apparmor name=seccomp,profile=builtin name=cgroupns]
docker version --format '{{.Server.Version}}'
The Docker socket is root access
Start here, because it outweighs everything else on this page. Anyone who can talk to /var/run/docker.sock can start a container that mounts the host's root filesystem, and from there do anything root can do. That includes:
- Members of the
dockergroup. Adding a user to it is equivalent to giving them passwordlesssudo. - Any container you start with
-v /var/run/docker.sock:/var/run/docker.sock. Dashboards, auto-updaters and some reverse proxies ask for this. - Anything that reaches the Docker API over TCP if you enabled it without TLS client certificates.
Before giving a container the socket, ask whether it really needs it. If it only needs to read container information, for example a proxy discovering services, put a filtering proxy such as docker-socket-proxy in front of the socket and allow only the API endpoints it uses. Mounting the socket with :ro doesn't help: read-only applies to the socket file, not to the API calls made through it.
If you need users to run containers without that level of trust, look at rootless mode, where the daemon itself runs as an ordinary user. It has limitations, including no binding to ports below 1024 by default and some storage and networking differences, so test your workloads before switching.
Run the process as a non-root user
By default, the process in a container runs as root (UID 0). Without user namespaces, that's the same UID 0 as on the host, restricted only by the other controls on this page. If the image supports it, run as an ordinary user:
docker run --rm --user 10001:10001 alpine:3 id
# uid=10001 gid=10001 groups=10001
Many official images already define a user. docker image inspect --format '{{.Config.User}}' image shows it; an empty result means root. Images you build yourself should set a fixed UID with USER, as shown in Dockerfile Best Practices.
If the app writes to a bind mount, give the host directory to the same UID, for example sudo chown -R 10001:10001 ./data. Some images start as root on purpose and drop privileges after fixing permissions. The official PostgreSQL image does this. For those, follow the image's own documentation rather than forcing --user.
Block privilege escalation
--security-opt no-new-privileges:true
This sets the kernel's no_new_privs flag on the container's processes, so setuid and setgid binaries such as su, sudo or ping can no longer raise privileges. An attacker who lands in the container as an ordinary user can't use a setuid program to become root. Almost no applications need setuid at runtime, which makes this one of the safest options to turn on everywhere.
You can make it the default for every container in /etc/docker/daemon.json:
{
"no-new-privileges": true
}
Drop Linux capabilities
Linux splits root's powers into about forty capabilities. Docker gives containers a reduced default set of roughly fourteen, including CHOWN, SETUID, NET_RAW and NET_BIND_SERVICE. Most applications need none of them. The strongest approach is to drop all of them and add back only what fails:
docker run -d --name web \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
-p 127.0.0.1:8080:80 \
nginx:alpine
Nginx needs NET_BIND_SERVICE to listen on port 80. The official image also needs CHOWN, SETUID and SETGID during start-up, when it changes the owner of its cache directories and switches its worker processes to the nginx user. If it exits immediately, the log usually names the operation that failed. Add capabilities back one at a time until it starts:
docker logs web
# chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
That error points at CHOWN. To see which capabilities a running process actually holds, decode its effective set:
docker exec web grep CapEff /proc/1/status
capsh --decode=00000000a80425fb # capsh is in libcap2-bin (Debian/Ubuntu) or libcap (Fedora)
Never use --privileged to fix a permission problem. It grants every capability, disables seccomp and AppArmor, and gives the container access to all host devices. It's meant for containers that manage the host itself, which ordinary applications aren't.
Read-only root filesystem
docker run -d --name web \
--read-only \
--tmpfs /tmp \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
-p 127.0.0.1:8080:80 \
nginx:alpine
--read-only mounts the container's root filesystem read-only. An attacker can't drop a binary into /usr/bin, change the app's code or plant a cron job. The app still needs somewhere to write temporary files, so mount small in-memory tmpfs filesystems on just the paths it uses, plus volumes for data that must persist.
Finding those paths is trial and error. Start the container read-only, read the error, and add a tmpfs or volume for the path it names. For a quick list of what an app writes during normal use, run it without --read-only for a while and ask Docker:
docker diff web # A = added, C = changed, D = deleted, relative to the image
Resource limits
A container with no limits can use all of the host's memory, CPU and process IDs. A memory leak, a fork bomb or a busy neighbour then takes everything else on the machine down with it. Limits are enforced by control groups:
docker run -d --name web \
--memory 256m \
--memory-swap 256m \
--cpus 1.0 \
--pids-limit 200 \
nginx:alpine
--memoryis a hard ceiling. When the container exceeds it, the kernel's OOM killer stops a process inside it.docker inspect --format '{{.State.OOMKilled}}' webtells you afterwards whether that happened.- Setting
--memory-swapto the same value stops the container from using swap to exceed its limit. --cpus 1.0allows the equivalent of one core, spread across however many cores the host has.--pids-limitcaps the number of processes and threads, which stops a fork bomb from exhausting the host's PID table.
Watch real usage before choosing numbers, so the limits leave headroom above normal peaks:
docker stats --no-stream
Seccomp and AppArmor
Docker applies a default seccomp profile that blocks around forty-four of the roughly three hundred system calls, including ones for loading kernel modules and changing the system clock. On Ubuntu and Debian it also applies an AppArmor profile called docker-default. Both are on unless you turn them off, which is what --security-opt seccomp=unconfined and apparmor=unconfined do.
The practical advice is to leave the defaults in place and treat any instruction to disable them as a red flag. Custom profiles that narrow the system call list even further are possible, but they need maintenance whenever the application changes, so they're worth it mainly for high-risk services.
Image hygiene
- Use images from publishers you trust: Docker Official Images, verified publishers, or the project's own registry. Check the source repository behind any image you run.
- Pin versions so you know what you're running, and update deliberately so you get security fixes.
- Scan images for known vulnerabilities.
docker scout cves imagereports them, and open-source scanners such as Trivy do the same. Expect findings in any image; focus on fixed vulnerabilities in packages your app actually uses. - Smaller base images carry fewer packages, so there's less to patch and less for an attacker to use.
A hardened Compose service
Here are the same settings as a Compose service, for a web app built with a fixed non-root user:
services:
app:
image: registry.example.net/myapp:1.4.2
restart: unless-stopped
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 512m
memswap_limit: 512m
cpus: 1.0
pids_limit: 200
ports:
- "127.0.0.1:8000:8000"
volumes:
- app-data:/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
app-data:
Replace the image with your own. The app listens on 8000, so it needs no capabilities at all. mem_limit, cpus and pids_limit are the Compose equivalents of the docker run flags above. The logging block caps the container's log at three 10 MB files, so a chatty app can't fill the disk.
Apply it and confirm each setting took effect:
docker compose up -d
docker compose exec app id # uid=10001
docker compose exec app touch /usr/test # Read-only file system
docker inspect --format '{{.HostConfig.CapDrop}} {{.HostConfig.SecurityOpt}} {{.HostConfig.Memory}}' $(docker compose ps -q app)
Checklist
- The Docker socket isn't mounted into any container that doesn't need it, and the
dockergroup contains only people you'd trust with root. - Processes run as a non-root user wherever the image allows it.
no-new-privilegesis set.cap_drop: [ALL], with only the capabilities that proved necessary added back.- A read-only root filesystem, with
tmpfsand volumes for the paths that must be writable. - Memory, CPU and PID limits based on observed usage.
- Seccomp and AppArmor or SELinux left on. No
--privileged. - Ports bound to
127.0.0.1unless they must be public; see Docker Networking Explained. - Images pinned, scanned and updated on a schedule.
Troubleshooting
Operation not permitted after dropping capabilities
The process needs a capability you removed. The failing operation in the log usually tells you which: chown means CHOWN, switching users means SETUID and SETGID, binding to a low port means NET_BIND_SERVICE. Add one back and try again.
Read-only file system errors
Add a tmpfs for that path if the data is temporary, or a volume if it must persist. docker diff on a non-read-only run lists the paths the app writes to.
The container exits with code 137
Exit code 137 means the process was killed with SIGKILL, usually by the OOM killer after it hit the memory limit. Confirm with docker inspect --format '{{.State.OOMKilled}}' and raise the limit, or find the leak.
The image ignores user: and fails to start
Some images expect to start as root so they can fix permissions before dropping privileges. Remove user:, keep the other settings, and check the image's documentation for its own way to set the runtime UID, often a PUID and PGID environment variable.
Next steps
- Dockerfile Best Practices: build images that run as non-root from the start.
- Docker Networking Explained: keep containers off the public network unless they need to be on it.
- Locking Down SSH: the host around your containers matters just as much.
- Docker's Docker Engine security overview.