Secure Docker on Ubuntu Server

What Docker is and how to install and configure it securely on a server, step by step

Installation from a verified source, admin-only access, ports that cannot bypass the firewall and containers with minimal rights. As an alternative, a mode in which Docker does not run with admin rights at all.

Vladimír NeporAuthorVladimír Nepor
Published
Secure Docker: packages come from the official repository with a verified signing key, only sudo controls the root daemon (no docker group), and containers publish ports on 127.0.0.1 with a DOCKER-USER firewall rule as a safety net.APT repositorydownload.docker.comSigned-By: docker.ascverified key…8D81 803C 0EBF CD88dockerdsudo docker …daemon.json ✓only via sudo,no docker groupcontainers127.0.0.1:8080:8080not exposed by defaultDOCKER-USER: DROP04 · INSTALLOfficial APT repositorykey fingerprint check, no curl | shchapter →06 · DAEMONSecure defaultsdaemon.json: logs, ports, privilegeschapter →07 · NETWORKPorts that respect the firewall127.0.0.1 + DOCKER-USERchapter →08 · CONTAINERSHardened Composenon-root, read-only, limitschapter →
Guide contents

00 · Overview

Secure Docker in short

This guide explains what Docker is and installs and configures it securely on Ubuntu Server, step by step. No all-in-one script: every step says what it does, why it is needed and how to check that it worked.

The whole setup in short

  1. Verified installation - Docker from the official source, packages verified with a signing key
  2. Admin-only access - only sudo controls Docker, the docker group stays empty
  3. Secure defaults - logs cannot fill the disk, container ports open for the server itself only
  4. A firewall for containers too - a port opened by mistake stays closed to the outside
  5. Containers with minimal rights - no admin rights, no way to change their own files, limited CPU and memory

What do you need to solve?

QuestionWhere to find the answer
What is Docker and do I need it?What is Docker →
Why is Docker a security risk?Security model →
How do I install it securely?Installation →
Can I add myself to the docker group?Who may control Docker →
Why is a container reachable from the internet although the firewall blocks it?Network and firewall →
What does a secure compose.yaml look like?Containers →
Can Docker run without admin rights at all?Rootless mode →
Something does not work.Troubleshooting →
Scope

Verified against the official documentation on 27 October 2026: Docker Engine 29.9, Docker Compose 5.6, Ubuntu Server 22.04, 24.04 and 26.04 LTS. Docker Desktop and Swarm are out of scope.

01 · Docker

What Docker is and what it is for

Docker runs applications in containers. A container holds an application and everything it needs to run (libraries, settings). On the server it runs as an ordinary program, but it sees only its own files and network and has limited CPU and memory. Applications therefore run the same everywhere and do not get in each other's way.

Container vs. virtual machine

Container (Docker)Virtual machine
Operating systemShares the server's kernelHas its own complete system
Resource useStarts in seconds, almost no extra memoryStarts in tens of seconds, hundreds of MB to GB of memory
Separation from the serverWeaker, depends on correct settingsStronger
Good forApplications and services: web, databases, automationA different operating system, untrusted code

A container therefore does not separate an application from the server as firmly as a virtual machine. Its isolation is good, but only when it is set up correctly. That is what the rest of this guide is about.

Terms you will need

TermMeaning
ImageThe template a container starts from (e.g. nginx:1.29). Images come from a registry, usually Docker Hub.
DigestA unique fingerprint of one exact image version (sha256:…). Unlike a version label (tag), it never changes.
ContainerA running image. Whatever the application writes inside disappears when the container is deleted.
VolumeStorage for data that must survive deleting the container (e.g. a database).
Daemon (dockerd)A background service that starts and manages containers. The docker command only sends it instructions.
Socket (docker.sock)The file through which the docker command talks to the daemon. Whoever can write to it controls Docker.
Root, sudoRoot is the server administrator with unlimited rights. sudo runs one command with root rights after a password check.
PortA numbered "door" on the server where a service accepts connections (web 80 and 443, SSH 22).
Published portA server port that Docker forwards into a container. Without one, a container is unreachable from outside.
Firewall (UFW)Rules that decide which connections reach the server. UFW is Ubuntu's firewall.
CapabilitiesIndividual pieces of root's power (e.g. changing the network). A container should have as few as possible.
Docker ComposeDescribes containers and their settings in a compose.yaml file, run with docker compose.

