A default Docker installation is built for convenience, not for production: containers run as root, keep a broad set of Linux capabilities, can write to their own filesystem and, when published with -p, are reachable from the internet even if your firewall says otherwise. In this tutorial you will harden a Docker host running Ubuntu 24.04 layer by layer: the daemon, the images you build, the way containers run, their networks and their secrets. Every step includes a command to verify the change, and the guide ends with an automated audit against the CIS Docker Benchmark.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A test server or a maintenance window: Step 1 restarts the Docker daemon, which restarts running containers unless
live-restoreis already enabled.
Step 1 - Protecting the Docker daemon
The Docker daemon runs as root, and anyone who can talk to its socket, /var/run/docker.sock, effectively has root on the host. Start by reviewing who has that access.
List the members of the docker group:
getent group docker
docker:x:988:deploy
Every user listed here can start a container that mounts the host's / and edits any file. Remove users who do not need it with sudo gpasswd -d username docker, and let occasional users run sudo docker instead, which is logged.
Next, make sure the daemon is not listening on TCP. An unauthenticated TCP socket on port 2375 lets anyone on the network take over the host:
sudo ss -tlnp | grep dockerd
This command should print nothing. If you see port 2375 or 2376, remove the -H tcp://... option from /etc/docker/daemon.json or from any systemd override in /etc/systemd/system/docker.service.d/. For remote management, use SSH instead: DOCKER_HOST=ssh://your_user@your_server_ip docker ps works from any machine with the Docker CLI.
Now set safer defaults for every container. Open the daemon configuration file:
sudo nano /etc/docker/daemon.json
{
"icc": false,
"no-new-privileges": true,
"live-restore": true,
"userland-proxy": false,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
What each option does:
icc: falseblocks traffic between containers on the defaultbridgenetwork. Containers that need to talk to each other should share a user-defined network (Step 5), where you control membership.no-new-privileges: truestops processes inside containers from gaining privileges through setuid binaries such assudoorsu.live-restore: truekeeps containers running while the daemon restarts, so future daemon upgrades do not cause downtime.userland-proxy: falseuses iptables rules instead of adocker-proxyprocess per published port.log-optscaps container logs at 30 MB each so a noisy container cannot fill the disk.
If daemon.json already exists, merge these keys into it instead of replacing the file. Restart Docker and confirm it started cleanly:
sudo systemctl restart docker
sudo systemctl is-active docker
active
If the result is failed, run sudo journalctl -u docker -n 20 to see the error; it is almost always invalid JSON.
Verify that the new defaults apply to new containers:
docker run --rm alpine grep NoNewPrivs /proc/self/status
NoNewPrivs: 1
NoteFor the strongest isolation, Docker can run entirely without root in rootless mode, or remap container root to an unprivileged host user with the
userns-remapdaemon option. Both break some workloads (privileged containers, certain volume permissions, ports below 1024 in rootless mode), so test them before enabling them on an existing server.
Step 2 - Building minimal, non-root images
The image is where most vulnerabilities come from: every package in it is code an attacker might use. Smaller images built from pinned, official bases have fewer packages to patch, and an image that declares a non-root USER runs without root even if someone forgets --user.
The following Dockerfile for a Go service applies these ideas with a multi-stage build. The first stage has the full toolchain; the final image contains only the compiled binary on a distroless base, which has no shell or package manager, and runs as the built-in nonroot user:
# Build stage: full Go toolchain, never shipped
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
# Runtime stage: only the binary
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
A few rules that apply to any language:
- Pin the base image. Use an explicit tag such as
python:3.12-slim-bookworminstead oflatest, and for fully reproducible builds pin the digest as well (image@sha256:...). Rebuild regularly so you pick up security patches. - Add a
.dockerignorethat excludes.git,.env, local credentials and build artifacts, soCOPY . .cannot leak them into the image. - Never put secrets in
ENV,ARGor copied files. They stay in the image layers and indocker history. If the build needs a token, for example to download private packages, mount it only for that step with a BuildKit secret:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
docker build --secret id=npm_token,src="$HOME/.npm_token" -t your_app .
After building, confirm the image does not run as root:
docker image inspect --format '{{.Config.User}}' your_app
nonroot:nonroot
An empty result means the image runs as root.
Step 3 - Scanning images for vulnerabilities with Trivy
Trivy is an open source scanner that checks OS packages and language dependencies in an image against vulnerability databases. Install it from Aqua Security's APT repository:
sudo apt install wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /etc/apt/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update
sudo apt install trivy
Scan an image and show only the findings that matter most:
trivy image --severity HIGH,CRITICAL nginx:latest
nginx:latest (debian 12.11)
Total: 3 (HIGH: 3, CRITICAL: 0)
...
The report lists each vulnerable package, the CVE, the installed version and the version that fixes it. Fix findings by updating the base image tag or the affected dependency and rebuilding. The --ignore-unfixed flag hides vulnerabilities that have no patch yet, which keeps the list actionable.
To fail a CI pipeline when a critical vulnerability appears, use a non-zero exit code:
trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed your_app
Trivy returns 1 when it finds matching vulnerabilities, so the build stops before the image is pushed.
Step 4 - Running containers with least privilege
Even a clean image should run with as few privileges as possible, so that a compromised process cannot do much. The following command starts a simple web server with every relevant restriction applied:
docker run -d --name hardened \
--user 1000:1000 \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
--memory 256m \
--cpus 1 \
--pids-limit 100 \
-p 127.0.0.1:8000:8000 \
python:3.12-slim python -m http.server 8000 --directory /tmp
Each flag closes a specific door:
| Flag | What it prevents |
|---|---|
--user 1000:1000 | The process runs as an unprivileged user instead of root. |
--read-only | Attackers cannot drop binaries or modify the application inside the container. --tmpfs /tmp gives it a writable scratch area in memory. |
--cap-drop ALL | Removes all Linux capabilities, such as changing file ownership or using raw sockets. Add back only what the app needs, for example --cap-add NET_BIND_SERVICE to bind a port below 1024. |
--security-opt no-new-privileges | Blocks privilege escalation through setuid binaries. |
--memory, --cpus, --pids-limit | A runaway or compromised container cannot exhaust the host's memory, CPU or process table (fork bombs). |
-p 127.0.0.1:8000:8000 | The port is only reachable from the host itself, typically through a reverse proxy. |
Verify that the server works and that the restrictions are active:
curl -sI http://127.0.0.1:8000 | head -1
docker exec hardened id
docker exec hardened touch /test
HTTP/1.0 200 OK
uid=1000 gid=1000 groups=1000
touch: cannot touch '/test': Read-only file system
ImportantDocker writes its own iptables rules, so a port published as
-p 8000:8000is open to the internet even if UFW denies it. Bind to127.0.0.1whenever the service sits behind a reverse proxy on the same host.
Docker also applies its default seccomp and AppArmor profiles to every container. Never disable them with --security-opt seccomp=unconfined or --privileged to "fix" a permission error; find the specific capability or path the application needs instead.
Remove the test container:
docker rm -f hardened
In Docker Compose, the same restrictions are regular service keys:
services:
web:
image: python:3.12-slim
command: ["python", "-m", "http.server", "8000", "--directory", "/tmp"]
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 256m
cpus: 1.0
pids_limit: 100
ports:
- "127.0.0.1:8000:8000"
Step 5 - Isolating container networks
By default every container can reach every other container on the same network and the internet. Split your application into user-defined networks so that each container can only reach what it needs, and mark back-end networks as internal so they have no route out.
The following Compose file puts a database on an internal network that only the API can reach, while the reverse proxy can only reach the API:
services:
proxy:
image: nginx:stable
ports:
- "80:80"
networks:
- frontend
api:
image: your_api_image
networks:
- frontend
- backend
db:
image: postgres:17
networks:
- backend
networks:
frontend:
backend:
internal: true
With this layout, proxy cannot talk to db, and db cannot open connections to the internet, which limits what an attacker can do from a compromised database container. After running docker compose up -d, you can check the isolation from inside the proxy container:
docker compose exec proxy getent hosts db
The command prints nothing, because db is not on any network the proxy is attached to.
Step 6 - Keeping secrets out of images and environment variables
Environment variables are better than hardcoded secrets, but they are still visible to anyone who can run docker inspect, and they often end up in logs and crash reports. Docker Compose can mount secrets as files instead, without Swarm. Many official images, including postgres, mysql and mariadb, read their passwords from a file through *_FILE variables.
Create the secret file with restrictive permissions:
mkdir -p ~/myapp/secrets
cd ~/myapp
openssl rand -base64 32 > secrets/db_password.txt
chmod 600 secrets/db_password.txt
Reference it from compose.yaml:
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Start the service and confirm the password is not exposed in the container's configuration:
docker compose up -d
docker inspect --format '{{json .Config.Env}}' "$(docker compose ps -q db)"
["POSTGRES_PASSWORD_FILE=/run/secrets/db_password","PATH=/usr/local/sbin:...","PGDATA=/var/lib/postgresql/data"]
Only the path appears, not the password. Add the secrets/ directory to .gitignore and .dockerignore so it never reaches your repository or an image.
Step 7 - Auditing the host with Docker Bench for Security
Docker Bench for Security is a script from Docker that checks a host against the CIS Docker Benchmark: daemon configuration, file permissions, images and running containers. Clone it and read the script before running it as root:
git clone https://github.com/docker/docker-bench-security.git
cd docker-bench-security
sudo sh docker-bench-security.sh
[INFO] 2 - Docker daemon configuration
[PASS] 2.2 - Ensure network traffic is restricted between containers on the default bridge
...
[WARN] 5.12 - Ensure that the container's root filesystem is mounted as read only
...
Section C - Score
[INFO] Checks: 105
[INFO] Score: 18
Work through the [WARN] lines: many will already be fixed by the previous steps, and each check number maps to a section of the CIS benchmark with an explanation. Some warnings, such as separate partitions for /var/lib/docker or auditd rules, are worth fixing on long-lived production hosts but can be accepted on smaller servers. Re-run the script after changes to track your progress.
Production checklist
| Area | Check |
|---|---|
| Daemon | Only trusted users in the docker group, no TCP socket, daemon.json hardened |
| Images | Pinned official bases, multi-stage builds, non-root USER, no secrets in layers |
| Scanning | Trivy runs in CI and fails on critical vulnerabilities, images rebuilt regularly |
| Runtime | Read-only root filesystem, --cap-drop ALL, no-new-privileges, memory/CPU/PID limits |
| Network | Ports bound to 127.0.0.1 behind a proxy, back-end networks marked internal |
| Secrets | Mounted as files, excluded from Git and images |
| Host | Docker Engine and the OS updated, Docker Bench run after each major change |
| Socket | /var/run/docker.sock never mounted into application containers |
Conclusion
You restricted who can control the Docker daemon, set secure defaults for every container, built a minimal non-root image, scanned it for vulnerabilities, ran containers with no capabilities and a read-only filesystem, isolated their networks and moved secrets out of environment variables. Together these layers mean that a single vulnerability in an application is much less likely to become a compromise of the whole server.
As next steps, you can:
- Add the Trivy scan and your
docker runrestrictions to your CI/CD pipeline so every new service gets them by default. - Put a reverse proxy with TLS, such as Nginx or Caddy, in front of the services you bound to
127.0.0.1. - Evaluate rootless mode on a test server to remove root from the Docker daemon itself.
