- Vrealmatic
- Ubuntu Server
- Docker
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.
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
- Verified installation - Docker from the official source, packages verified with a signing key
- Admin-only access - only
sudocontrols Docker, thedockergroup stays empty - Secure defaults - logs cannot fill the disk, container ports open for the server itself only
- A firewall for containers too - a port opened by mistake stays closed to the outside
- Containers with minimal rights - no admin rights, no way to change their own files, limited CPU and memory
What do you need to solve?
| Question | Where 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 → |
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 system | Shares the server's kernel | Has its own complete system |
| Resource use | Starts in seconds, almost no extra memory | Starts in tens of seconds, hundreds of MB to GB of memory |
| Separation from the server | Weaker, depends on correct settings | Stronger |
| Good for | Applications and services: web, databases, automation | A 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
| Term | Meaning |
|---|---|
| Image | The template a container starts from (e.g. nginx:1.29). Images come from a registry, usually Docker Hub. |
| Digest | A unique fingerprint of one exact image version (sha256:…). Unlike a version label (tag), it never changes. |
| Container | A running image. Whatever the application writes inside disappears when the container is deleted. |
| Volume | Storage 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, sudo | Root is the server administrator with unlimited rights. sudo runs one command with root rights after a password check. |
| Port | A numbered "door" on the server where a service accepts connections (web 80 and 443, SSH 22). |
| Published port | A 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. |
| Capabilities | Individual pieces of root's power (e.g. changing the network). A container should have as few as possible. |
| Docker Compose | Describes 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.
- 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.
- Access to Docker = administrator rights. The
dockergroup therefore gives full rights without a password. Thedocker.socksocket must never go into containers or onto the network. - 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.
- Containers share the server's kernel. The default restrictions are sensible, but options such as
privilegedornetwork_mode: hostremove them.
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).- 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>withsudorights 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 as | administrator (root) | an ordinary user |
| If an attacker breaks out of a container | they get the whole server | they get only the user <DOCKER_USER> |
| UFW firewall | container ports bypass it (fixed in chapter 07) | applies to containers too |
| Limitations | none | ports below 1024 only after a change, some images do not work, the container does not see the visitor's real IP |
| Choose when | Most servers, works with everything | You 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
| Placeholder | Meaning | Example / where to find it |
|---|---|---|
<ADMIN_USER> | Your user with sudo rights | admin |
<DOCKER_USER> | A user without sudo for rootless mode (only chapter 09) | dockerapp |
<EXT_IF> | The server's network interface towards the internet | ip -4 route show default (the word after dev) |
<LAN_SUBNET> | Your local network (optional) | 192.168.88.0/24 |
<SERVER> | The address you SSH to | Tailscale IP, local IP, hostname |
<SSH_PORT> | The SSH port | sudo 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 version | chapter 08 |
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.comscript (curl | sh) - runs someone else's code unchecked; Docker recommends it for testing only - The
dockersnap - data elsewhere and its own updates, guides do not expect it - Ubuntu's
docker.iopackage 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.
# 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)# only if Docker was installed as a snap (this deletes the snap's data - back it up first)
sudo snap remove dockerStep 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.
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.ascpub 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]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.
# 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.ascStep 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.
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.sourcessudo apt update
apt-cache policy docker-ce
# Candidate: 5:29.x… from https://download.docker.com/linux/ubuntu <CODENAME>/stableThe 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.
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginsystemctl status docker containerd --no-pager
sudo docker version
sudo docker run --rm hello-world
sudo docker image rm hello-worldAfter 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:
# anyone in the docker group gets a root shell on the host with this one line:
docker run --rm -it -v /:/host alpine chroot /hostThis 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
getent group docker # → docker:x:<GID>: (nobody after the last colon)
# if someone is there, remove them:
sudo gpasswd -d <USER> dockerDo 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.
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| Setting | What it does |
|---|---|
log-driver, log-opts | Default logs are never deleted and eventually fill the disk. Here they are compressed and capped at 5 × 10 MB per container. |
live-restore | Containers keep running while Docker restarts or updates. |
no-new-privileges | A program in a container cannot raise its own rights (e.g. with sudo or su). |
icc | Containers on the default network cannot talk to each other. Compose networks are covered in chapter 08. |
ip | Container ports open for the server itself only (127.0.0.1) by default, not to the internet. |
default-network-opts | The same for networks created by Compose. |
default-address-pools | The 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.
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), cgroupnsSecurity 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.
# 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 keptSome 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 access | Setting in compose.yaml | Note |
|---|---|---|
| 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 tunnel | The 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:
# 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 PCIt 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
ip -4 route show default # → default via … dev <EXT_IF> …
sudo cp -a /etc/ufw/after.rules /etc/ufw/after.rules.bakStep 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.
# 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.
# 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-USERStep 4: Load the rules and check them
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-USERUFW 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.
sudo systemctl stop rollback-docker-user.timer
systemctl list-timers rollback-docker-user.timer # → 0 timers listedThe 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.
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:
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:
# the "Digest:" line is the value for @sha256:…
sudo docker buildx imagetools inspect nginxinc/nginx-unprivileged:1.29-alpinesudo docker compose config # prints the resolved file and reports errors
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail=50Rules for every container
| Rule | Why |
|---|---|
| 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 latest | The 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: true | An attacker cannot store or overwrite anything in the container. Writing only where the application needs it. |
Limits: mem_limit, cpus, pids_limit | One application cannot exhaust the whole server. |
Separate networks, the database on an internal: true network | A 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. |
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:
# 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_passwordservices:
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_passwordThe 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>:
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 eachUbuntu 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.
# 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.
# 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>:
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:
# 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 dockerRepeat 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
privilegedoption does not give full rights, andpingin 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.
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.
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-pluginApplication images
- Follow new versions of the application and the vendor's security advisories.
- Change the version and digest in
compose.yaml. - Pull the new image, deploy it, check the status and delete old images:
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 pruneTools 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.
# 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:
# 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):
# 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-leakThe 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 see | Cause | What to do |
|---|---|---|
permission denied … docker.sock | you are not in the docker group (intended) | use sudo docker |
| Docker does not start after editing daemon.json | a mistake in the file, or the same setting also in systemd | sudo dockerd --validate --config-file=/etc/docker/daemon.json |
| A service is not reachable from outside | the port is on 127.0.0.1 only, or DOCKER-USER blocks it | allow a public port deliberately (ports, firewall) |
| A container has no internet | the network is internal: true, or a VPN blocks the traffic | check the network in compose.yaml and sudo iptables -S DOCKER-USER |
| Part of the network stops working after starting a container | container addresses collide with your network or VPN | default-address-pools (chapter 06), then recreate the networks |
| The disk fills up | old images, build cache, logs | docker system df, image prune, builder prune |
An image fails with read_only or cap_drop | the application needs to write somewhere or needs one individual power | find out what from the logs and allow only that (tmpfs, cap_add) |
Rootless: Failed to connect to bus | switched user with sudo -iu or su | sudo machinectl shell <DOCKER_USER>@ |
| Rootless: nothing runs after a reboot | start at boot is missing | chapter 09, step 2 |
| Docker does not update after an Ubuntu upgrade | the upgrade disables third-party repositories | Suites: in docker.sources to the new codename |
sudo dockerd --validate --config-file=/etc/docker/daemon.json
journalctl -u docker -b --no-pager | tail -50sudo docker system df
sudo docker image prune # unused images
sudo docker builder prune # build cache- 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
Deleting /var/lib/docker removes all images, containers and volumes, databases included. Back up first.
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 updateDelete the BEGIN DOCKER-USER … END DOCKER-USER block from /etc/ufw/after.rules and run sudo ufw reload. Uninstall rootless mode before apt purge:
# 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
This guide is based on Docker's official documentation (verified on 27 October 2026):
- Install Docker Engine on UbuntuSupported versions, repository, install script and firewall.
- Linux post-installation stepsThe docker group and its rights, log rotation.
- Docker Engine securityDaemon risks, capabilities, user namespaces.
- Protect the Docker daemon socketRemote access only over SSH or TLS.
- Packet filtering and firewallsDocker and UFW.
- Docker with iptablesThe DOCKER-USER chain.
- Port publishing and mappingPublishing ports and the default address.
- dockerd referenceEvery daemon.json setting.
- Rootless modeInstalling rootless mode.
- Rootless - tips and troubleshootingAppArmor, low ports, limits, uninstall.
- Compose file referenceEvery compose.yaml option.
- OWASP Docker Security Cheat SheetAn overview of security recommendations.
Related
- Ubuntu Server guidesSSH keys, 2FA, Fail2Ban, updates, VPN, time sync.
- SSH keysLogging in with a key instead of a password.
- Unattended upgradesAutomatic security updates.
- Tailscale on Ubuntu Server (Czech)SSH only over a private network.
How to cite this publication (APA 7)Nepor, V. (2026, October 28). Docker on Ubuntu Server: Secure Installation and Configuration. Vrealmatic. https://vrealmatic.com/ubuntu/docker
Need a secure server setup?
We design, secure and document networks and servers.