When Docker makes sense on a server

Use Docker

  • Applications the vendor ships as an image (Home Assistant, n8n, Nextcloud…)
  • Several applications on one server that need different library versions
  • Settings kept in files, easy to move and to version in Git

Prefer a classic installation

  • A single service Ubuntu ships as a package (nginx, postgresql) - it updates itself
  • Untrusted code or a need for strong separation - that belongs in a virtual machine

02 · Risks

Where Docker is vulnerable

Docker has four weak spots. Every further step of this guide deals with one of them.

  1. Docker runs with administrator (root) rights. Whoever can give it commands can take over the whole server: it is enough to start a container that sees all of the server's files.
  2. Access to Docker = administrator rights. The docker group therefore gives full rights without a password. The docker.sock socket must never go into containers or onto the network.
  3. Docker bypasses the firewall. It forwards a connection to a published port into the container before UFW checks it. By default it also opens the port on every server address, the internet included.
  4. Containers share the server's kernel. The default restrictions are sensible, but options such as privileged or network_mode: host remove them.
An incoming packet from the internet or LAN reaches eth0. In nat PREROUTING Docker rewrites the destination of a published port to the container IP (DNAT). Traffic for the host goes through the INPUT chain with the UFW rules to a host process such as sshd. Traffic for a container goes through the FORWARD chain: first DOCKER-USER (your rules), then Docker's rules, then the container. UFW rules in INPUT never see published container ports.internet / LANclient connects to a server porteth0nat PREROUTING (Docker)published port → DNAT to the container IPfor the hostfor a containerINPUT: UFWallow / deny from ufw statusFORWARD: DOCKER-USERyour rules → Docker's ruleshost processsshd, reverse proxy on the hostcontainerpublished portUFW rules (INPUT) never see published container ports.
UFW checks only connections meant for the server (the INPUT chain). Connections to a container take another path (FORWARD) that UFW rules do not reach. Rules for containers therefore belong in the DOCKER-USER chain (chapter 07).
Summary
  • Installation from a verified source (chapter 04).
  • Only the administrator controls Docker via sudo, or Docker runs without admin rights (chapters 05 and 09).
  • Secure defaults and a firewall for containers too (chapters 06 and 07).
  • Every container with minimal rights (chapter 08).

03 · Before you start

Prerequisites and choosing a mode

What to prepare, which mode to choose and which values in the commands to replace with your own.

  • Ubuntu Server 22.04, 24.04 or 26.04 LTS (64-bit).
  • A user <ADMIN_USER> with sudo rights who logs in with an SSH key.
  • The UFW firewall enabled (Ubuntu UFW guide). Ideally SSH reachable only over a VPN such as Tailscale or from your local network.
  • Automatic security updates (unattended-upgrades). Containers share the server's kernel, so keeping it up to date is essential.

Standard or rootless mode?

Standard (rootful)Rootless
Docker runs asadministrator (root)an ordinary user
If an attacker breaks out of a containerthey get the whole serverthey get only the user <DOCKER_USER>
UFW firewallcontainer ports bypass it (fixed in chapter 07)applies to containers too
Limitationsnoneports below 1024 only after a change, some images do not work, the container does not see the visitor's real IP
Choose whenMost servers, works with everythingYou want the smallest possible impact of an attack and can live with the limits

The installation (chapter 04) is the same for both modes. Chapters 05-08 describe standard mode, rootless follows in chapter 09.

Values you replace with your own

PlaceholderMeaningExample / where to find it
<ADMIN_USER>Your user with sudo rightsadmin
<DOCKER_USER>A user without sudo for rootless mode (only chapter 09)dockerapp
<EXT_IF>The server's network interface towards the internetip -4 route show default (the word after dev)
<LAN_SUBNET>Your local network (optional)192.168.88.0/24
<SERVER>The address you SSH toTailscale IP, local IP, hostname
<SSH_PORT>The SSH portsudo sshd -T | grep -i '^port'
<PUBLIC_IP>The server's public IP (for a server at home, the router's IP)provider panel / router
<PROJECT>Project name (a folder in /opt/stacks/)web
<DIGEST>Fingerprint of an image versionchapter 08
Fallback access

