Yann M. Vidamment · blog

A hardened compose file for a web app

1,783 words 9 min read

A hardened image goes a long way: small, non-root, no package manager, no shell to log into (here is how I build one). None of that decides what the running container can touch on the host, reach on the network, or leak when someone runs docker inspect. That is the compose file’s job, and most compose files I see give all three away by default.

Same app as before: a Node server with nginx in front, shipped as one image. Here is the compose file almost everyone writes first.

services:
app:
image: ghcr.io/you/app:latest
restart: unless-stopped
environment:
API_KEY: super-secret-value
ports:
- '8080:8080'

It runs. It also gives the container a writable filesystem, the full default set of Linux privileges, a secret that anyone with Docker access can read back, and a port punched straight through the host firewall. Four lines fix each of those.

Freeze the filesystem

read_only: true
tmpfs:
- /tmp:size=64m,mode=1777

read_only: true mounts the container’s root filesystem as read-only. The app runs, but nothing can write to the image: no dropping a payload, no rewriting a config, no swapping a binary. A whole class of “attacker lands and modifies the running container” stops at the door.

Real programs do need to write somewhere. nginx writes its process id and a few temp files, the app might cache. tmpfs mounts a small scratch disk in memory for exactly those paths, writable but wiped on restart and capped at 64 MB so it cannot grow to eat the host’s memory. mode=1777 is the permission that lets any user write to it, the same one /tmp carries on a normal system. The image’s nginx config points its pid and temp paths at /tmp for this reason, which is why the whole setup runs with the rest of the disk frozen.

Drop every privilege

cap_drop: ['ALL']
security_opt:
- no-new-privileges:true

Root’s power on Linux is split into about forty pieces called capabilities: bind a port below 1024, change a file’s owner, load a kernel module, and so on. A container gets a default handful of them even when it is not running as root. This app needs none: it runs as a normal user, listens on 8080, and does no privileged work. cap_drop: ['ALL'] takes the whole set away.

no-new-privileges:true is the matching lock. It stops any process in the container from gaining more privileges than it started with, the trick a setuid binary would pull to climb back to root. The image already strips those binaries; this makes sure the kernel refuses the move even if one slips back in.

Publish no ports

This is the line that surprises people:

expose:
- '8080'
networks:
- edge

There is no ports: mapping. A published port like 8080:8080 does more than open a port. Docker writes its own firewall rules to forward it, in a chain that sits in front of the host firewall. So a port you publish is reachable from outside the machine even when ufw or firewalld is set to deny it. People lock down the host, publish a port, and undo the lock without noticing.

expose publishes nothing. It is documentation, a note that the container listens on 8080. To reach it, the container joins a Docker network that the reverse proxy also sits on, and the proxy forwards traffic to it by name over that internal network. The host firewall only ever has to allow the proxy’s ports, 80 and 443. The app is not on the host’s network at all.

Two ways to expose a container. With a published port, traffic from the internet reaches the host port through Docker's own firewall rules, which sit in front of ufw and bypass it. With a shared network, traffic reaches the reverse proxy on 443, and the proxy forwards to the container over an internal Docker network; the host firewall only opens the proxy's ports.

networks:
edge:
external: true

external: true means the network already exists, created by the proxy’s own stack, and this container joins it rather than defining its own. The proxy and the app meet on that shared network and nowhere else.

You no longer see the client’s IP

One consequence comes with putting a proxy in front: every request now arrives from the proxy’s address, not the visitor’s. Anything that works per client IP, rate limiting, an allow list, useful logs, sees one address for the whole world.

The proxy forwards the real address in an X-Forwarded-For header, and nginx can take it back, but only if you tell it which sources to trust:

set_real_ip_from 10.0.0.0/8; # the proxy's network, never the public internet
real_ip_header X-Forwarded-For;

The restriction matters. Trust that header from anyone and any visitor can forge their own IP by setting it, which turns your rate limiter and your logs into fiction. Trust it only from the proxy’s network, the one place the header is real.

Keep the secret out of docker inspect

