Harden Docker on Ubuntu 24.04
The Docker Engine is a privileged control plane. Anyone who can talk to the daemon can mount the host, steal secrets, and become root. Ubuntu 24.04 (Noble) gives you a solid starting point — cgroup v2, AppArmor, nftables — but the defaults after apt install docker-ce are still too open for a host that faces a network.
This guide is a baseline you can apply to a fresh server, then tighten further for the workloads you actually run. Every command has a reason. Do not paste daemon.json blindly onto a host that already has production Compose stacks; some options (especially icc and user-namespace remap) will break naive setups.
What you are defending against
| Risk | What it looks like |
|---|---|
| Socket = root | Membership of the docker group, or a world-readable /var/run/docker.sock, is equivalent to sudo |
| Published ports ignore UFW | Docker inserts iptables/nft rules that bypass Ubuntu's firewall for -p mappings |
| Privileged / extra caps | A breakout from the container becomes a breakout of the host |
Fat images + latest |
Untracked CVEs and surprise rebuilds |
| Unbounded logs | json-file with no rotation fills /var and takes the node down |
| Daemon reboot kills apps | You cannot patch docker-ce without an outage unless live-restore is on |
You will not get a "secure Docker" by installing one package. You get it by shrinking who can talk to the daemon, what a container is allowed to do, and which ports actually leave the host.
1. Install Docker CE, not the Snap and not only docker.io
Ubuntu ships docker.io (distro-built) and a Snap. The Snap confines the daemon in ways that surprise bind-mounts and Compose. docker.io lags Docker Inc. security releases. For a host you intend to harden, use the official Docker CE packages.
Remove leftovers so you do not mix engines:
sudo apt-get remove -y docker.io docker-doc docker-compose docker-compose-v2 \
podman-docker containerd runc
sudo snap remove docker 2>/dev/null || true
Why: two engines fighting over /var/run/docker.sock and iptables is how you get "my UFW rules vanished" and undiagnosable networking.
Install Docker's apt repo (Noble = ubuntu + noble):
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
Why signed-by: apt will only install packages whose metadata is signed by that key. A compromised mirror cannot slip you a backdoored containerd.
Pin and enable:
sudo apt-mark unhold docker-ce docker-ce-cli containerd.io 2>/dev/null || true
sudo systemctl enable --now docker
sudo docker version
sudo docker info --format 'Cgroup: {{.CgroupVersion}} Driver: {{.Driver}} Security: {{.SecurityOptions}}'
On 24.04 you should see cgroup v2, overlay2, and apparmor (and usually seccomp). cgroup v2 is what makes modern CPU/memory limits actually enforceable.
Keep the engine patched with the rest of the OS:
sudo apt-get install -y unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades — ensure origin Docker is allowed, or
# rely on unattended-upgrades already covering packages from configured repos
Why: Docker CVEs (authorization bypasses, runc escapes) are patched in docker-ce / containerd.io. An unattended host that never upgrades the engine is a known-exploit waiting room.
2. Treat the Docker group as root
This is the single most misunderstood Docker fact.
getent group docker
ls -l /var/run/docker.sock
# srw-rw---- 1 root docker ... /var/run/docker.sock
Anyone in docker can run:
docker run -it --rm -v /:/host ubuntu chroot /host
That is a root shell on the host, no sudo prompt. Do not add your login user to docker "for convenience" on a production box. Use sudo docker ... (which is logged in auth.log) or Rootless Docker (section 8).
If someone already added users during a tutorial:
sudo gpasswd -d someuser docker
# they must log out fully (or reboot) for the group drop to stick
Why: Unix groups are not a Docker RBAC system. There is no "can only start my compose project" in the default engine. Socket access is total.
Never publish the API on TCP without TLS:
# these are hostile defaults if you ever find them
grep -E 'tcp://|2375|2376' /lib/systemd/system/docker.service /etc/systemd/system/docker.service.d/* 2>/dev/null
sudo ss -lntp | grep -E '2375|2376'
Port 2375 is the unauthenticated Docker API. Leave it closed. If a remote API is required, that is 2376 with client certificates — a separate design, not a daemon.json one-liner. Prefer SSH (docker -H ssh://user@host) over opening the API at all.
3. A daemon.json baseline
Create /etc/docker/daemon.json. Docker merges this on daemon start. Validate JSON before restarting or the engine will refuse to come up.
sudo nano /etc/docker/daemon.json
{
"icc": false,
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2",
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 1024
},
"nproc": {
"Name": "nproc",
"Hard": 4096,
"Soft": 1024
}
},
"default-address-pools": [
{
"base": "172.30.0.0/16",
"size": 24
}
]
}
Apply:
sudo python3 -m json.tool /etc/docker/daemon.json > /dev/null
sudo systemctl restart docker
sudo docker info
What each key is doing
icc: false — ICC is inter-container communication on the default bridge (docker0). When true (the default), every container on bridge can talk to every other container by IP, even if you never published a port. Attackers who land in a low-value container use that as a free LAN. User-defined Compose networks still work: containers on the same Compose network can communicate; they just do not get a promiscuous docker0 mesh.
If an old tutorial container "cannot ping its neighbour," that is this setting working. Put those services on an explicit Compose network instead.
live-restore: true — Containers keep running when the daemon restarts. That lets you upgrade docker-ce without SIGTERMing production. It is not a substitute for orchestrator liveness; it is so patching the engine is not an outage.
userland-proxy: false — Docker's default starts a docker-proxy process per published port for hairpin NAT. Turning it off uses kernel hairpin / routing instead. Fewer extra root processes, slightly cleaner ps. A few old kernel/NAT edge cases needed the proxy; on 24.04 with cgroup v2 this is the usual hardened choice. Test host-to-container connections via published ports after you set it.
no-new-privileges: true — Sets NoNewPrivileges on new containers so a process cannot gain privileges via setuid binaries inside the image. Closes a class of container-local privilege escalations. You can still override per-container if a legacy image truly needs it (it almost never does).
log-driver / log-opts — The default json-file logger has no rotation. A chatty app fills /var/lib/docker/containers/*/ *-json.log until the disk is gone — an availability attack you inflict on yourself. 10 MB × 3 files per container is a sane start; raise it for apps you actually debug from docker logs.
storage-driver: overlay2 — Ubuntu 24.04 should already pick overlay2. Making it explicit documents intent and fails loudly if something tries aufs/devicemapper.
default-ulimits — Caps file descriptors and processes so a fork-bomb or leaked FDs in one container is less likely to take the host PID/file table with it. Tune nproc if you run JVM/Node with large thread pools.
default-address-pools — The engine's default 172.17.0.0/16 (and automatic 172.18, 172.19, …) collides with a surprising number of corporate VPNs. Pinning a dedicated range (172.30.0.0/16 split into /24 networks) makes routing conflicts a configuration problem instead of a 3 a.m. mystery.
Restarting Docker with icc: false does not delete existing networks; it changes default-bridge behaviour going forward. Recreate Compose stacks if they relied on docker0 links.
4. Stop Docker from punching holes in UFW
UFW on Ubuntu 24.04 filters INPUT. Published container ports (-p 8080:80) are handled by Docker's nat PREROUTING + FORWARD / DOCKER chains. Result: UFW says "deny incoming" and the world can still hit :8080.
First rule: do not publish on all interfaces unless the service is meant to be public.
# hostile — 0.0.0.0:8080
docker run --rm -p 8080:80 nginx
# better — only the loopback, then put nginx/caddy/haproxy in front if needed
docker run --rm -p 127.0.0.1:8080:80 nginx
Compose:
ports:
- "127.0.0.1:8080:80"
Why: bind address is the cheapest, most reliable firewall. If nothing listens on the WAN IP, UFW vs Docker stop arguing.
For ports that must be public, restrict them in the DOCKER-USER chain. Docker guarantees this chain is evaluated before DOCKER accept rules, and it is not wiped on daemon restart.
sudo nano /etc/ufw/after.rules
Append (keep UFW's existing *filter / COMMIT blocks intact — add a separate filter table at the end of the file):
# Docker: default-deny published ports except from LAN + established
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -s 10.0.0.0/8 -j RETURN
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
-A DOCKER-USER -s 192.168.0.0/16 -j RETURN
-A DOCKER-USER -s 127.0.0.1/32 -j RETURN
-A DOCKER-USER -p tcp --dport 443 -j RETURN
-A DOCKER-USER -j DROP
COMMIT
sudo ufw reload
sudo iptables -L DOCKER-USER -n -v
# or, if the host is fully on nftables:
sudo nft list ruleset | grep -A20 'DOCKER-USER'
Adjust the -p tcp --dport 443 line to whatever you actually publish. Why RETURN not ACCEPT: RETURN hands the packet back to Docker's own chains so published-port DNAT still works for allowed sources. DROP is for everyone else.
Do not set "iptables": false in daemon.json unless you have replaced Docker networking entirely. That switch stops Docker from programming NAT, and published ports simply stop working.
5. AppArmor and seccomp are already there — use them
Ubuntu 24.04 runs AppArmor. Docker loads docker-default unless you disable it.
sudo aa-status | grep -A2 docker
docker info --format '{{.SecurityOptions}}'
You want name=apparmor and name=seccomp in that list. Never start production containers with:
# do not do this
docker run --security-opt apparmor=unconfined --security-opt seccomp=unconfined ...
Why people disable them: a volume mount or nested runtime failed and a blog said "unconfined." That removes the kernel's last generic brake on mount(), ptrace(), and a long list of syscalls used in breakouts.
Custom profiles belong in /etc/apparmor.d/ and are loaded with apparmor_parser -r. Only write one if docker-default blocks a specific syscall you have identified, not as a first step.
6. How to run a container that is actually constrained
Daemon defaults help new containers. Each workload still needs its own shrink-wrap. Example: a web app that only needs to listen on 8080, write a temp dir, and talk out on 443.
docker run -d --name web \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--pids-limit 256 \
--memory 256m \
--cpus 0.50 \
--restart unless-stopped \
--user 10001:10001 \
-p 127.0.0.1:8080:8080 \
ghcr.io/org/web@sha256:REPLACE_WITH_REAL_DIGEST
Why these flags
| Flag | Reason |
|---|---|
--read-only |
The container filesystem cannot be used as a staging disk for malware. Anything that must be written goes on an explicit tmpfs or a volume you chose. |
--tmpfs ... noexec,nosuid |
Writable, but you cannot drop a binary there and execute it. |
--cap-drop ALL then --cap-add only what you need |
Linux capabilities are the real root split. NET_BIND_SERVICE is only required if you bind below 1024 inside the namespace. On 8080 you can often drop all caps. |
--pids-limit |
Fork bombs die at 256 PIDs instead of exhausting host pid_max. |
--memory / --cpus |
cgroup v2 enforcement. Without this, one leaky process is a noisy-neighbour DoS. |
--user 10001:10001 |
The process is not uid 0 in the container. Combine with a non-root USER in the image. |
@sha256:... |
Digests do not move. :latest and even :1.2 tags can be overwritten on the registry. |
--privileged is a last resort for nested Docker/KVM, not for "the app failed to bind." It turns most of the above off.
Compose equivalent:
services:
web:
image: ghcr.io/org/web@sha256:REPLACE_WITH_REAL_DIGEST
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /var/run:rw,noexec,nosuid,size=16m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 256m
cpus: 0.50
user: "10001:10001"
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
Do not mount the Docker socket into the app container (-v /var/run/docker.sock:/var/run/docker.sock). That is the same as putting the app in the docker group. Sidecar agents that truly need it (certain CI / Traefik docker-provider setups) should run as a separate service with the socket read-only (:ro) and still be treated as root-equivalent.
7. Images: pin, scan, and stop trusting latest
# see what you actually run
docker images --digests
docker inspect web --format '{{.Image}} {{index .RepoDigests 0}}'
Turn on content trust for anything you push or pull by tag from Docker Hub when you want signature enforcement:
export DOCKER_CONTENT_TRUST=1
docker pull alpine:3.20
Why: DCT refuses unsigned tags. It is not a full SLSA supply-chain story, but it stops a class of "tag was overwritten" accidents. Official images and notary-signed repos work; many GHCR/GitLab images will fail until you pin by digest instead.
Scan what you run (pick one and run it in CI, not once on the laptop):
# Trivy example — pin the scanner version in CI the same way you pin app images
trivy image --severity HIGH,CRITICAL ghcr.io/org/web@sha256:REPLACE
Rebuild on a short cadence. A hardened daemon in front of a two-year-old log4j-era image is theatre.
Prefer distroless or scratch for compiled apps (see the existing multi-stage Docker guide on this site). No shell in the image means a lot of post-exploitation tooling is simply not there.
8. Rootless Docker (when the host is single-tenant)
Rootless maps the daemon into your user namespace. A container escape becomes "you as that uid," not "host root."
sudo apt-get install -y uidmap dbus-user-session fuse-overlayfs slirp4netns
dockerd-rootless-setuptool.sh install
systemctl --user enable --now docker
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker info
Why the extra packages: uidmap for /etc/subuid, slirp4netns (or pasta) for networking without CAP_NET_ADMIN on the host, fuse-overlayfs when kernel overlay in user namespaces is not enough.
Trade-offs: binding host ports below 1024 needs sysctl net.ipv4.ip_unprivileged_port_start=80 (or a fronting reverse proxy as root). Overlay performance can be slightly worse. Rootless is an excellent default for a developer workstation or a single-app box; it is awkward on a shared multi-user CI host where you still wanted a system-wide engine.
Do not mix rootful and rootless blindly — two daemons, two graph directories, two networks.
9. Audit who used the engine
Install auditd and watch the socket, binary, and unit file:
sudo apt-get install -y auditd
sudo tee /etc/audit/rules.d/docker.rules >/dev/null <<'EOF'
-w /usr/bin/docker -p rwxa -k docker
-w /usr/bin/dockerd -p rwxa -k docker
-w /var/lib/docker -p rwxa -k docker
-w /etc/docker -p rwxa -k docker
-w /etc/docker/daemon.json -p rwxa -k docker
-w /usr/lib/systemd/system/docker.service -p rwxa -k docker
-w /usr/lib/systemd/system/docker.socket -p rwxa -k docker
-w /var/run/docker.sock -p rwxa -k docker
EOF
sudo augenrules --load
sudo systemctl restart auditd
sudo ausearch -k docker | tail
Why: sudo docker already hits auth.log. Direct docker as a group member, or a dropped binary replacing /usr/bin/docker, does not. Auditd is how you reconstruct "who started --privileged at 02:14."
Pair with journalctl -u docker.service after daemon.json changes. If the engine fails to start, the JSON parse error is there — not in docker info.
10. A short verification pass
Run this after the baseline and after every Compose deploy:
echo '=== engine ==='
docker info --format 'Rootless={{.SecurityOptions}} LiveRestore={{.LiveRestoreEnabled}} Logging={{.LoggingDriver}}'
echo '=== socket ==='
ls -l /var/run/docker.sock
getent group docker
echo '=== no 2375 ==='
sudo ss -lntp | grep -E '2375|2376' || echo 'API not on TCP (good)'
echo '=== published ports (should be 127.0.0.1 unless public) ==='
docker ps --format 'table {{.Names}}\t{{.Ports}}'
echo '=== privileged / extra caps (should be empty for app containers) ==='
docker ps -q | while read -r id; do
docker inspect "$id" --format '{{.Name}} Privileged={{.HostConfig.Privileged}} CapAdd={{.HostConfig.CapAdd}}'
done
echo '=== ufw vs docker ==='
sudo ufw status verbose
sudo iptables -L DOCKER-USER -n -v 2>/dev/null | head
Anything Privileged=true, CapAdd=[...] you did not document, 0.0.0.0: on a database, or a populated docker group you cannot name, is leftover risk.
What this baseline does not do
- It is not Kubernetes. No admission controller will stop a developer from
docker run --privilegedif they have socket access. Remove the access. userns-remap(daemon-wide user namespaces) is a stronger isolation mode than this file uses. It remaps container uid 0 to a high host uid and breaks many bind mounts,--pid=host, and third-party tools. Enable it only after you have tested every volume path.- Authorization plugins and TLS mutual-auth on the API are the next step for a shared engine with multiple teams. They are overkill for a single-app VM that already denied the
dockergroup and bound ports to localhost.
Harden the daemon, then harden each Compose file the same way you harden a systemd unit: no extra privileges, no extra filesystem, no extra network. The host's job is to make the wrong docker run either impossible or loud.