The installation itself does not endanger access to the server. Only the firewall change in chapter 07 is risky: a mistake in the file can stop the firewall from loading. Check beforehand that you have another way in (provider console, physical access, SSH from the local network).

04 · Installation

Installing from the official repository

We install Docker from its official repository (a package source for APT). It has the latest versions and gets security fixes first. Before the system trusts it, we check the signing key: it guarantees that the packages really come from Docker.

We do not use

  • The get.docker.com script (curl | sh) - runs someone else's code unchecked; Docker recommends it for testing only
  • The docker snap - data elsewhere and its own updates, guides do not expect it
  • Ubuntu's docker.io package together with Docker's packages - they conflict

We use

  • Docker's official repository
  • A signing key valid for this repository only
  • A check of the key and the package origin before installing

Step 1: Remove conflicting packages

Older unofficial packages conflict with the official ones. The first command lists the installed ones; if it prints nothing, skip the second. Container data is kept.

Terminal
# list unofficial packages that conflict with Docker's own (no output = nothing to remove)
dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null

# remove only those that are installed; /var/lib/docker (images, volumes) is kept
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null | cut -f1)
Terminal
# only if Docker was installed as a snap (this deletes the snap's data - back it up first)
sudo snap remove docker

Step 2: Download and verify the signing key

We download the key to a temporary file and compare its fingerprint with the official value 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88. The output must match the second block.

Terminal
sudo apt update
sudo apt install ca-certificates curl gnupg

# download the key to a temporary file and inspect it before trusting it
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /tmp/docker.asc
gpg --show-keys --with-fingerprint --keyid-format long /tmp/docker.asc
Output
pub   rsa4096/8D81803C0EBFCD88 2017-02-22 [SCEA]
      Key fingerprint = 9DC8 5822 9FC7 DD38 854A  E2D8 8D81 803C 0EBF CD88
uid                            Docker Release (CE deb) <docker@docker.com>
sub   rsa4096/7EA0A9C3F273FCD8 2017-02-22 [S]
Fingerprint does not match?

Stop. Delete /tmp/docker.asc and find the cause (proxy, spoofed address, a key change announced on docs.docker.com).

Step 3: Store the key

We store the key so that the system trusts it for Docker's repository only, not for any other.

Terminal
# keyring for locally added repositories; the key is trusted only by the repo that points to it
sudo install -d -m 0755 -o root -g root /etc/apt/keyrings
sudo install -m 0644 -o root -g root /tmp/docker.asc /etc/apt/keyrings/docker.asc
rm /tmp/docker.asc

Step 4: Add the repository

The command detects your Ubuntu version and processor type, prints them for a check and writes the repository. The second block confirms Docker will be installed from it.

Terminal
CODENAME="$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")"
ARCH="$(dpkg --print-architecture)"
echo "$CODENAME $ARCH"     # e.g. noble amd64

sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $CODENAME
Components: stable
Architectures: $ARCH
Signed-By: /etc/apt/keyrings/docker.asc
EOF

cat /etc/apt/sources.list.d/docker.sources
Terminal
sudo apt update
apt-cache policy docker-ce
# Candidate: 5:29.x…  from  https://download.docker.com/linux/ubuntu <CODENAME>/stable

The output must show version 5:29.x and download.docker.com. A NO_PUBKEY error means the path to the key is wrong.

Step 5: Install Docker and try it

Installs Docker, the docker command and the extensions for building images and for Compose. Docker starts on its own and with every boot. The hello-world test container confirms everything works; then we delete it.

Terminal
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Terminal
systemctl status docker containerd --no-pager
sudo docker version
sudo docker run --rm hello-world
sudo docker image rm hello-world

After upgrading to a new Ubuntu version, change the Suites: line in docker.sources to its codename. The upgrade disables third-party repositories.

