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 ...

alt text

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.

alt text

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.