Docker & Compose on Ubuntu 26.04
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026A clean, repeatable way to install Docker Engine and the Compose plugin on Ubuntu 26.04 — the base layer for every self-hosted app on this site.
- A fresh Ubuntu 24.04 server you can SSH into as a sudo user.
- About 20 minutes and a terminal.
- No prior Docker install — we start clean, so remove any distro `docker.io` package first.
Almost every app in our self-hosting guides runs the same way: a docker compose up -d on a small Ubuntu box. This is the guide to work through once — a fresh Ubuntu 26.04 server, a non-root user, Docker Engine from the official repository, a basic firewall, unattended security updates, and your first stack running — so every later guide on this site is just a compose file away. Every other deploy guide here assumes you've done this first.
Start from a fresh, updated box
Start from a fresh Ubuntu 26.04 LTS server. After your first SSH login as root, update the system and create a non-root user with sudo — you should not run containers, or anything else, as root day to day.
apt update && apt -y upgrade
adduser deploy
usermod -aG sudo deploy
adduser walks through an interactive prompt: a password (twice), then optional full name / room number / phone fields you can leave blank by pressing Enter. usermod -aG sudo deploy adds deploy to the sudo group without touching its other group memberships — using -G alone would wipe them.
Log out and back in as deploy for the rest of this guide:
ssh deploy@your-server-ip
sudo whoami # should print "root" after you enter your password — confirms sudo works
Install Docker Engine from the official repository
Skip the distro's older docker.io package — it lags Docker's own releases and doesn't ship Compose v2 at all. Install Docker's own apt repository instead, which gets you current Engine and the Compose plugin together, maintained as one unit.
# official one-line convenience script — inspect it first if you'd rather not pipe to sh:
# curl -fsSL https://get.docker.com -o get-docker.sh && less get-docker.sh
curl -fsSL https://get.docker.com | sh
The script detects your distro, adds Docker's apt repository and GPG key, and installs docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, and docker-compose-plugin in one pass. That last package is what gives you the docker compose subcommand — there's no separate Compose install step on a repository-based install.
Add your user to the docker group so you don't need sudo for every Docker command, and enable Docker's systemd service so it survives reboots (the install script does this by default on Ubuntu, but it's worth confirming):
sudo usermod -aG docker $USER
sudo systemctl enable --now docker
Group membership changes don't apply to your current login shell. Either run newgrp docker to pick it up in this shell, or just log out and back in — the fully clean option, and the one to use if anything downstream still complains about permissions.
Verify the install
docker run --rm hello-world
docker compose version
docker --version
The first command pulls a tiny test image and runs it. A working install prints:
Hello from Docker!
This message shows that your installation appears to be working correctly.
along with a short explanation of the steps Docker took to produce it. docker compose version prints something like Docker Compose version v5.x.x — as long as it's the space-separated docker compose form (not an error), the plugin is installed and wired up. If all three commands succeed without needing sudo, Engine, the Compose plugin, and your group membership are all confirmed working.
Set up a basic firewall (ufw)
This is the first guide most readers hit before deploying anything on the box, so it's the right place for the security baseline: allow SSH so you don't lock yourself out, allow 80 and 443 for the HTTPS guide that follows this one, deny everything else by default.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
OpenSSH is a ufw application profile that maps to port 22/tcp — using the name instead of the raw port number is clearer intent, but if your SSH daemon listens on a non-default port, allow that port number explicitly instead (sudo ufw allow 2222/tcp) or the rule won't match.
Do not skip the ufw allow OpenSSH step, and do not run ufw enable before it. ufw's default policy denies all incoming traffic that isn't explicitly allowed — if you enable it without an SSH rule in place, your current session keeps running until it disconnects, but the next connection attempt will hang and you'll be locked out with no way back in short of the VPS provider's console. ufw enable itself warns you about this:
Command may disrupt existing ssh connections. Proceed with operation (y|n)?
Type y only after you've confirmed the SSH rule is in the list. Check the result, and open a second terminal to confirm you can still SSH in before you close your first session:
sudo ufw status verbose
Expected output looks like:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
OpenSSH ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
OpenSSH (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)
Note what's not open: Docker containers you publish with -p or a compose ports: mapping are reachable directly on their published port regardless of ufw, on most default Docker installs — Docker manipulates iptables directly and its rules are evaluated ahead of ufw's. Don't publish a database or admin panel to 0.0.0.0 and assume ufw is protecting it; bind sensitive ports to 127.0.0.1 instead (127.0.0.1:5432:5432), the same pattern used throughout this site's other deploy guides.
Turn on automatic security updates
A server that runs unattended for months needs security patches applied without you remembering to SSH in and run apt upgrade every week. unattended-upgrades is Ubuntu's own tool for this, and it's installed on many cloud images by default — worth confirming either way.
sudo apt install -y unattended-upgrades apt-listchanges
sudo dpkg-reconfigure --priority=low unattended-upgrades
The reconfigure step asks "Automatically download and install stable updates?" — choose Yes. It writes /etc/apt/apt.conf.d/20auto-upgrades:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
The actual behavior — which origins it patches, whether it removes unused dependencies, whether it reboots — lives in /etc/apt/apt.conf.d/50unattended-upgrades. By default it applies ${distro_id}:${distro_codename}-security updates only, which is what you want on a production box: security patches land automatically, feature/version upgrades don't sneak in unattended. If you want it to reboot automatically for kernel updates that need one, uncomment and set:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
Test the configuration without waiting for the next scheduled run:
sudo unattended-upgrade --dry-run --debug
Verify it's actually scheduled to run:
systemctl status unattended-upgrades.service
and check /var/log/unattended-upgrades/unattended-upgrades.log after it's had a chance to run once — that's where to look first if you ever suspect it silently stopped working.
Your first stack
Drop a docker-compose.yml in a project folder and bring it up:
mkdir ~/uptime && cd ~/uptime
cat > docker-compose.yml <<'YAML'
services:
uptime-kuma:
image: louislam/uptime-kuma:1
ports: ["3001:3001"]
volumes: ["./data:/app/data"]
restart: unless-stopped
YAML
docker compose up -d
docker compose up -d pulls the image, creates the named volume-backed data directory, starts the container in the background, and returns your prompt. Confirm it's actually running and healthy:
docker compose ps
docker compose logs --tail 20
The restart: unless-stopped line is what makes the container survive reboots — Docker's own systemd service is enabled on boot (you confirmed that above with systemctl enable --now docker), so on boot it starts every container carrying that policy, unless you'd explicitly stopped it before the reboot.
Docker hygiene for the long haul
A box that runs Docker for months, not days, accumulates two kinds of debt if you never touch it again: unbounded log files, and stopped containers/unused images eating disk.
Cap container log size. Docker's default logging driver, json-file, has no size limit by default — a chatty container can quietly fill your disk over weeks with nothing but its own stdout/stderr. Set a global cap in the Docker daemon config:
sudo tee /etc/docker/daemon.json <<'JSON'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
JSON
sudo systemctl restart docker
This caps each container at 3 rotated 10 MB log files (30 MB max). It only applies to containers created after the change — restarting the daemon doesn't retroactively cap existing containers' logs, so recreate them (docker compose up -d --force-recreate, or just down then up -d) to pick it up.
Tail logs live instead of polling:
docker compose logs -f
docker compose logs -f uptime-kuma # a single service, once you have several
Update images deliberately. Compose doesn't auto-update running containers — you decide when:
docker compose pull
docker compose up -d
pull fetches newer images for any service with a tag (not a pinned digest); up -d then recreates only the containers whose image actually changed, leaving the rest untouched.
Reclaim disk from old images and containers. Every rebuild and pull leaves the previous image layers behind. Check what's actually using space, then clean it up:
docker system df
docker system prune
Plain docker system prune removes stopped containers, unused networks, dangling (untagged) images, and unused build cache — safe defaults, nothing currently in use gets touched. Two flags go further and are worth understanding before you reach for them: -a also removes any image not referenced by a running container (not just dangling ones — this can mean re-pulling a large base image next time you need it), and --volumes also removes volumes not used by at least one container, which can permanently delete data if a database container is simply stopped rather than running. Never run --volumes reflexively.
Troubleshooting
permission denied while trying to connect to the Docker daemon socket. You ran usermod -aG docker $USER but never picked up the new group membership in your current shell. Run groups — if docker isn't listed, either run newgrp docker or log out and back in. This is the single most common first-time Docker error on a fresh box.
docker-compose: command not found or the reverse — docker compose looks right but a script expects docker-compose. These are two different tools. docker-compose (hyphenated) is the old, now-deprecated standalone Python binary from Compose v1; docker compose (space) is the current plugin, part of docker-compose-plugin, which get.docker.com installs for you. A server provisioned earlier from a distro image or a different install method may have only one of the two, or — confusingly — both, doing slightly different things. Check what you actually have:
dpkg -l | grep -E 'docker-compose|docker-ce'
docker compose version # the plugin
docker-compose --version 2>/dev/null || echo "v1 binary not installed"
Prefer docker compose (space) everywhere going forward; it's the actively maintained one, and it's what every compose snippet on this site uses.
Disk fills up over time (no space left on device). Almost always unpruned images, stopped containers, or uncapped container logs, not the app itself. Check with docker system df and df -h, then work through the pruning and log-rotation steps above. If /var/lib/docker specifically is the culprit, du -sh /var/lib/docker/* narrows down whether it's images, containers, or volumes.
The get.docker.com script fails partway through. Most often this means an older or conflicting package is already present — docker.io, docker-doc, docker-compose (the old apt package), podman-docker, or a stray containerd/runc from another source. Remove the conflicting packages before rerunning:
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
sudo apt-get remove -y "$pkg"
done
If the script had already partially added Docker's apt repository before failing, a stale or malformed entry can also block a rerun — check /etc/apt/sources.list.d/docker.list and remove it if it looks broken (missing signed-by, wrong codename) before running curl -fsSL https://get.docker.com | sh again.
Where to go next
You're done when docker run --rm hello-world succeeds without sudo, docker compose ps shows your test stack running, sudo ufw status verbose shows only SSH/80/443 open, and systemctl status unattended-upgrades.service shows it enabled. From here, every app guide on this site is a copy-paste compose file onto this same base. Next, put HTTPS in front of whatever you deploy — see Automatic HTTPS with Caddy.