Step-by-step tutorial, 2026
Install Docker on Ubuntu. Then stop it opening holes in your firewall.
Installing Docker takes five minutes. The part most guides skip is what happens next: your firewall stops applying to your containers.
ufw status
Status: active 22/tcp ALLOW 8080/tcp DENY
- Requestto port 8080
- Docker’s nat ruleredirects it to the container
- ufw (INPUT)never sees it
- Containeranswers 200 OK
What we measured: ufw denied 8080, and the container still answered from outside.
The short answer
Install from Docker’s own apt repository: add the key and repo, then sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin, and test with sudo docker run hello-world. Full commands below.
On a VPS, do one more thing: publish ports as 127.0.0.1:8080:80, not 8080:80. We tested it: a normally published port answered from outside even with ufw set to deny it.
You install Docker, start a container with -p 8080:80, and your firewall says port 8080 is closed. It isn’t.
We tested it on a fresh Ubuntu 24.04 with Docker 29.9.2. With ufw denying every incoming port except SSH, an ordinary web server was blocked. The Docker container answered from outside. Then we added ufw deny 8080. Still answered.
This isn’t a bug. It’s how Docker works: published ports bypass your firewall rules. Most install guides never mention it, and some even open the Docker API to the network.
So this tutorial installs Docker the current official way, then fixes the three things that bite on a public server. Twenty minutes, and your containers are only as public as you decide.
In this guide you’ll learn
- The official install for Ubuntu 22.04, 24.04 and 26.04, without deprecated apt-key
- Our firewall test: seven setups, which ones ufw actually blocked
- A free checker: paste
docker ps, see which ports the internet can reach - Three fixes, from one-line to server-wide
- Why the docker group is root, and what to do about it
- Logs that fill your disk, and the one setting that stops it
Chapter 1
Install Docker the official way
Docker supports Ubuntu 22.04, 24.04, 26.04 and 26.10, 64-bit. Run these as your sudo user.
Remove conflicting packages
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
On a fresh server this removes nothing. That’s fine.
Add Docker’s repository
sudo apt update sudo apt 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 sudo tee /etc/apt/sources.list.d/docker.sources <<EOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt updateInstall
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Check it works
sudo docker run hello-world docker compose version
On Ubuntu, Docker starts on boot by default.
| Method | Version | Use it? |
|---|---|---|
| Docker’s apt repo (above) | Current release | Yes: the documented method |
| Ubuntu’s docker.io package | Ubuntu’s own build, often older | Works, but Compose and docs lag behind |
| get.docker.com script | Current release | No: meant for testing, not servers |
The convenience script is meant for testing and development, not a server you rely on. And if a guide leads with apt-key add, skip it: that’s deprecated, and the keyring file above replaces it.
Chapter 2
What we found: ufw doesn’t see Docker ports
Ubuntu 24.04, Docker 29.9.2, ufw set to deny all incoming except SSH. We sent each request from a separate machine.
| Setup | Reached from outside? |
|---|---|
| Plain web server on port 9090, no Docker | No: ufw blocked it |
| docker run -p 8080:80 | Yes, 200 OK |
| Same, plus ufw deny 8080/tcp | Yes, 200 OK |
| docker run -p 127.0.0.1:8080:80 | No (works from the server itself) |
| -p 8080:80 plus a DOCKER-USER drop rule | No |
| DOCKER-USER rule allowing one IP | Yes, from that IP only |
| Compose "8080:80" with the daemon.json below | No |
Why: ufw filters traffic in the INPUT chain. Docker redirects traffic for a published port earlier, in the nat table, and sends it on to the container. Those packets are routed before the firewall rules can be applied.
And by default, a published port listens on every address, 0.0.0.0 and [::]. Publishing is open to the world unless you say otherwise.
Chapter 3
Which of your ports are public?
Run docker ps on your server and paste the output. The checker lists every published port, says who can reach it, and writes the localhost-only version.
Paste your docker ps output on the left.
A public 80 or 443 is usually fine: that’s your reverse proxy or website. A public database or admin panel is the one to fix today.
Moving to a fresh server anyway? Deploy a Lite 6, set the daemon.json from the next chapter before your first container, and nothing is ever public by accident.
Chapter 4
Three ways to fix it
All three worked in our test. Start with the first.
1. Publish to localhost, put a reverse proxy in front
# docker run
docker run -d -p 127.0.0.1:8080:80 nginx
# compose.yaml
services:
app:
ports:
- "127.0.0.1:8080:80"Only the server itself can reach port 8080. A reverse proxy (Caddy, nginx, Traefik) on 80 and 443 is then the only thing facing the internet, and it adds HTTPS.
2. Make localhost the default for the whole server
Put this in /etc/docker/daemon.json, then sudo systemctl restart docker:
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": { "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1" }
},
"log-driver": "local"
}"ip" covers the default network; the host_binding_ipv4 option covers user-defined networks, which is what Compose creates. In our test a Compose service with plain "8080:80" then listened on 127.0.0.1 only. Ports you really want public, like your proxy’s 443, now need 0.0.0.0:443:443 written out.
3. Allow-list in the DOCKER-USER chain
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
Docker processes DOCKER-USER before its own rules, so this drops traffic to published ports from everyone except 203.0.113.10. Swap in your interface (ip route shows it) and your IP.
Mythufw deny on the port will close it.
We tried. The port still answered from outside.
MythSet "iptables": false and ufw will work again.
That isn’t meant for most setups, and it will likely break container networking.
Chapter 5
The docker group is root
Every guide tells you to run sudo usermod -aG docker $USER so you can drop sudo. Few say what it means.
Here’s the thing: the docker group grants root-level privileges. Anyone in it can start a container that mounts / and change any file on the server.
- Only youHow to check: Add only the accounts you’d trust with sudo. Never a web app’s user.
- Never expose the APIHow to check: Ports 2375 and 2376 are the Docker API. Open to the internet, that’s root for anyone who finds it. Our checker flags them.
- Want less?How to check: Rootless mode runs the daemon as a normal user. It has limits, so read them first.
Chapter 6
Stop logs filling your disk
By default, container logs go to JSON files that can grow large and exhaust disk space. On a 40 GB VPS, a chatty container can fill it.
The local logging driver rotates and compresses logs by itself, keeping 100 MB per container. It’s the "log-driver": "local" line in the daemon.json above. Restart Docker, then recreate containers: the setting only applies to containers created after the change.
sudo systemctl restart docker docker compose up -d --force-recreate
Chapter 7
Keeping Docker and images updated
Docker itself and your containers update separately.
- Docker Engine comes from apt. Ubuntu’s automatic updates skip Docker’s repo unless you add it; our unattended upgrades tutorial shows the line, and why restarting Docker automatically needs care.
- Your images never update through apt. Pull and recreate:
docker compose pull docker compose up -d docker image prune
The last line removes old images that nothing uses. It asks before deleting.
Now it’s your move
Docker on a VPS is two jobs: install it properly, then decide what the internet can see. Run the checker after every new service.
Planning what to run? Our VPS use-case planner sizes a server for a stack of apps. Then lock down logins with the SSH hardening tutorial.
Ready to ship it? Deploy a Lite 6, paste the install block, and your compose stack is live soon after the server is ready (usually within 15 minutes). Billed by the hour, never more than the monthly price.