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 sudo privileges.
  • 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-restore is 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: false blocks traffic between containers on the default bridge network. Containers that need to talk to each other should share a user-defined network (Step 5), where you control membership.
  • no-new-privileges: true stops processes inside containers from gaining privileges through setuid binaries such as sudo or su.
  • live-restore: true keeps containers running while the daemon restarts, so future daemon upgrades do not cause downtime.
  • userland-proxy: false uses iptables rules instead of a docker-proxy process per published port.
  • log-opts caps 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

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-bookworm instead of latest, and for fully reproducible builds pin the digest as well (image@sha256:...). Rebuild regularly so you pick up security patches.
  • Add a .dockerignore that excludes .git, .env, local credentials and build artifacts, so COPY . . cannot leak them into the image.
  • Never put secrets in ENV, ARG or copied files. They stay in the image layers and in docker 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:

FlagWhat it prevents
--user 1000:1000The process runs as an unprivileged user instead of root.
--read-onlyAttackers cannot drop binaries or modify the application inside the container. --tmpfs /tmp gives it a writable scratch area in memory.
--cap-drop ALLRemoves 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-privilegesBlocks privilege escalation through setuid binaries.
--memory, --cpus, --pids-limitA runaway or compromised container cannot exhaust the host's memory, CPU or process table (fork bombs).
-p 127.0.0.1:8000:8000The 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

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

AreaCheck
DaemonOnly trusted users in the docker group, no TCP socket, daemon.json hardened
ImagesPinned official bases, multi-stage builds, non-root USER, no secrets in layers
ScanningTrivy runs in CI and fails on critical vulnerabilities, images rebuilt regularly
RuntimeRead-only root filesystem, --cap-drop ALL, no-new-privileges, memory/CPU/PID limits
NetworkPorts bound to 127.0.0.1 behind a proxy, back-end networks marked internal
SecretsMounted as files, excluded from Git and images
HostDocker 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 run restrictions 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.