Hardening Docker with User NS
Most people either run Docker wide open as root or go full rootless mode and then struggle with networking and permissions. But here’s the sweet spot — run Docker rootful, so it still works normally, but lock it down hard with proper isolation.
Let’s go step by step.
Install Docker
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Then add Docker’s repo and install it:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Once Docker’s in, remove yourself from the docker group (yes, you read that right):
sudo gpasswd -d $USER docker
This prevents anyone from running Docker commands without sudo — basically, no free root access through the Docker socket.
Enable user namespace remapping
User namespaces are your secret weapon here. They make sure that root inside a container ≠ root on your host.
Create a dedicated remap user:
sudo useradd --system --shell /usr/sbin/nologin dockremap
sudo groupadd dockremap
Then give that user a UID/GID range to map to:
echo "dockremap:231072:65536" | sudo tee -a /etc/subuid
echo "dockremap:231072:65536" | sudo tee -a /etc/subgid
Now set up your Docker daemon to use this mapping:
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<EOF
{
"userns-remap": "default",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"seccomp-profile": "/etc/docker/seccomp/default.json",
"no-new-privileges": true,
"default-runtime": "runc",
"live-restore": true,
"storage-driver": "overlay2"
}
EOF
Add seccomp for syscall filtering
Seccomp helps block dangerous syscalls — it’s like a safety net for the kernel.
Grab the default Docker seccomp profile:
sudo mkdir -p /etc/docker/seccomp/
sudo wget -O /etc/docker/seccomp/default.json https://raw.githubusercontent.com/moby/profiles/refs/heads/main/seccomp/default.json
Reload docker and apply seccomp profiles
sudo systemctl daemon-reload && sudo systemctl restart docker
Verify seccomp profile
sudo docker run -it --rm \
--security-opt seccomp=/etc/docker/seccomp/default.json \
--security-opt no-new-privileges \
--read-only \
--security-opt apparmor=docker-default \
--name alpine-sec alpine /bin/sh
grep Seccomp /proc/$$/status
If you see:
Seccomp: 2
Seccomp_filters: 1
— nice, it’s active.
Try it out with a real app
Here’s an example using Nginx Proxy Manager in a docker-compose.yml file:
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
network_mode: 'bridge'
ports:
- "80:80"
- "443:443"
- "81:81" # Web UI
environment:
- TZ=Asia/Jakarta
volumes:
- ./data/nginx-proxy:/data
- ./data/nginx-proxy/letsencrypt/:/etc/letsencrypt
security_opt:
- seccomp=/etc/docker/seccomp/default.json
- apparmor=docker-default
Then run:
sudo docker compose up -d
Now check your mounted files:
ls -l data/nginx-proxy/
You’ll see something like:
drwxr-xr-x 2 231072 231072 4096 ...

That’s proof user namespace remapping is doing its job. The container thinks it’s root, but your host just sees some harmless UID 231072.

How it works: User NS remapping (e.g., mapping to UID 231072 on the host) ensures that the root user (UID 0) inside the container is mapped to a high-numbered, unprivileged user (e.g., UID 231072) on the host operating system.
Security Benefit: If an attacker achieves a container escape (breaking out of the container isolation), they will land on the host as the unprivileged user (dockremap, UID 231072), not the powerful host root user. This vastly limits their ability to damage the host or escalate privileges.
A few notes
- Once you harden Docker this way, you can’t use
network_mode: host. - To isolate services safely, just bind them to localhost instead:
ports:
- "127.0.0.1:81:81"
Wrap-up
You don’t need to go full rootless to stay safe. By combining a few simple things:
- no direct access to the Docker socket
- user namespace remapping
- seccomp + AppArmor
- no-new-privileges
You already get 90% of rootless-level security, without breaking compatibility or networking.
This setup is stable, secure, and perfect for anyone running Docker in production or homelab environments who still needs “rootful” flexibility but wants peace of mind.