05 · Access

Who may control Docker

Many guides tell you to join the docker group so you do not have to type sudo. On a server, do not: a member of the docker group has full administrator rights without a password. One command is enough:

Terminal
# anyone in the docker group gets a root shell on the host with this one line:
docker run --rm -it -v /:/host alpine chroot /host

This skips the password, 2FA and the log entry that sudo requires. A stolen SSH key is all an attacker needs. Run Docker with sudo docker. If you want Docker without sudo, use rootless mode.

The docker group must stay empty

Terminal
getent group docker      # → docker:x:<GID>:   (nobody after the last colon)

# if someone is there, remove them:
sudo gpasswd -d <USER> docker
Never control Docker over the network

Do not enable remote access to Docker (port 2375/2376, the hosts option, "Expose daemon on tcp"). Whoever connects to it controls the server. Manage the server over SSH.

docker.sock does not belong in a container

Some tools (Portainer, Traefik, Watchtower, various dashboards) want docker.sock mounted into their container. Whoever takes over such a container takes over the whole server. If you really need the tool, put a go-between (a socket proxy) in front of the socket that allows reading only.

06 · Settings

Secure default settings

Docker's defaults are convenient, not secure. The /etc/docker/daemon.json file changes them so that a single mistake in a container's settings neither opens a port to the internet nor fills the disk. If the file already exists, add the values and do not duplicate any.

/etc/docker/daemon.json
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "5"
  },
  "live-restore": true,
  "no-new-privileges": true,
  "icc": false,
  "ip": "127.0.0.1",
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  },
  "default-address-pools": [
    { "base": "172.30.0.0/16", "size": 24 }
  ]
}
EOF
sudo chmod 0644 /etc/docker/daemon.json
SettingWhat it does
log-driver, log-optsDefault logs are never deleted and eventually fill the disk. Here they are compressed and capped at 5 × 10 MB per container.
live-restoreContainers keep running while Docker restarts or updates.
no-new-privilegesA program in a container cannot raise its own rights (e.g. with sudo or su).
iccContainers on the default network cannot talk to each other. Compose networks are covered in chapter 08.
ipContainer ports open for the server itself only (127.0.0.1) by default, not to the internet.
default-network-optsThe same for networks created by Compose.
default-address-poolsThe address range for container networks. The default range can collide with your network or VPN, and part of the network then stops working.

Step 1: Check the file and restart Docker

The check finds a mistake before it stops Docker from starting.

Terminal
sudo dockerd --validate --config-file=/etc/docker/daemon.json     # → configuration OK
sudo systemctl restart docker

sudo docker info --format '{{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
sudo docker info | grep -A5 'Security Options'     # apparmor, seccomp (builtin), cgroupns

Security Options must list apparmor, seccomp and cgroupns - the default security restrictions for containers.

Step 2: Recreate existing containers

The new settings apply only to newly created containers and networks. Data in volumes is kept.

Terminal
# daemon defaults apply only to newly created containers and networks
cd /opt/stacks/<PROJECT>
sudo docker compose down && sudo docker compose up -d     # volumes are kept
An image stopped working?

Some images call sudo or su at start-up and fail with no-new-privileges. Allow an exception for that container only (security_opt: ["no-new-privileges:false"]), or find an image that does not need admin rights.

The advanced option "userns-remap": "default" makes the administrator inside a container an ordinary user on the server. It complicates file permissions and rules out some options, though. Rootless mode gives the same protection more simply.

07 · Network

Ports and the firewall

The goal: a container is not reachable from the internet unless you explicitly want it to be. And if a port opens by mistake, the firewall stops it.

Where to publish ports

Who needs accessSetting in compose.yamlNote
Other containers only (e.g. a database)no ports:Containers on the same network find each other by service name.
The server itself only"127.0.0.1:8080:80"Always write the address, even though chapter 06 makes it the default.
You, remotely (administration)"127.0.0.1:8080:80" + SSH tunnelThe port is not opened anywhere, see below.
Anyone on the internet (web)"0.0.0.0:443:443"Only on the one container that serves the website (reverse proxy), with an exception in DOCKER-USER.

