Hardening Docker on a small server in payoff order, plus the ufw trap
Non-root user, dropped capabilities, read-only filesystem and memory limits in payoff order, with one complete compose file, and why ufw does not block Docker ports.
- Four compose lines (user, cap_drop, read_only, no-new-privileges) remove most of what an attacker inside a container could do next
- mem_limit and pids_limit stop one container from taking the whole box, which on a small server happens more often than a breach
- Ports Docker publishes bypass ufw; bind them to 127.0.0.1 or add rules to the DOCKER-USER chain
- Never mount /var/run/docker.sock into a container; even with :ro it is root on the host
Docker’s defaults are tuned to “it runs”, not “it is as safe as it can be”. On a small server where several systems share one box, a single loosely configured container can take everything down, whether through an attacker or a plain memory leak. This article lists what to do in the order that pays off most, ends with a docker-compose.yml that applies all of it, and explains a trap that catches many ufw users: ports Docker publishes do not pass through ufw at all.
Tested with Docker Engine 28 and Compose v2 on Ubuntu 24.04, on the machine sized in a 2GB server.
The idea: shrink what a container can do
If the app inside a container has a hole, the attacker gets the rights of that process. Our job is to make those rights worth as little as possible: not root, no special capabilities, no writable disk, no way to escalate, a memory cap, and no line to the Docker daemon. Each step below removes one item from that list.
1. Do not run as root in the image
Almost every image defaults to root, so a compromised app is root inside the container at once, and root on the host if it escapes. Add USER to the Dockerfile:
FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["gunicorn", "-b", "0.0.0.0:8000", "main:app"]
A high uid such as 10001 is chosen so it never collides with a real host user. Database images like postgres already ship a postgres user (uid 999); set user: "999:999" in compose.
2. Drop every capability, add back only what is needed
Linux splits root’s power into capabilities. Docker already drops most of them, but several remain, such as CHOWN, SETUID and NET_RAW. A web app running as non-root and listening above port 1024 needs none.
cap_drop: [ALL]
# only if it truly must listen on port 80 inside the container: cap_add: [NET_BIND_SERVICE]
And never use privileged: true. It switches on every capability and every device on the host.
3. Read-only filesystem plus tmpfs for scratch
read_only: true stops an attacker dropping files, installing tools or editing the app’s code. Most apps only need /tmp, so make it a tmpfs (in RAM, gone when the container stops). This step breaks most often on first start, because some library caches somewhere unexpected. Read the error, add a tmpfs for that path, and keep read_only.
4. no-new-privileges
setuid binaries such as sudo and passwd let an ordinary process become root for a moment. security_opt: [no-new-privileges:true] closes that door for the whole container in one line. Almost no app needs it.
5. Limit memory and processes
On a small box the thing that actually happens, more often than a breach, is one container eating RAM until the machine swaps and every site slows down. mem_limit lets the kernel kill only that container; pids_limit stops a fork bomb, from a bug or an intruder.
| 2GB machine | mem_limit | pids_limit |
|---|---|---|
| Python/Node web app | 512m | 200 |
| PostgreSQL | 768m | 200 |
| nginx on the host (not a container) | leaves ~700m for the OS | - |
Set them, then watch docker stats for a week. Real usage is usually lower than you feared.
6. Never mount the Docker socket
/var/run/docker.sock is the remote control of the Docker daemon, which runs as root. Anyone who can talk to it can start a container with the host’s / mounted inside: root on the entire machine.
Mounting the socket
:rodoes not help.:roforbids writing the socket file; it does not forbid sending commands through it. OWASP lists this as rule number one of Docker security.
When a tool asks for the socket (dashboards, auto-updaters, some reverse proxies), ask whether it is worth handing it root. If it truly is, put a filtering socket proxy in front and keep it alone on its own network.
7. Bind ports to 127.0.0.1
ports: ["8000:8000"] means 0.0.0.0:8000, open to the whole internet. If only nginx on the same machine needs the app, write "127.0.0.1:8000:8000". A database that talks to the app over the compose network should not be published at all; delete its ports block.
8. Keep secrets out of the image and out of docker inspect
ENV DB_PASSWORD=... in a Dockerfile is baked into every layer of the image, and environment: in compose shows up in docker inspect for anyone who can read the socket. Better options, from easy to safest:
env_file: .envwithchmod 600and a line in.gitignore(still visible in inspect, but not in the image)- File-based Docker secrets: compose reads a file on the host and mounts it read-only at
/run/secrets/<name>. The app reads the file instead of an env var; thepostgresimage already supportsPOSTGRES_PASSWORD_FILE.
Each key should be able to do only its own job, the principle behind separate credentials by role.
9. Pin tags, rebuild on a schedule
python:latest today and next month are different images. Pin at least to a minor (python:3.12-slim) or to a digest @sha256:..., then a weekly cron rebuild picks up OS patches inside the image. The pain of unpinned things is told in works on my machine.
Pinning got a fresh lesson in March 2026: the scanner Trivy had its release pipeline compromised (CVE-2026-33634) and GitHub Action tags were pointed at malicious code. The users who stayed safe were pinned to a commit SHA. The same lesson applies to our images.
10. Scan images
# on the machine that builds the image
trivy image --severity HIGH,CRITICAL myapp:1.4.2
# or Docker Scout, bundled with the Docker CLI
docker scout cves --only-severity critical,high myapp:1.4.2
Both are free for a setup this size at the time of writing (Scout’s quota and terms change; check the current docs). Install Trivy only from a release you can verify, for the reason in step 9.
11. Cap the logs
The default json-file driver has no size limit. An app erroring in a loop can fill the disk overnight and take everything on the machine with it. Set it machine-wide in /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
Values under log-opts must be strings, and the change applies only to containers created after Docker restarts.
12. The host side, and the ufw trap
The host must patch itself (unattended-upgrades) and SSH must be tight (SSH hardening). Then the big trap:
Ports Docker publishes do not pass through ufw. After
ufw deny 8000,ufw statussays closed, yet another machine still connects.
Docker writes its own NAT rules in iptables, and packets are routed to the container before they reach the INPUT chain ufw uses. Docker’s docs say this plainly; it is not a bug. The fixes, in recommended order:
- Do not publish what need not be open. Step 7 covers 90% of cases.
- Add rules to the
DOCKER-USERchain. Docker leaves this chain to you and evaluates it before its own rules. For example, allow only the office IP to reach published ports:
# replace eth0 with the real interface name (ip -br link)
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
sudo apt install iptables-persistent && sudo netfilter-persistent save
Always include -i eth0, or you also block containers talking to each other. Docker 28 and later can use nftables as the backend, where this rule may need a different form; check the current docs.
Always verify from another machine: nmap -p 1-65535 <public IP>, not ufw status.
The complete compose: Python/Node app + PostgreSQL
# docker-compose.yml (Docker Engine 28, Compose v2, Ubuntu 24.04)
services:
app:
image: ghcr.io/example/myapp:1.4.2 # pinned tag; safer still: @sha256:...
user: "10001:10001" # step 1 non-root (also USER in the Dockerfile)
read_only: true # step 3
tmpfs:
- /tmp:size=64m,mode=1777 # the only scratch space allowed
cap_drop: [ALL] # step 2 nothing added back; listens on 8000
security_opt: [no-new-privileges:true] # step 4
mem_limit: 512m # step 5
pids_limit: 200
env_file: .env # step 8 non-secret settings, chmod 600
secrets: [db_password] # app reads /run/secrets/db_password
ports:
- "127.0.0.1:8000:8000" # step 7 only nginx on this host
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
logging: # step 11 (repeats daemon.json, in case the box moves)
options: { max-size: "10m", max-file: "3" }
restart: unless-stopped
db:
image: postgres:16.6 # pinned minor+patch
user: "999:999" # the image's postgres user
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql # postgres socket
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 768m
pids_limit: 200
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # the image supports *_FILE
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data # the only persistent writable place
# no ports: the app reaches it over the compose network; nothing opens on the host
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 5
logging:
options: { max-size: "10m", max-file: "3" }
restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password # chmod 600, listed in .gitignore
volumes:
pgdata:
No /var/run/docker.sock, no privileged, no network_mode: host. If db fails to start because of read_only, the log tells you where it wanted to write; add a tmpfs there.
Before / after
| Item | Default | After |
|---|---|---|
| User in container | root | uid 10001 / 999 |
| Capabilities | Docker’s default set (~14) | none |
| Disk writes | anywhere | tmpfs and the data volume only |
| Escalation via setuid | possible | blocked |
| Memory / processes | unlimited | 512m-768m / 200 |
| App port | 0.0.0.0, whole internet, bypasses ufw | 127.0.0.1 only |
| Database port | often 5432 to the internet | not published |
| DB password | in environment, visible in inspect | file in /run/secrets |
| Image | :latest | pinned tag + weekly rebuild + scan |
| Logs | unbounded | 30MB per container |
Common mistakes
- Trusting
ufw statusand never scanning from another machine - Publishing the database port “so DBeaver can connect”; use an SSH tunnel instead
- Setting
cap_drop: [ALL]while still running as root in the image; root without capabilities can still read every file in the container - Giving an auto-updater or dashboard the socket without treating it as root on the host
- Setting
max-sizein daemon.json and not recreating old containers, which keep the unlimited setting
Sources
FAQ
Will all of this break my app?
read_only breaks most often, because an app or library writes temp files somewhere you did not expect. Read the start-up log and add a tmpfs for that path instead of removing read_only. cap_drop ALL needs nothing added back for an ordinary web app that runs as non-root and listens above port 1024.
Can I use rootless Docker instead of all this?
Rootless greatly reduces the impact of a container escape and combines with everything here, but it has limits around networking and ports below 1024. On a small single-admin server, do this list first and try rootless once it is familiar.
How do I check that it is all in place?
docker inspect shows User, CapDrop, ReadonlyRootfs, SecurityOpt and Memory for a running container. For ports, run nmap or curl from another machine against the public IP; never trust ufw status on the server itself.
Commands and settings were tested on sample systems. Try them on a test machine before production.