Skip to main content

Rootless Docker — Run Containers Without Root Privileges

· 8 min read
Goel Academy
DevOps & Cloud Learning Hub

By default, the Docker daemon runs as root. Every container you start has root-level access to the host kernel. If an attacker escapes the container — through a kernel vulnerability, a misconfigured volume mount, or a privileged container — they land on the host as root. Game over. Rootless mode eliminates this risk by running both the Docker daemon and containers under a regular, unprivileged user account.

Why Root Docker Is Dangerous

The default Docker installation creates a real security gap that most people accept without thinking about it.

# Default Docker — the daemon runs as root
ps aux | grep dockerd
# root 1234 dockerd --group docker

# Any user in the "docker" group can run containers
# This is equivalent to giving them root access
docker run -v /:/host ubuntu:latest cat /host/etc/shadow
# ☝️ A "non-root" user just read the host's password hashes

The Docker group is effectively a root-equivalent group. Anyone in the docker group can mount the host filesystem, read any file, modify system configuration, or install rootkits — all through container commands that look innocuous.

# Container escape via Docker socket mount
docker run -v /var/run/docker.sock:/var/run/docker.sock \
docker:cli docker run --privileged --pid=host \
ubuntu:latest nsenter -t 1 -m -u -i -n bash
# You are now root on the host

Setting Up Rootless Docker

Rootless mode runs the entire Docker daemon — dockerd, containerd, and all containers — under your regular user account.

# Prerequisites — install required packages
sudo apt-get install -y uidmap dbus-user-session

# Check that your user has subordinate UID/GID ranges
grep $USER /etc/subuid
# vivek:100000:65536

grep $USER /etc/subgid
# vivek:100000:65536

# If these files are empty, add entries:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
# Install rootless Docker
# Option 1: If Docker is already installed
dockerd-rootless-setuptool.sh install

# Option 2: Fresh install using the official script
curl -fsSL https://get.docker.com/rootless | sh

# The script outputs something like:
# [INFO] Creating /home/vivek/.config/systemd/user/docker.service
# [INFO] Installed docker.service successfully.
# [INFO] To control docker.service, run:
# systemctl --user start docker
# systemctl --user enable docker
# Start the rootless daemon
systemctl --user start docker
systemctl --user enable docker

# Set the DOCKER_HOST environment variable
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock

# Add to your shell profile
echo 'export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock' >> ~/.bashrc

# Verify it is running rootless
docker info | grep "rootless"
# rootless
# Security Options: rootless

User Namespaces

Rootless Docker maps the root user inside the container (UID 0) to your unprivileged user on the host. This is done through Linux user namespaces.

# Inside the container, you appear to be root
docker run --rm alpine id
# uid=0(root) gid=0(root)

# But on the host, the process runs as your user (e.g., UID 1000)
# Plus an offset from /etc/subuid (e.g., 100000)
ps aux | grep "sleep infinity"
# vivek 12345 ... sleep infinity

# The mapping:
# Container UID 0 → Host UID 1000 (your user)
# Container UID 1 → Host UID 100000
# Container UID 2 → Host UID 100001
# Container UID 65534 → Host UID 165534
# View the namespace mapping for a running container
docker inspect --format '{{.State.Pid}}' mycontainer
# 12345

cat /proc/12345/uid_map
# 0 1000 1
# 1 100000 65536

Even if an attacker escapes the container as "root," they are actually UID 1000 on the host — a regular user with no special privileges.

Limitations of Rootless Mode

Rootless mode has real limitations. Understanding them upfront saves debugging time later.

# Cannot bind to ports below 1024
docker run -p 80:80 nginx:alpine
# Error: failed to expose ports: bind: permission denied

# Workaround 1: Use a high port
docker run -p 8080:80 nginx:alpine

# Workaround 2: Allow unprivileged port binding (system-wide)
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# Or permanently in /etc/sysctl.conf
# Cannot use --privileged
docker run --privileged myapp:latest
# Error: privileged mode is not supported in rootless

# Cannot use certain network modes
docker run --network host myapp:latest
# Limited support — depends on kernel version

# Cannot use overlay2 storage driver on some filesystems
# Rootless often uses fuse-overlayfs instead
docker info | grep "Storage Driver"
# Storage Driver: fuse-overlayfs
FeatureRoot DockerRootless Docker
Ports < 1024YesNo (workaround: sysctl)
--privilegedYesNo
--network hostYesLimited
AppArmor/SELinuxFull supportLimited
cgroup resource limitsFull (cgroup v1/v2)cgroup v2 only (with delegation)
Storage driveroverlay2fuse-overlayfs or overlay2 (kernel 5.11+)
PerformanceBaselineSlight overhead (fuse-overlayfs)
Docker socket mount/var/run/docker.sock$XDG_RUNTIME_DIR/docker.sock

Port Mapping in Rootless Mode

# Default: slirp4netns (user-space networking)
# Slower but works without root

# Check which networking mode is in use
docker info | grep -i network
# Network: slirp4netns

# For better performance, use pasta (if available)
# Edit ~/.config/docker/daemon.json
{
"network": {
"driver": "pasta"
}
}