You reach a service on 127.0.0.1 remotely through an SSH tunnel. No port is opened, and it also works with a VPN such as Tailscale that allows SSH only:

Terminal
# on your PC: forward local port 8080 to 127.0.0.1:8080 on the server
ssh -N -L 8080:127.0.0.1:8080 -p <SSH_PORT> <ADMIN_USER>@<SERVER>
# then open http://127.0.0.1:8080 in the browser on your PC
Publishing on a Tailscale address

It works, but at boot Docker can be faster than Tailscale and the container then fails to start (error cannot assign requested address). An SSH tunnel does not have this problem.

Do not use network_mode: host (every container port then becomes a server port) or "iptables": false (it breaks container networking and exposes containers to the local network).

A firewall for containers: the DOCKER-USER chain

Rules in the DOCKER-USER chain apply to connections into containers, and Docker never overwrites them. The rules below drop every connection from outside into a container except the ports you explicitly allow. Containers can still connect out (updates, APIs). We store them in /etc/ufw/after.rules, so UFW loads them at every boot.

Step 1: Find the network interface and back up the file

Terminal
ip -4 route show default          # → default via … dev <EXT_IF> …
sudo cp -a /etc/ufw/after.rules /etc/ufw/after.rules.bak

Step 2: Turn on the safety timer

In 10 minutes it restores the original file and deletes the rules. If everything works, you cancel it in step 5.

Terminal
# safety net: in 10 minutes restore the original file and empty DOCKER-USER
sudo systemd-run --unit=rollback-docker-user --on-active=10min \
  /bin/sh -c 'cp -a /etc/ufw/after.rules.bak /etc/ufw/after.rules; iptables -F DOCKER-USER; ufw reload'

Step 3: Add the rules

Paste the block at the very end of /etc/ufw/after.rules (sudo nano /etc/ufw/after.rules) and replace <EXT_IF> with the interface name. Keep the 80 and 443 lines only for a public website; without them no container is reachable from outside.

/etc/ufw/after.rules
# BEGIN DOCKER-USER
*filter
:DOCKER-USER - [0:0]
# replies to existing connections (including those a container opened to the outside)
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
# explicitly allowed public ports (original destination port, before DNAT)
-A DOCKER-USER -i <EXT_IF> -p tcp -m conntrack --ctorigdstport 80 --ctdir ORIGINAL -j RETURN
-A DOCKER-USER -i <EXT_IF> -p tcp -m conntrack --ctorigdstport 443 --ctdir ORIGINAL -j RETURN
# optional: your whole LAN may reach published ports
# -A DOCKER-USER -i <EXT_IF> -s <LAN_SUBNET> -j RETURN
# drop everything else coming from the external interface into containers
-A DOCKER-USER -i <EXT_IF> -j DROP
COMMIT
# END DOCKER-USER

Step 4: Load the rules and check them

Terminal
sudo ufw reload
sudo ufw status verbose            # must say "Status: active", no errors
sudo iptables -S DOCKER-USER
sudo iptables -S FORWARD | head -5 # the first rules jump to DOCKER-USER

UFW must report Status: active and the DOCKER-USER output must contain your rules.

Step 5: Check that you can log in and cancel the timer

Log in over SSH in a new window. If it works, cancel the timer, otherwise it deletes the rules in 10 minutes.

Terminal
sudo systemctl stop rollback-docker-user.timer
systemctl list-timers rollback-docker-user.timer   # → 0 timers listed
IPv6

The rules cover IPv4. Container networks have IPv6 off by default; if you turn it on, add the same block to /etc/ufw/after6.rules. Do not switch Docker to the experimental nftables backend - the DOCKER-USER chain does not exist there.

08 · Containers

Running containers securely

Describe containers in a compose.yaml file, not in long commands. The settings are then clear, reviewable and can be kept in Git. Each project gets its own folder that only root can write to: whoever can change the file controls the container.

Terminal
sudo install -d -m 0750 -o root -g root /opt/stacks/<PROJECT>
cd /opt/stacks/<PROJECT>

Example: a hardened container