The first compose file put API_KEY straight in environment. Environment variables feel private. They are not: docker inspect prints them back, they show up in /proc/1/environ, and they ride along into crash dumps and logs. Anyone who can talk to the Docker socket can read every secret you set this way.

The fix is to pass the secret as a file instead of a value.

environment:
API_KEY_FILE: /run/secrets/api_key
secrets:
- api_key
secrets:
api_key:
file: ./secrets/api_key.txt

Docker mounts the file at /run/secrets/api_key, and the app reads the key from there. Many images already support the _FILE convention; the image’s entrypoint reads API_KEY_FILE if it is set and falls back to the plain variable otherwise. Now docker inspect shows a path, not the key.

Cap what a bad day can cost

pids_limit: 256
mem_limit: 512m
cpus: 1.0
ulimits:
nofile:
soft: 4096
hard: 8192
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

A memory leak, a fork bomb, a runaway loop: without limits, one container can drag the whole host down with it. mem_limit, cpus and pids_limit draw a box around it. The worst case becomes a container that gets killed and restarted, not a host that falls over. ulimits.nofile caps open file descriptors in the same spirit.

logging rotates the container’s logs at 10 MB across three files, so a chatty app cannot fill the disk one line at a time, a real way servers die. stop_grace_period: 30s gives the container half a minute to shut down cleanly on docker stop before Docker forces it, which pairs with the tini init in the image that turns that signal into a clean exit.

The whole file

name: app
services:
app:
image: ghcr.io/you/app:${TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
API_KEY_FILE: /run/secrets/api_key
secrets:
- api_key
expose:
- '8080'
networks:
- edge
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: ['ALL']
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 512m
cpus: 1.0
stop_grace_period: 30s
ulimits:
nofile:
soft: 4096
hard: 8192
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:8080/healthz || exit 1']
interval: 30s
timeout: 3s
retries: 3
start_period: 20s
secrets:
api_key:
file: ./secrets/api_key.txt
networks:
edge:
external: true

The healthcheck repeats what the image already declares, so the platform restarts a container that stops answering. Pin TAG to a specific build for a deploy you can roll back, the same way the image pins its base.

What this costs

A read-only filesystem breaks any app that writes to disk: an upload folder, a SQLite database, a cache on disk. Those need a named volume, which is a real mount you decide on rather than the blanket freeze here. The freeze suits a stateless web app; a stateful one needs you to carve out exactly what it writes.

Publishing no ports means the container is useless until the proxy and its network exist. On a fresh host you set up the proxy first, and a quick local test needs you to attach to the network by hand instead of hitting localhost:8080. You trade convenience for a container the host firewall cannot accidentally expose.

Secrets as files add a step and a file to manage, and the limits can throttle a legitimate spike, not only an attack. Set mem_limit too low and a real traffic burst gets your container killed mid-request. The numbers here fit a small app; measure yours.

Your app is not this app

The shape is a stateless service behind a shared-network proxy. A stateful app adds volumes for the data it owns. An app that has to be reachable on its own, with no proxy in front, needs a published port, and then the firewall point becomes a thing to handle on purpose rather than avoid. Read each line as a question about your app, what it writes, what must reach it, what it would leak, not as a block to paste whole.

What you end up with

A container that cannot write to its own image, holds no Linux privileges, never touches the host network, and keeps its secret in a file instead of its config. Paired with the hardened image, a bug in the app now runs as a powerless user, on a frozen disk, behind a firewall it cannot punch through.

The image is built and the run is locked down. Building and publishing that image on every push, instead of by hand, is a job for CI.

Further reading

More on the runtime lockdown, and the firewall trap under it:

  • The OWASP Docker Security Cheat Sheet is the runtime checklist behind most of this file: dropped capabilities, resource limits, and the seccomp and AppArmor profiles a next pass would add.
  • chaifeng/ufw-docker explains the firewall bypass behind “publish no ports”, and fixes it for the day you have to publish one after all.
  • Docker Compose secrets is the full reference for the secret-as-a-file pattern, including the environment-sourced variant.