# Expose ports using high-numbered ports
docker run -d --name web -p 8080:80 -p 8443:443 nginx:alpine

# Use a reverse proxy on the host (running as root) for port 80/443
# For example, Caddy or nginx on the host can proxy to localhost:8080

Storage Driver Differences

# Root Docker typically uses overlay2
docker info --format '{{.Driver}}'
# overlay2

# Rootless Docker uses fuse-overlayfs (or native overlay2 on kernel 5.11+)
DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock docker info --format '{{.Driver}}'
# fuse-overlayfs
# On modern kernels (5.11+), rootless can use native overlay2
# Check your kernel version
uname -r
# 6.5.0-generic

# If 5.11+, set overlay2 in rootless daemon config
# ~/.config/docker/daemon.json
{
"storage-driver": "overlay2"
}

# Restart rootless daemon
systemctl --user restart docker

fuse-overlayfs works on older kernels but has a small performance penalty compared to native overlay2. On kernel 5.11 and later, rootless Docker can use native overlay2 directly.

Rootless Docker Compose

Docker Compose works with rootless Docker out of the box — it just uses the rootless socket.

# Ensure DOCKER_HOST points to rootless socket
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock

# Use Compose as normal
docker compose up -d
docker compose ps
docker compose down
# docker-compose.yml — rootless-compatible
services:
web:
image: nginx:alpine
ports:
- "8080:80" # Use high port — no root needed

api:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/app/data # Bind mounts work, but UID mapping applies

db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_pass

volumes:
pgdata:

One gotcha: bind mount permissions. Because of user namespace remapping, files created inside the container may have unexpected UIDs on the host.

# File created inside rootless container
docker run --rm -v $(pwd)/data:/data alpine touch /data/testfile

ls -la data/testfile
# -rw-r--r-- 1 100000 100000 0 Aug 6 10:00 data/testfile
# UID 100000 — this is the remapped UID

Podman as a Rootless Alternative

Podman was designed rootless from the start. It has no daemon, runs as your user, and is CLI-compatible with Docker.

# Install Podman
sudo apt-get install -y podman

# Podman commands look identical to Docker
podman run -d --name web -p 8080:80 nginx:alpine
podman ps
podman logs web
podman stop web

# Podman Compose (or docker-compose with Podman socket)
podman compose up -d

Comparison: Root Docker vs Rootless Docker vs Podman

FeatureRoot DockerRootless DockerPodman
DaemonYes (dockerd as root)Yes (dockerd as user)No (daemonless)
Default UIDroot (0)Your userYour user
Container escape riskRoot on hostUnprivileged userUnprivileged user
Ports < 1024YesNo (sysctl workaround)No (sysctl workaround)
cgroup limitsFullcgroup v2 with delegationcgroup v2 with delegation
Docker ComposeNativeNativepodman-compose or socket
Kubernetes integrationDocker-in-DockerLimitedpodman generate kube
Image compatibilityFullFullFull (OCI standard)
Pod conceptNoNoYes (like Kubernetes pods)
Systemd integrationsystemd servicesystemd --user servicepodman generate systemd
MaturityMost matureMature (since Docker 20.10)Very mature

Performance Considerations

# Benchmark: rootless vs root Docker (file I/O)
# Root Docker with overlay2
docker run --rm -v bench-vol:/data alpine \
sh -c "dd if=/dev/zero of=/data/test bs=1M count=500 2>&1 | tail -1"
# 500 MB: ~0.8 seconds

# Rootless Docker with fuse-overlayfs
DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock \
docker run --rm -v bench-vol:/data alpine \
sh -c "dd if=/dev/zero of=/data/test bs=1M count=500 2>&1 | tail -1"
# 500 MB: ~1.1 seconds (fuse-overlayfs)
# 500 MB: ~0.85 seconds (native overlay2 on kernel 5.11+)

The performance gap with fuse-overlayfs is typically 10-30% for I/O-heavy workloads. For CPU-bound applications and network-bound services, the difference is negligible. On kernel 5.11+ with native overlay2, the performance gap nearly disappears.

When to Use Rootless Mode

  • Shared development servers. Multiple developers running containers without giving everyone root.
  • CI/CD runners. Build containers without requiring privileged CI agents.
  • Security-sensitive environments. Compliance requirements that prohibit root daemons.
  • Untrusted workloads. Running third-party containers where escape is a concern.

Keep root Docker when:

  • You need --privileged containers (e.g., Docker-in-Docker for CI).
  • You are running on older kernels without cgroup v2.
  • Performance on fuse-overlayfs is unacceptable for your workload.

Wrapping Up

Running Docker as root was the default because it was easier to implement, not because it was the right security decision. Rootless mode removes the biggest risk in container security — a daemon running as root that any group member can leverage for full host access. The limitations are real but manageable for most workloads, and they continue to shrink with each kernel release. If you are starting a new deployment today, there is no reason to default to root Docker unless you have a specific technical requirement that demands it.

In the next post, we will cover Docker Init Systems — the PID 1 problem, why your containers ignore Ctrl+C, and how to handle signals and zombie processes correctly.