A web server (nginx) with restrictions that suit most containers. The comments explain each line:

/opt/stacks/<PROJECT>/compose.yaml
services:
  web:
    # fixed version + digest: the image never changes without you knowing
    image: nginxinc/nginx-unprivileged:1.29-alpine@sha256:<DIGEST>
    user: "101:101"               # do not run as root inside the container
    read_only: true               # root filesystem is read-only
    tmpfs:
      - /tmp                      # writable only where the app needs it
    cap_drop:
      - ALL                       # no Linux capabilities
    security_opt:
      - no-new-privileges:true    # also in daemon.json; keep it visible here
    ports:
      - "127.0.0.1:8080:8080"     # server only / SSH tunnel
    mem_limit: 256m
    cpus: 0.50
    pids_limit: 200
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 3
    networks:
      - frontend

networks:
  frontend: {}

The first command gives you the digest for the image: line. The second block checks the settings and starts the container:

Terminal
# the "Digest:" line is the value for @sha256:…
sudo docker buildx imagetools inspect nginxinc/nginx-unprivileged:1.29-alpine
Terminal
sudo docker compose config      # prints the resolved file and reports errors
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail=50

Rules for every container

RuleWhy
Images only from a trusted publisher (Docker Official Image, Verified Publisher, the application's vendor)An image is a program you run. An unknown one may contain malware.
A fixed version with a digest, never latestThe image never changes without you knowing (chapter 10).
No admin rights (user:)An attacker who breaks the container does not get admin rights.
cap_drop: [ALL]The container has none of root's individual powers. Most applications need none.
read_only: trueAn attacker cannot store or overwrite anything in the container. Writing only where the application needs it.
Limits: mem_limit, cpus, pids_limitOne application cannot exhaust the whole server.
Separate networks, the database on an internal: true networkA container sees only the services it needs. An internal network has no internet access.
Passwords in files (secrets:)Passwords in environment variables are easy to print and end up in logs.
These options switch isolation off

privileged: true, network_mode: host, pid: host, cap_add: [SYS_ADMIN], apparmor:unconfined, seccomp:unconfined and mounting /, /etc, /root, /home or /var/run/docker.sock. If an application's guide requires them, consider another approach (an APT package, a virtual machine).

Passwords

Compose hands a password to the container as the file /run/secrets/<name>. Most official images can read it through a variable ending in _FILE:

Terminal
# password file readable by root only, never typed into the shell history
sudo install -d -m 0700 /opt/stacks/<PROJECT>/secrets
openssl rand -base64 32 | sudo tee /opt/stacks/<PROJECT>/secrets/db_password >/dev/null
sudo chmod 0600 /opt/stacks/<PROJECT>/secrets/db_password
compose.yaml
services:
  db:
    image: postgres:18-alpine@sha256:<DIGEST>
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql
    networks:
      - backend

networks:
  backend:
    internal: true      # no internet access, no published ports

volumes:
  db-data: {}

secrets:
  db_password:
    file: ./secrets/db_password

The password file must be readable by the user the application in the container runs as. If it cannot read it, change the file's owner to that user; do not make it readable for everyone.

09 · Rootless

Option: Docker without admin rights

In rootless mode, Docker and the containers run as an ordinary user without admin rights. Whoever breaks a container does not get the server, only this user. We use a separate user without sudo so that an attacker does not get your SSH keys either.

Step 1: Prepare packages and the user

Needs the repository from chapter 04. The commands stop standard Docker and create the user for containers. Run them as <ADMIN_USER>:

Terminal
sudo apt install uidmap dbus-user-session docker-ce-rootless-extras systemd-container

# stop the system-wide (rootful) daemon - rootless does not need it
sudo systemctl disable --now docker.service docker.socket
sudo rm -f /var/run/docker.sock

# a dedicated user for containers: no password, no sudo
sudo adduser --disabled-password --gecos "" <DOCKER_USER>
grep '^<DOCKER_USER>:' /etc/subuid /etc/subgid     # at least 65536 IDs each

Ubuntu 24.04 and newer requires an AppArmor exception for rootless mode. The docker-ce-rootless-extras package installs it.

Step 2: Enable start at boot

The user's Docker starts with the server even when nobody is logged in.

Terminal
# start the user's daemon at boot, even when nobody is logged in
sudo loginctl enable-linger <DOCKER_USER>

Step 3: Install Docker as the user

Switch to the user with machinectl shell. With sudo -iu or su, service management would not work.

Terminal
# a full systemd session for the user (sudo -iu / su - do not create one)
sudo machinectl shell <DOCKER_USER>@

# --- now as <DOCKER_USER> ---
dockerd-rootless-setuptool.sh install
systemctl --user enable docker
docker context use rootless
docker info | grep -Ei 'context|rootless|cgroup'

The output must contain Context: rootless and Cgroup Version: 2. This Docker's settings live in ~/.config/docker/daemon.json; copy the logging, no-new-privileges and default-address-pools settings from chapter 06.

Step 4: Allow performance limits

Without this step the system silently ignores CPU and disk limits for rootless containers. Run as <ADMIN_USER>:

Terminal
sudo install -d -m 0755 /etc/systemd/system/user@.service.d
sudo tee /etc/systemd/system/user@.service.d/delegate.conf >/dev/null <<'EOF'
[Service]
Delegate=cpu cpuset io memory pids
EOF
sudo systemctl daemon-reload
# takes effect once the user's systemd instance restarts (simplest: reboot)

Ports and firewall

In rootless mode the UFW firewall applies to containers too, and the DOCKER-USER chain is not needed. Allow a port that should be reachable from outside in UFW (sudo ufw allow 443/tcp). An ordinary user may not open ports below 1024 (e.g. 80 and 443); allow it for the rootlesskit program only:

Terminal
# allow ports below 1024 only for rootlesskit, not for every user
sudo setcap cap_net_bind_service=ep "$(command -v rootlesskit)"

# as <DOCKER_USER>:
systemctl --user restart docker

Repeat the command after every update of docker-ce-rootless-extras; the update removes the permission.

Other limitations

  • By default a container does not see the visitor's real IP (a problem e.g. for website logs). The documentation describes the fix.
  • The privileged option does not give full rights, and ping in a container may not work.
  • Data lives in the user's ~/.local/share/docker, not in /var/lib/docker. Keep that in mind for backups.

10 · Updates

Updating Docker and applications

Two things need updating: Docker itself and the application images. Ubuntu's automatic updates handle only the first.

Docker - option A: automatically

Adds Docker to the automatic updates. Thanks to live-restore, containers survive the update without a restart.

Terminal
sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-docker >/dev/null <<'EOF'
Unattended-Upgrade::Origins-Pattern {
        "site=download.docker.com";
};
EOF

# dry run: Docker packages must show up as allowed
sudo unattended-upgrade --dry-run --debug 2>&1 | grep -iE 'docker|containerd'

Docker - option B: manually

For a server where you schedule restarts yourself. Follow the release notes and security advisories. Update regularly (e.g. monthly) and right after a security fix.

Terminal
sudo apt update
apt list --upgradable 2>/dev/null | grep -E 'docker|containerd'
sudo apt install --only-upgrade docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Application images

  1. Follow new versions of the application and the vendor's security advisories.
  2. Change the version and digest in compose.yaml.
  3. Pull the new image, deploy it, check the status and delete old images:
Terminal
cd /opt/stacks/<PROJECT>
# after changing tag + digest in compose.yaml:
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
sudo docker image prune

Tools that update containers automatically need access to docker.sock and deploy anything new without a check. They do not belong on a server. A scanner (Trivy, Docker Scout) finds known vulnerabilities in images.

11 · Verification

Checking the setup

A check after the installation and after every larger change. The comments next to the commands say what to expect.

Terminal
# 1) nobody in the docker group, no API on the network
getent group docker
sudo ss -tlnp | grep -E ':2375|:2376'          # → nothing

# 2) daemon security options
sudo docker info | grep -A5 'Security Options'
sudo docker info --format '{{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'

# 3) where ports are published (0.0.0.0 / [::] only for ports meant to be public)
sudo docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -tlnp | grep -E 'docker-proxy|rootlesskit'

# 4) firewall safety net
sudo iptables -S DOCKER-USER

# 5) settings of every running container
sudo docker inspect --format '{{.Name}} user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} priv={{.HostConfig.Privileged}} caps={{.HostConfig.CapDrop}} sec={{.HostConfig.SecurityOpt}}' $(sudo docker ps -q)

Test from outside

From a computer outside your network (another server, a phone on mobile data), find out which ports are open. Only the ones you allowed may be open, typically 80 and 443 for a website:

Terminal
# from a machine OUTSIDE your network
nmap -Pn -p 80,443,2375,2376,5432,6379,8080,<SSH_PORT> <PUBLIC_IP>

Test the container firewall with a container that opens a port to the internet on purpose. From outside, the port must be filtered (blocked):

Terminal
# on the server: publish a test port on all addresses on purpose
sudo docker run --rm -d --name test-leak -p 0.0.0.0:8081:8080 nginxinc/nginx-unprivileged:alpine

# from outside: port 8081 must be "filtered", not "open"
nmap -Pn -p 8081 <PUBLIC_IP>

# on the server: clean up
sudo docker stop test-leak

The CIS Docker Benchmark offers a detailed checklist. A small server does not need to meet every item.

12 · Problems

When something does not work

The most common problems and their fixes.

What you seeCauseWhat to do
permission denied … docker.sockyou are not in the docker group (intended)use sudo docker
Docker does not start after editing daemon.jsona mistake in the file, or the same setting also in systemdsudo dockerd --validate --config-file=/etc/docker/daemon.json
A service is not reachable from outsidethe port is on 127.0.0.1 only, or DOCKER-USER blocks itallow a public port deliberately (ports, firewall)
A container has no internetthe network is internal: true, or a VPN blocks the trafficcheck the network in compose.yaml and sudo iptables -S DOCKER-USER
Part of the network stops working after starting a containercontainer addresses collide with your network or VPNdefault-address-pools (chapter 06), then recreate the networks
The disk fills upold images, build cache, logsdocker system df, image prune, builder prune
An image fails with read_only or cap_dropthe application needs to write somewhere or needs one individual powerfind out what from the logs and allow only that (tmpfs, cap_add)
Rootless: Failed to connect to busswitched user with sudo -iu or susudo machinectl shell <DOCKER_USER>@
Rootless: nothing runs after a rebootstart at boot is missingchapter 09, step 2
Docker does not update after an Ubuntu upgradethe upgrade disables third-party repositoriesSuites: in docker.sources to the new codename
Terminal
sudo dockerd --validate --config-file=/etc/docker/daemon.json
journalctl -u docker -b --no-pager | tail -50
Terminal
sudo docker system df
sudo docker image prune          # unused images
sudo docker builder prune        # build cache
Summary
  • Docker only from the official repository with a verified key, never curl | sh.
  • The docker group = administrator without a password. On a server use sudo docker, or rootless.
  • Container ports bypass the firewall: open them on 127.0.0.1 and back them up with DOCKER-USER rules.
  • Every container without admin rights, read-only, with limits and a fixed image version.

13 · Uninstall

Uninstalling

Deletes all data

Deleting /var/lib/docker removes all images, containers and volumes, databases included. Back up first.

Terminal
sudo apt purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo rm -rf /var/lib/docker /var/lib/containerd
sudo rm -f /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc
sudo rm -f /etc/apt/apt.conf.d/52unattended-upgrades-docker
sudo rm -rf /etc/docker
sudo apt update

Delete the BEGIN DOCKER-USER … END DOCKER-USER block from /etc/ufw/after.rules and run sudo ufw reload. Uninstall rootless mode before apt purge:

Terminal
# as <DOCKER_USER>, before apt purge:
dockerd-rootless-setuptool.sh uninstall
rootlesskit rm -rf ~/.local/share/docker

# as <ADMIN_USER>:
sudo loginctl disable-linger <DOCKER_USER>

Sources

Official documentation and further reading

Need a secure server setup?

We design, secure and document networks and servers.

Servers & Networks