Hardening Docker Containers: Rootless, Secrets, and Resource Limits
Make your server "production-ready". Part 5
Make your server "production-ready". Part 5
Containers isolate processes, but Docker's default settings are built for convenience, not security. Out of the box, many containers run as root, have unlimited access to RAM and CPU, and credentials travel through compose files in plain text. In this part of the "Make Your Setup Production-Ready" series, you'll secure your Docker setup along three dimensions: fewer privileges (non-root and rootless), protected credentials (secrets), and limited resources (limits), so that a compromised or broken container doesn't take the whole server down with it.
Start containers as a non-root user, trim capabilities down to the bare minimum, move credentials out of the compose file into a protected .env file or secrets, and set RAM and CPU limits for every container. If you want to go further, run the Docker daemon entirely rootless.
Two scenarios drive this tutorial. First, the breakout: a web application in a container has a vulnerability, and an attacker achieves code execution. If the container runs as root with full capabilities, the path to the host is far shorter than it needs to be. Second, the resource cascade: a single container with a memory leak eats up all the RAM, the kernel starts killing processes indiscriminately — and suddenly every service is down, not just the broken one. Both scenarios can be defused with just a few lines of configuration.
The single most important step. First check which of your containers are running as root:
An empty field after the name means: running as root (unless the image specifies otherwise). Many official images already include a non-root user, Grafana, for example, runs as user 472 out of the box. Where that's not the case, enforce it in the compose file:
Important: the specified user needs write access to the mounted volumes. For bind mounts, adjust ownership on the host (chown -R 1000:1000 ./data); for named volumes, the image usually handles this on first start. If a container fails with "Permission denied" after this change, that's almost always the cause.
Even a root process inside a container doesn't automatically have all privileges. Docker grants a default set of kernel capabilities. The production-ready approach is the reverse: strip everything, then grant back only what's needed. For most web applications, this is enough:
The four building blocks in detail: cap_drop: ALL strips all special privileges; if a service does need one (say, NET_BIND_SERVICE for ports below 1024), you grant back only that one via cap_add. no-new-privileges prevents processes inside the container from gaining additional privileges through setuid binaries. read_only makes the container's filesystem immutable — an attacker can't load additional files or swap out binaries. And tmpfs gives the application a writable location for temporary files, which many still need.
Work through this service by service: set the option, recreate the container, check the logs. Not every application tolerates read_only, so it's better to make specific paths writable via a volume than to drop the option entirely.
Storing passwords directly in the environment: block (as our Grafana example from Part 1 did, for simplicity) has two problems: they end up in Git, in backups, and in every docker inspect. The pragmatic solution is a .env file next to the compose file:
In the compose file, you then just reference the values:
Docker Compose reads the .env file automatically. That way, the compose file itself is free of secrets and can be safely versioned.
Compose secrets go a step further: the value lives in its own file and is exposed to the container only as a file under /run/secrets/ — meaning it shows up neither in the environment nor in docker inspect:
This requires the application to support _FILE variants of its variables — many official images (PostgreSQL, MySQL, Nextcloud) do. Where they don't, a .env file with chmod 600 remains the solid default.
Without limits, any container can claim the entire server. So set an upper bound for every service:
memory is the most important limit: if the container exceeds it, the kernel specifically kills that container (OOM kill) — instead of picking some random process on the host. Thanks to restart: unless-stopped, it then restarts, while every other service keeps running unaffected. cpus caps compute time relative to a core (1.0 = one core), and pids_limit guards against fork bombs.
What values are right? Measure the current state instead of guessing:
Take the observed peak RAM usage plus roughly 50% headroom. For the monitoring stack from Part 1, around 512M for Prometheus, 256M for Grafana, and 128M each for the exporters are realistic starting values. And you can actually see whether a container is hitting its limit right within that same stack: cAdvisor supplies the metric, Grafana displays it and a Prometheus alert can warn you before the OOM killer strikes.
Two hardening measures at the network level round things out. First: one network per stack instead of one big network for everything. Containers can only see each other within their own network, so the web app from Project A can't even talk to the database from Project B in the first place. Mark pure backend networks (say, between app and database) additionally with internal: true, and there's then no path from them to the internet at all. And as throughout this series: bind ports only to 127.0.0.1 and leave access to the reverse proxy.
Second, the Docker socket: mounting /var/run/docker.sock into a container effectively grants root access to the host — whoever controls the socket can start privileged containers. Only mount it where it's genuinely needed (Traefik from Part 3, Alloy from Part 2), always read-only, and never into containers that are themselves publicly reachable. For exposed setups, a socket proxy that filters the Docker API down to read-only access is worthwhile.
Everything so far hardens containers within a daemon that itself still runs as root. Rootless Docker eliminates that remaining risk too: the entire daemon runs as a regular user, so a breakout ends, at worst, at that user's privileges instead of root's. Setup is manageable:
Afterward, the shell displays the required environment variables (DOCKER_HOST), which you add to your .bashrc, and with loginctl enable-linger $USER the daemon keeps running even without an active SSH session.
An honest assessment: rootless comes with trade-offs. Ports below 1024 require extra configuration, network performance is somewhat lower, and tools that expect the privileged socket (cAdvisor from Part 1 is one of them) need adjustments. For a server running third-party or particularly exposed workloads, it's worth it. For a typical self-hosting setup, Steps 1–5 are the far better effort-to-benefit deal — rootless is the bonus round, not a requirement.
The best hardening won't help much if the image itself is compromised or ancient. Pin versions instead of pulling :latest (you should adjust our examples from Parts 1–4 accordingly for long-term operation), source images only from trusted registries, and update regularly via docker compose pull && docker compose up -d.
Your Docker setup now follows the principle of least privilege: containers run without root and without unnecessary capabilities, credentials no longer sit in plain text in versioned files, and resource limits ensure that a single outlier doesn't bring down the whole server. Together with monitoring (Part 1), logging (Part 2), and uptime checks (Part 4), you'll not only notice when something goes wrong — significantly less will go wrong in the first place.
In the final part of our "Production-Ready Server" series, things get serious: migrating servers with zero downtime — how to move your data and services to a new machine without your users noticing a thing.
No — and you shouldn't. Work through it service by service: non-root user first, then limits, then capabilities, testing after each step. That way you immediately find out which option is causing a problem. The order of steps in this article also doubles as a sensible priority list.
The two most common causes: missing write permissions on volumes after switching users (docker logs shows "Permission denied," in which case you need to adjust ownership with chown) and an overly strict read_only setting (the app needs a writable path — grant it selectively via tmpfs or a volume).
VPS on AMD EPYC with NVMe SSD, full control, hosted in Germany.
Configure VPS
The kernel kills the container via OOM kill, and the restart policy brings it back up. Every other service remains unaffected. That's exactly the point: a controlled, local failure instead of an uncontrolled one across the whole host. You can spot repeated OOM kills in docker inspect and in the monitoring setup from Part 1.
No. user: determines who the process inside the container runs as, but the Docker daemon itself remains root. Rootless Docker also runs the daemon itself as a regular user. The former is the important everyday protection with minimal effort; the latter is the additional safeguard for elevated requirements.
docker ps -q | xargs docker inspect --format '{{.Name}}: {{.Config.User}}'services:
my-app:
image: my-app:1.4.2
user: "1000:1000"services:
my-app:
image: my-app:1.4.2
user: "1000:1000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp# ~/monitoring/.env
GRAFANA_ADMIN_PASSWORD=a-long-random-password
SMTP_PASSWORD=another-random-passwordchmod 600 .env
echo ".env" >> .gitignore grafana:
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}services:
my-app:
image: my-app:1.4.2
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txtservices:
my-app:
image: my-app:1.4.2
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
pids_limit: 200docker stats --no-streamsudo apt install -y uidmap dbus-user-session
dockerd-rootless-setuptool.sh install