Running containerd in rootless mode means the container runtime daemon itself isn’t running as root. This is a big deal because it dramatically shrinks the attack surface if a container is compromised – the attacker only gets the privileges of the unprivileged user running containerd, not root on the host.

Let’s see it in action. We’ll set up a simple user, install containerd for that user, and run a small alpine container.

# On a fresh Ubuntu 22.04 VM (or similar)
sudo apt update && sudo_apt install -y curl gnupg
# Create a new unprivileged user
sudo useradd -m -s /bin/bash unprivileged_user
sudo su - unprivileged_user

# Download containerd bootstrap tarball
# Find the latest version at: https://github.com/containerd/containerd/releases
VERSION="1.7.11" # Replace with the latest stable version
curl -L "https://github.com/containerd/containerd/releases/download/v${VERSION}/containerd-${VERSION}-linux-amd64.tar.gz" | tar -C /usr/local -xz

# Create the containerd configuration directory for the user
mkdir -p ~/.config/containerd

# Generate a default containerd config
containerd config default | tee ~/.config/containerd/config.toml

# --- IMPORTANT CONFIGURATION CHANGES ---
# Edit ~/.config/containerd/config.toml
# Find the [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] section
# Uncomment and set SystemdCgroup = false if not already set.
# This is crucial for rootless mode as systemd cgroup drivers are not available to unprivileged users.
# Example snippet to look for and modify:
#
# [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
#   SystemdCgroup = false
#
# Also, ensure the state directory is user-owned.
# The default is usually /run/containerd, but for rootless, it should be in the user's home.
# Find the [root] section and modify:
#
# [root]
#   path = "/home/unprivileged_user/.local/share/containerd/rootfs" # Adjust path if needed
#   # ... other options
#
# And the state directory:
#
# [state]
#   path = "/home/unprivileged_user/.local/share/containerd/state" # Adjust path if needed
#   # ... other options

# Create necessary directories for containerd to use
mkdir -p ~/.local/share/containerd/rootfs
mkdir -p ~/.local/share/containerd/state
mkdir -p ~/.local/share/containerd/log # For logs

# Initialize the containerd daemon for the user
containerd --state ~/.local/share/containerd/state --address ~/.local/share/containerd/containerd.sock --config ~/.config/containerd/config.toml &
# Note the '&' to run in the background. You'll want to manage this as a proper service later.

# Wait a second for containerd to start
sleep 2

# Install nerdctl (a Docker-compatible CLI for containerd)
curl -L "https://github.com/containerd/nerdctl/releases/download/v1.7.11/nerdctl-1.7.11-linux-amd64.tar.gz" | sudo tar -C /usr/local/bin -xz

# Set environment variables so nerdctl can find the rootless containerd
export CONTAINERDR_RUNTIME_ADDRESS=unix:///home/unprivileged_user/.local/share/containerd/containerd.sock
export NERDCTL_RUNTIME_ADDRESS=$CONTAINERDR_RUNTIME_ADDRESS
export NERDCTL_NEUTER_USERNS=true # Essential for rootless networking

# Pull an image
nerdctl pull alpine:latest

# Run a container
nerdctl run -it --rm alpine sh

# Inside the alpine container, you are NOT root.
# You can verify this by running 'id'
# uid=1000(unprivileged_user) gid=1000(unprivileged_user) groups=1000(unprivileged_user)
# Exit the container
# exit

The core problem containerd solves is the complexity of running containers securely without requiring root privileges for the daemon itself. Traditionally, container runtimes like Docker (which uses containerd underneath) require root to manage network namespaces, mount host directories, and control cgroup resources. Rootless containerd sidesteps this by leveraging user namespaces and delegating resource management to user-level tools.

Internally, containerd in rootless mode relies on a few key components. First, runc is still the low-level OCI runtime, but it’s configured to operate within the user’s existing user namespace. The SystemdCgroup = false setting in the config.toml is vital because it tells runc not to attempt to interact with systemd’s cgroup management, which is a root-only operation. Instead, it allows runc to manage cgroups within the user’s own namespace.

Network isolation is handled by slirp4netns (or vpnkit on macOS/Windows), which allows containers to have network access without requiring root to set up host-level network interfaces. This is why NERDCTL_NEUTER_USERNS=true is important; it tells nerdctl to ensure user namespaces are properly utilized for networking.

The containerd daemon itself runs as the unprivileged user. It communicates with runc and other components via a Unix domain socket, typically located in the user’s home directory (~/.local/share/containerd/containerd.sock). All data, including container images and runtime state, is stored within directories owned by this user (e.g., ~/.local/share/containerd/rootfs and ~/.local/share/containerd/state).

The most surprising thing most people miss is how user namespaces and slirp4netns interact to provide network connectivity without root. When you run a rootless container, its network stack is isolated within a user namespace. slirp4netns acts as a user-mode network stack, translating outgoing packets from the container’s private IP space to the host’s IP space and vice-versa. This means the container effectively gets its own private network, but the traffic is routed through a userspace process managed by the unprivileged user. The container’s eth0 will have an IP like 10.0.2.15, but it’s slirp4netns that’s doing the heavy lifting to make it appear on your host’s network.

The next hurdle you’ll likely encounter is managing the rootless containerd daemon as a system service, typically using systemd user units.

Want structured learning?

Take the full Containerd course →