Articles › Security

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.

Intermediate 10 min read October 21, 2026

What you will learn
  • 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.

Host: updates + ufw (mind the Docker bypass)Container: non-root · cap_drop ALL · read-onlyApp: secrets from files, not the image
The innermost ring is what an attacker gets if the app breaks; keep it small

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 machinemem_limitpids_limit
Python/Node web app512m200
PostgreSQL768m200
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 :ro does not help. :ro forbids 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:

  1. env_file: .env with chmod 600 and a line in .gitignore (still visible in inspect, but not in the image)
  2. 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; the postgres image already supports POSTGRES_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 status says 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:

  1. Do not publish what need not be open. Step 7 covers 90% of cases.
  2. Add rules to the DOCKER-USER chain. 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

ItemDefaultAfter
User in containerrootuid 10001 / 999
CapabilitiesDocker’s default set (~14)none
Disk writesanywheretmpfs and the data volume only
Escalation via setuidpossibleblocked
Memory / processesunlimited512m-768m / 200
App port0.0.0.0, whole internet, bypasses ufw127.0.0.1 only
Database portoften 5432 to the internetnot published
DB passwordin environment, visible in inspectfile in /run/secrets
Image:latestpinned tag + weekly rebuild + scan
Logsunbounded30MB per container

Common mistakes

  • Trusting ufw status and 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-size in 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.