Secure Your Docker Host: Rootless, Hardened, and Upgradable
A hardened container host is not simply a normal server with Docker installed. The daemon’s privilege boundary, published ports, credentials, logs, backups, and upgrade path all become part of the production design.
This tutorial builds a deliberately small but complete system: a rootless Docker daemon runs an authenticated Nginx service on port 8080, while nftables limits access to approved networks. The workload has resource limits, rotated logs, health checks, a protected secret, consistent backups, and a tested rollback path.
Prerequisites and operating assumptions
Use a dedicated, systemd-based Debian or Ubuntu host with cgroup v2, a stable IP address, working time synchronization, and administrative access through a console or an existing SSH session. The examples assume the official Docker package repository is already configured.
You will need two real network ranges:
- An administration network permitted to reach SSH.
- A service or reverse-proxy network permitted to reach port 8080.
The examples use 10.20.0.0/16 and 10.30.0.0/16. Replace them before applying the firewall. Port 8080 carries plain HTTP, so expose it only to a trusted private network, VPN, or TLS-terminating reverse proxy. Do not publish it directly to an untrusted Internet path.
Keep the current administrative session open while changing firewall rules. A syntax check cannot detect an incorrect source network.
Architecture and trade-offs
Docker runs as the unprivileged account appsvc. Container root is mapped into that account’s subordinate user-ID range rather than host root. A container escape therefore does not immediately become a root compromise of the host.
Rootless mode has costs. Its user-space networking path can consume more CPU than native bridge networking, low-numbered ports require additional design, and resource limits depend on cgroup v2 delegation. These are reasonable trade-offs for an internal web workload, but benchmark high-throughput or latency-sensitive services before standardizing on the pattern.
The host layout is intentionally explicit:
/home/appsvc/edge-site/
├── compose.yaml
├── nginx/
│ └── nginx.conf
└── site/
└── index.html
/home/appsvc/.config/edge-site/
└── htpasswd
/home/appsvc/backups/
└── edge-site-TIMESTAMP.tar.zst
Install the rootless runtime
Run the following as host root. The dedicated account has no password and should not receive broad sudo access.
apt-get update
apt-get install -y \
docker-ce-cli docker-ce-rootless-extras docker-compose-plugin \
uidmap dbus-user-session slirp4netns fuse-overlayfs \
nftables openssl zstd curl
adduser --disabled-password --gecos "" appsvc
loginctl enable-linger appsvc
install -d -o appsvc -g appsvc -m 0750 \
/home/appsvc/edge-site \
/home/appsvc/edge-site/nginx \
/home/appsvc/edge-site/site
install -d -o appsvc -g appsvc -m 0700 \
/home/appsvc/.config/edge-site \
/home/appsvc/backups
Confirm that appsvc has entries in both /etc/subuid and /etc/subgid. Do not invent overlapping ranges manually; use the account-management mechanism appropriate to the distribution if either entry is absent.
Start the lingering user manager, then install Docker as appsvc. These commands are issued by root, but the setup tool and daemon run under the service account:
app_uid="$(id -u appsvc)"
test -n "$app_uid"
test -S "/run/user/$app_uid/bus"
sudo -u appsvc \
XDG_RUNTIME_DIR="/run/user/$app_uid" \
dockerd-rootless-setuptool.sh check
sudo -u appsvc \
XDG_RUNTIME_DIR="/run/user/$app_uid" \
dockerd-rootless-setuptool.sh install
sudo -u appsvc \
XDG_RUNTIME_DIR="/run/user/$app_uid" \
systemctl --user enable --now docker
If the bus socket is not present, start the appsvc user manager through loginctl or create a real login session, then repeat the check. Do not fall back to a root-owned Docker socket merely to bypass a user-session problem.
Build the workload
Create the authentication secret
Run this and all subsequent Docker commands as appsvc. The password is read without terminal echo and is passed to OpenSSL through standard input rather than a command-line argument.
cd /home/appsvc/edge-site
umask 077
IFS= read -r -s -p "Operator password: " site_password
printf '\n'
test -n "$site_password"
password_hash="$(
printf '%s\n' "$site_password" | openssl passwd -6 -stdin
)"
unset site_password
printf 'operator:%s\n' "$password_hash" \
> /home/appsvc/.config/edge-site/htpasswd
unset password_hash
chmod 0600 /home/appsvc/.config/edge-site/htpasswd
A Compose secret is not a vault and is not encrypted merely because it appears under /run/secrets. Its value is that the credential stays out of the image, environment, Compose file, and process arguments. Protect its host file and every backup containing it.
Configure Nginx
Create /home/appsvc/edge-site/nginx/nginx.conf with this configuration:
user nginx;
worker_processes auto;
error_log /dev/stderr info;
pid /run/nginx/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout combined;
sendfile on;
keepalive_timeout 30;
server_tokens off;
server {
listen 8080;
root /usr/share/nginx/html;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
auth_basic "Restricted";
auth_basic_user_file /run/nginx/htpasswd;
try_files $uri $uri/ =404;
}
}
}
Create site/index.html containing a simple paragraph such as <p>Rootless service is healthy.</p>. In a real deployment, this directory can hold generated documentation or another read-only site.
Define the container
Create /home/appsvc/edge-site/compose.yaml:
services:
site:
image: docker.io/library/nginx:stable-alpine
container_name: edge-site
restart: unless-stopped
ports:
- "0.0.0.0:8080:8080"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./site:/usr/share/nginx/html:ro
secrets:
- basic_auth
command:
- /bin/sh
- -ec
- |
cp /run/secrets/basic_auth /run/nginx/htpasswd
chmod 0444 /run/nginx/htpasswd
exec nginx -g 'daemon off;'
read_only: true
tmpfs:
- /run/nginx:size=1m,mode=0755
- /var/cache/nginx:size=16m,mode=0755
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
pids_limit: 100
mem_limit: 256m
cpus: 1.0
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: local
options:
max-size: "10m"
max-file: "5"
secrets:
basic_auth:
file: /home/appsvc/.config/edge-site/htpasswd
The image’s narrowly scoped capabilities exist inside the rootless user namespace. They allow Nginx to initialize directories and drop privileges, but they do not grant corresponding host capabilities. The read-only root filesystem and bounded temporary filesystems reduce both persistence opportunities and uncontrolled disk growth.
Start the service:
cd /home/appsvc/edge-site
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
Enforce the host firewall
Rootless Docker does not provide a substitute for host ingress policy. Create /etc/nftables.d/edge-host.nft as root, after replacing both example networks:
table inet edge_host {
chain input {
type filter hook input priority 0;
policy drop;
ct state invalid drop
ct state established,related accept
iifname "lo" accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
ip saddr 10.20.0.0/16 tcp dport 22 accept
ip saddr 10.30.0.0/16 tcp dport 8080 accept
}
chain forward {
type filter hook forward priority 0;
policy drop;
}
chain output {
type filter hook output priority 0;
policy accept;
}
}
Inspect existing nftables, firewalld, or UFW ownership before continuing. Multiple firewall managers should not compete for the same hooks. Validate the fragment before loading it:
nft --check --file /etc/nftables.d/edge-host.nft
nft --file /etc/nftables.d/edge-host.nft
nft list table inet edge_host
For persistence, ensure /etc/nftables.conf contains exactly one include "/etc/nftables.d/*.nft" line, validate the complete file with nft --check --file /etc/nftables.conf, and enable nftables.service. Test a second SSH connection from the approved administration network before closing the original session.
Test security and failure paths
From an allowed service-network machine, verify health, rejection, and successful authentication:
curl --fail --silent --show-error --max-time 2 \
http://HOST_ADDRESS:8080/healthz
test "$(
curl --silent --output /dev/null \
--write-out '%{http_code}' \
--max-time 2 \
http://HOST_ADDRESS:8080/
)" = "401"
curl --fail --user operator \
--max-time 5 \
http://HOST_ADDRESS:8080/
Also test from a network outside the permitted range; the TCP connection should time out or be dropped. Stop the user daemon and confirm the service becomes unavailable, then restart it with systemctl --user start docker. Finally, reboot the host and verify that lingering starts both the rootless daemon and the restart-managed container.
Logging, observability, and capacity
Use docker compose logs --since 15m site for access and error output, and journalctl --user -u docker --since today for daemon events. The local logging driver rotates container logs, but rotation is not centralized retention. Forward selected host and application logs to a remote collector if incident investigation must survive host loss.
Monitor container health, restart count, memory, process count, filesystem usage, and the rootless daemon’s user service. docker stats --no-stream provides a useful spot check, not durable monitoring.
If memory or CPU limits appear ineffective, verify that the host uses cgroup v2 and that the user manager delegates controllers. Do not silently remove limits: treat unavailable enforcement as a deployment failure or document an explicit capacity-control alternative.
Create and test consistent backups
The example is static, but the backup procedure stops the workload to establish a clear consistency boundary. Database-backed services should normally use their native snapshot or dump mechanism instead of copying live data files.
cd /home/appsvc/edge-site
umask 077
backup_dir=/home/appsvc/backups
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
archive="$backup_dir/edge-site-$stamp.tar.zst"
test -d "$backup_dir"
test ! -e "$archive"
docker compose stop
trap 'docker compose start' EXIT
tar --create --zstd --file="$archive" \
compose.yaml nginx site \
-C /home/appsvc .config/edge-site
sha256sum "$archive" > "$archive.sha256"
docker compose start
trap - EXIT
restore_dir="$(mktemp -d /home/appsvc/restore-test.XXXXXX)"
tar --extract --zstd --file="$archive" --directory="$restore_dir"
test -f "$restore_dir/compose.yaml"
test -f "$restore_dir/.config/edge-site/htpasswd"
Inspect the restoration directory and remove that exact directory when finished. Copy encrypted backups to separate storage with independent credentials and retention. A backup remaining only on the Docker host is not protection against host loss.
Upgrade with an automatic rollback
The mutable stable-alpine tag makes controlled updates convenient, while the previous local image ID provides a rollback target. Run upgrades from the project directory:
set -eu
cd /home/appsvc/edge-site
image_ref=docker.io/library/nginx:stable-alpine
old_image="$(docker inspect --format '{{.Image}}' edge-site)"
test -n "$old_image"
docker compose pull site
if ! docker compose up -d --force-recreate site; then
docker tag "$old_image" "$image_ref"
docker compose up -d --pull never --force-recreate site
exit 1
fi
healthy=false
for attempt in $(seq 1 30); do
if curl --fail --silent --output /dev/null --max-time 2 \
http://127.0.0.1:8080/healthz; then
healthy=true
break
fi
sleep 2
done
if test "$healthy" != true; then
docker compose logs --tail 100 site
docker tag "$old_image" "$image_ref"
docker compose up -d --pull never --force-recreate site
exit 1
fi
docker image inspect "$old_image" > /dev/null
docker compose ps
For a larger fleet, promote tested image digests through environments instead of allowing each host to pull a moving tag independently. Keep the previous image until application tests and an observation window have passed.
Common failures
- The rootless daemon disappears after logout: confirm
loginctl show-user appsvcreports lingering enabled and that the user service is enabled. - The container is healthy locally but unreachable remotely: inspect the published address with
docker port edge-site, then examine nftables counters and the client’s source address. - Nginx repeatedly restarts: inspect its logs. Typical causes are an unreadable secret, malformed configuration, or a missing writable tmpfs path.
- Limits are ignored or rejected: check cgroup v2 and user-service delegation rather than assuming rootless mode can enforce controllers unavailable to the account.
- An upgrade fails before health testing: preserve the old image ID and run the documented rollback. Do not prune images as part of the upgrade transaction.
Final verification checklist
- The Docker daemon runs as
appsvc, not root, and survives a reboot. - Only approved networks can reach SSH and port 8080.
- Unauthenticated application requests return HTTP 401.
- The health endpoint succeeds with a bounded timeout.
- The container has a read-only root filesystem, minimal capabilities, and resource limits.
- Logs rotate locally and important events have an external retention plan.
- A backup checksum exists and a restoration was successfully extracted.
- The previous image remains available and rollback has been exercised.
Hardening works best when it changes routine operations, not just installation defaults. A rootless daemon limits privilege, but the firewall limits exposure; secrets discipline limits leakage; rotation limits disk pressure; backups limit loss; and rehearsed rollback limits upgrade risk. The durable result is not an invulnerable host. It is a host whose failures are constrained, visible, and recoverable.