Intro
This is a practical, step-by-step guide to hardening Docker with examples you can run in a controlled pilot. You will establish a known-good baseline, apply safe configuration changes, verify results, and recover if anything breaks. The focus is on least privilege, secrets, permissions, and limiting network exposure while keeping changes measurable and reversible.
Scope note: examples below are constructed to illustrate safe patterns you can adapt to your environment.
---
Version and Environment Inventory
Before any changes, capture the current state so you can compare, verify, and roll back if needed. Keep this scoped to a small pilot container or service that you can inspect locally.
- Record core versions and settings
- Constructed example commands:
# Record Docker and kernel info
sudo docker version
sudo docker info
uname -a
# Save to a dated snapshot
sudo docker version > baseline-$(date +%F).txt
sudo docker info >> baseline-$(date +%F).txt
- Identify the pilot and topology
- Choose one simple service for the pilot (for example, a small HTTP server bound to localhost).
- Decide the minimum host paths that must be mounted.
- Decide the smallest set of exposed ports and networks required.
- Confirm prerequisites
- Linux host with a supported Docker Engine.
- Ability to edit /etc/docker/daemon.json and restart Docker.
- Sudo privileges for administrative steps.
Why start small? A narrow, measurable pilot is easy to inspect, lets you quantify effects of each control, and reduces rework when issues arise later.
---
Safe Configuration Path
Apply hardening in layers and verify after each layer. The goal is least privilege and minimal exposure without breaking required functionality.
1. Access control around the Docker daemon
- Do not broadly grant membership in the
dockergroup; it effectively provides root on the host. - Prefer command execution via
sudofor admins. For developers, consider rootless Docker on dev machines if feasible.
Optional pilot: rootless Docker on a developer workstation (constructed example)
# Prereqs: ensure 'uidmap' is installed, then run:
sudo apt-get install -y uidmap
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export PATH=/usr/bin:$PATH
# Set up rootless (constructed example)
dockerd-rootless-setuptool.sh install
systemctl --user enable --now docker
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
Verify with docker info that it reports a rootless mode if used.
2. Daemon defaults that reduce blast radius
Edit /etc/docker/daemon.json and restart Docker. Back up the file first.
Constructed example daemon.json:
{
"icc": false,
"live-restore": true,
"userns-remap": "default",
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
icc: falseblocks inter-container communication on the default bridge unless explicitly networked together.live-restore: truehelps containers survive a daemon restart.userns-remap: defaultmaps container root to an unprivileged UID/GID on the host.- Local logging with size limits helps avoid disk fill.
Apply and restart:
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak.$(date +%F)
sudo systemctl restart docker
Note on user namespace remap: after enabling, host file ownership for bind mounts may need to match the remapped range. See Recovery for details.
3. Hardened container run pattern
Start with a minimal container and gradually add only what is required. The flags below demonstrate a strong default stance.
Constructed example: hardened web container
# Constructed example image and digest; replace with your own
IMAGE="mycorp/web:1.2.3@sha256: deadbeef..."
sudo docker network create --driver bridge --subnet 172.30.0.0/24 --internal app_net || true
sudo docker run --rm -d \
--name web \
--network app_net \
--read-only \
--tmpfs /tmp: rw, noexec, nosuid, nodev, size=64m \
--pids-limit=200 \
--memory=256m --memory-swap=256m --cpus=1.0 \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges \
--security-opt seccomp=default \
--user 10001:10001 \
-v /srv/web/static:/srv/web/static: ro \
-p 127.0.0.1:8080:8080 \
--health-cmd='curl -sSf http://127.0.0.1:8080/health || exit 1' \
--health-interval=30s --health-retries=3 \
"$IMAGE"
Why these choices:
--read-onlyprevents unexpected writes to the root filesystem.--tmpfs /tmp:...offers a safe write area for temporary files.--cap-drop=ALLthen--cap-add=NET_BIND_SERVICEenforces least privilege.--security-opt no-new-privilegesblocks privilege escalation.--security-opt seccomp=defaultkeeps the default syscall filter in place.--user 10001:10001avoids running as root inside the container.-v ...:robinds data read-only from the host.--network app_net --internalcombined with local port publish to127.0.0.1keeps network exposure tight.
If an app needs write paths beyond /tmp, mount those paths explicitly with suitable options instead of disabling --read-only globally.
4. Secrets handling
Goals: avoid secrets in images and environment variables; prefer files with restricted permissions.
- Docker Swarm secrets (constructed example):
echo "constructed-db-password" | docker secret create db_password -
docker service create \
--name app \
--secret source=db_password, target=/run/secrets/db_password, mode=0400 \
"$IMAGE"
- Standalone containers using a host file (constructed example):
# Create a file readable only by the in-container user (UID 10001 here)
sudo install -o 10001 -g 10001 -m 0400 /root/db_password /srv/app/db_password
sudo docker run --rm \
--user 10001:10001 \
-v /srv/app/db_password:/run/secrets/db_password: ro \
"$IMAGE"
Prefer file mounts over environment variables for secrets. Ensure correct UID/GID and mode (0400/0440) on the host file.
5. Avoiding dangerous flags and modes
- Do not use
--privilegedunless absolutely necessary. - Avoid
--pid=host,--net=host, and--ipc=hostin the pilot; they expand the blast radius. - Publish ports explicitly and bind to loopback if the service is meant to be local only, e.g.
-p 127.0.0.1:8080:8080.
6. Remote API exposure
- Do not expose the Docker API unauthenticated on
tcp://0.0.0.0:2375. - If remote access is required, enable TLS and strict firewall rules.
Constructed example daemon.json snippet for TLS:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlscert": "/etc/docker/certs.d/server-cert.pem",
"tlskey": "/etc/docker/certs.d/server-key.pem",
"tlscacert": "/etc/docker/certs.d/ca.pem",
"tlsverify": true
}
7. Image hygiene and pinning
- Pin images by digest:
repo: tag@sha256:<digest>to ensure reproducibility. - Keep base images minimal and remove unneeded packages in your own images.
- Optionally set
DOCKER_CONTENT_TRUST=1when pulling and running images in environments where content trust is configured.
8. Resource limits
- Memory and CPU limits:
--memory,--memory-swap,--cpus. - Process count limit:
--pids-limit. These prevent a single container from exhausting host resources.
---
Hardening controls quick map
The table shows where each control is set and the expected outcome.
| Area | Control | Where to set | Example | Expected outcome |
|---|---|---|---|---|
| Access | Restrict docker group | Host | Manage membership with sudo | Fewer users with root-equivalent access |
| Daemon | userns-remap | /etc/docker/daemon.json | "userns-remap": "default" | Container root mapped to unprivileged host UID |
| Runtime | Non-root user | Dockerfile/run flags | --user 10001:10001 | App runs without root inside container |
| Runtime | Capabilities | Run flags | --cap-drop=ALL --cap-add=NET_BIND_SERVICE | Least-privilege capabilities |
| Runtime | No new privileges | Run flags | --security-opt no-new-privileges | Blocks privilege escalation |
| Runtime | Seccomp profile | Run flags | --security-opt seccomp=default | Syscall filtering active |
| Filesystem | Read-only root | Run flags | --read-only; --tmpfs /tmp | Prevents unexpected writes to rootfs |
| Secrets | File mounts or Swarm | Run/service flags | -v secret:...:ro; --secret ... | Secrets not in env or image layers |
| Network | Minimize exposure | Run flags/networks | -p 127.0.0.1:8080:8080; --internal | Reduced external attack surface |
| Limits | Memory/CPU/PIDs | Run flags | --memory 256m --cpus 1 --pids-limit 200 | Contained resource usage |
---
Verification and Diagnostics
Verify each control with targeted checks and record results.
- Daemon settings
sudo docker info | grep -E "userns|Live Restore|Security Options"
cat /etc/docker/daemon.json
Expected: userns enabled, Live Restore true, security options include apparmor/selinux where applicable.
- Container runtime options (constructed container name: web)
sudo docker inspect web \
--format 'User={{.Config.User}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapAdd={{.HostConfig.CapAdd}} CapDrop={{.HostConfig.CapDrop}} NoNewPrivs={{.HostConfig.SecurityOpt}} PidsLimit={{.HostConfig.PidsLimit}} Memory={{.HostConfig.Memory}} CPUs={{.HostConfig.NanoCpus}}'
Expected: non-root user ID, read-only true, CapDrop includes ALL, NoNewPrivs present, limits nonzero.
- In-container checks
sudo docker exec web id -u
sudo docker exec web sh -c 'test -w / || echo "rootfs is read-only"'
sudo docker exec web sh -c 'grep NoNewPrivs /proc/1/status || true'
sudo docker exec web sh -c 'grep CapEff /proc/1/status'
Expected: user ID not 0, rootfs not writable, NoNewPrivs: 1 line present (kernel dependent), capabilities effective mask small.
- Secrets
sudo docker exec web sh -c 'stat -c "%U %G %a %n" /run/secrets/* 2>/dev/null || true'
Expected: secret files exist only if you mounted them; modes should be 400/440 and owned by the app user.
- Network exposure
sudo ss -ltnp | grep 8080
sudo docker network inspect app_net | grep -E '"Internal":|"Subnet"'
Expected: service listening on 127.0.0.1:8080, network shows "Internal": true.
- Health and logs
sudo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
sudo docker logs --tail 50 web
Expected: healthy container, no permission errors due to read-only or missing capabilities.
---
Failure Modes and Recovery
When hardening, the most common issues relate to permissions, capabilities, and write paths. Keep a simple rollback plan.
Common issues
| Symptom | Likely cause | Quick fix |
|---|---|---|
| App cannot write to expected path | --read-only blocks writes | Mount a writable path: -v /srv/app/data:/app/data: rw or add --tmpfs /var/cache/app |
| Permission denied on bind mount | userns-remap changed host UID/GID mapping | chown -R <remapped_uid>:<remapped_gid> /srv/app/data (constructed example: 165536:165536) |
| Cannot bind to port 80 as non-root | Missing NET_BIND_SERVICE capability | Add --cap-add=NET_BIND_SERVICE or use port >= 1024 |
| Network calls fail from internal net | --internal blocks egress | Use a non-internal network for services needing egress |
| Secret file unreadable | Wrong UID/GID or mode on host | Set -o <uid> -g <gid> -m 0400 on the secret file and mount :ro |
| Process killed with OOM or pids limit | Limits too tight | Increase --memory or --pids-limit with measured headroom |
User namespace remap specifics
After userns-remap, container root maps to an unprivileged range on the host, typically starting at a high UID like 165536 (constructed example). Files mounted from the host must be owned by that UID/GID to be writable inside the container.
Constructed example fix for a writable mount:
# Determine the remapped UID/GID (constructed example assumes 165536)
grep dockremap /etc/subuid /etc/subgid || true
# Adjust ownership for a data directory
sudo chown -R 165536:165536 /srv/app/data
Rolling back daemon changes
- Restore previous daemon.json
sudo cp /etc/docker/daemon.json.bak.$(date +%F -d 'yesterday' 2>/dev/null || echo backup) /etc/docker/daemon.json
- Restart the daemon
sudo systemctl restart docker
Note: restarting Docker can stop containers unless live-restore is enabled. Plan a maintenance window for production changes.
Rolling back a container run
- Stop the hardened container and start it again with previously working flags.
sudo docker stop web
# Rerun without read-only and with broader capabilities as a temporary rollback (constructed example)
sudo docker run -d --name web --cap-drop=NET_RAW -p 127.0.0.1:8080:8080 "$IMAGE"
Document the differences and re-apply hardening step by step to isolate the breaking flag.
---
Operations Checklist
Use this concise checklist for each service you harden. Mark N/A where not applicable.
Access and daemon
- [ ] Review docker group membership; remove unnecessary users
- [ ] Confirm
userns-remapenabled and working - [ ] Set
live-restoreand bounded log options
Image and runtime
- [ ] Pin image by digest
- [ ] Run as non-root user
- [ ] Drop all capabilities; add back only what is needed
- [ ] Enable
no-new-privileges - [ ] Use default seccomp profile or a stricter custom one
Filesystem and secrets
- [ ] Enable
--read-only - [ ] Provide required writable paths via
--tmpfsor dedicated mounts - [ ] Mount secrets as read-only files with correct UID/GID and mode
Network and exposure
- [ ] Use user-defined networks; internal where possible
- [ ] Bind published ports to loopback unless external access is required
- [ ] Keep remote Docker API disabled; if enabled, require TLS and firewall it
Limits and health
- [ ] Set memory, CPU, and PIDs limits
- [ ] Configure a healthcheck where applicable
Verification
- [ ] Capture
docker inspectkey fields for the service - [ ] Validate in-container: non-root, read-only rootfs, limited caps
- [ ] Confirm secrets ownership and permissions
- [ ] Confirm only required ports are listening
Recovery
- [ ] Back up daemon.json before changes
- [ ] Document previous run flags to enable quick rollback
---
Conclusion
You now have a measured, repeatable path to harden Docker in production-like conditions without guessing: establish the environment baseline, apply layered controls for access, runtime, filesystem, secrets, networking, and limits, then verify each outcome with targeted checks. Start with a narrow pilot that you can inspect locally, record what works, and scale the same approach to additional services. Keep rollback instructions close at hand so you can move fast without getting stuck when something breaks.