GOING FURTHER
Tested for real: I ran every command in this guide top-to-bottom on a fresh Ubuntu 24.04 virtual machine (4 CPUs, 8 GB RAM) on 13 August 2026. Versions at test time: Docker Engine 29.7.2, Docker Compose v5.4.0, Uptime Kuma 2.5.0, on Ubuntu 24.04.4 LTS.
Found a problem? Tell me via the contact page.
What you'll end up with
A real service — Uptime Kuma, a status-monitoring app — running from one small text file. It survives reboots, and its data survives upgrades. Then the part most guides skip. I prove, with real output from a second machine, that Ubuntu's firewall does not protect Docker's published ports. Then I show you the fix.
NAME IMAGE STATUS PORTS
uptime-kuma louislam/uptime-kuma:2 Up (healthy) 127.0.0.1:3001->3001/tcp
Who this is for
GOING FURTHER. It assumes you installed Docker with the Docker install guide, including the docker group step. One deliberate branch near the end.
Time and cost
My machine time was about 15 minutes, including a reboot and firewall work. Allow 45 minutes end to end. Free. The Uptime Kuma image is about 500 MB.
Words you'll meet
- Compose file — a text file describing a service: which image, which ports, where data lives. One file, whole service. See /basics.
- Named volume — a storage area Docker manages for a container. It outlives the container, which is the whole point.
- Published port — a door Docker opens so the outside world can reach a container.
- UFW — Uncomplicated Firewall, Ubuntu's built-in firewall tool.
- Binding — which network addresses a port listens on.
0.0.0.0means "every address, everyone";127.0.0.1means "this machine only". - iptables — the low-level packet rulebook in Linux. Both UFW and Docker write rules into it. That shared rulebook is where today's trap lives.
Placeholders
YOUR-SERVER-IP means your server's address on your network (find it with ip -4 addr). Everything else is copy-paste as written.
Before you start
- Docker installed per the Docker install guide — that guide is the only prerequisite.
- A second machine on the same network (a laptop is fine) for the firewall proof. You can follow without one, but seeing it yourself is the lesson.
The steps
Step 1 — Write the compose file
One folder per service keeps things tidy. Make one and open a new file in it.
mkdir -p ~/uptime-kuma
nano ~/uptime-kuma/compose.yaml
Paste exactly this, then save (Ctrl+O, Enter) and exit (Ctrl+X):
services:
uptime-kuma:
image: louislam/uptime-kuma:2
container_name: uptime-kuma
volumes:
- kuma-data:/app/data
ports:
- "3001:3001"
restart: unless-stopped
volumes:
kuma-data:
Four lines carry the weight. image is what to run. volumes gives the app a permanent home for its data. ports opens door 3001. restart: unless-stopped means "come back after reboots unless I stopped you on purpose".
Check it worked.
cat ~/uptime-kuma/compose.yaml
You should see the file back, exactly as pasted. Indentation matters in this format — two spaces, no tabs.
Step 2 — Start it
From the service's folder, one command downloads the image and starts everything the file describes.
cd ~/uptime-kuma
docker compose up -d
You should see something like (trimmed — the download scrolls for a while):
Image louislam/uptime-kuma:2 Pulled
Volume uptime-kuma_kuma-data Created
Network uptime-kuma_default Created
Container uptime-kuma Started
The important line is Container uptime-kuma Started.
In my test the download and start took 37 seconds. The -d means detached: it runs in the background and gives your terminal back.
Check it worked.
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001
302
302 is a redirect — Uptime Kuma is answering and pointing new visitors at its setup page. From your laptop's browser, http://YOUR-SERVER-IP:3001 shows that page. (Remember this works from the laptop — it matters in step 4.)
Step 3 — Reboot and make sure it comes back
A service you must remember to restart is a chore, not a service. Reboot the machine.
sudo reboot
Your connection drops. Wait a minute, log back in, then:
Check it worked.
docker ps
Your output will vary, but look for:
CONTAINER ID IMAGE STATUS PORTS NAMES
1fc5ee841e0c louislam/uptime-kuma:2 Up 5 seconds (health: starting) 0.0.0.0:3001->3001/tcp uptime-kuma
The important part is Up in the STATUS column.
Nobody started that. restart: unless-stopped did. In my test it was up 5 seconds after I could log in.
Step 4 — Turn on the firewall, block the port… and watch Docker ignore it
Here is the trap this guide exists for. Let's walk into it deliberately.
First, allow SSH — do this before enabling the firewall or you lock yourself out:
sudo ufw allow OpenSSH
Rules updated
Rules updated (v6)
Now switch the firewall on. It will warn you about SSH; we just allowed it, so answer y:
sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
The important line is Firewall is active and enabled on system startup.
Now explicitly deny our service's port:
sudo ufw deny 3001/tcp
Rules updated
Rules updated (v6)
Check the firewall believes you.
sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), deny (routed)
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
3001/tcp DENY IN Anywhere
The important lines are deny (incoming) and 3001/tcp DENY IN.
Denied, in writing. Now go to your second machine and ask for the page anyway:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://YOUR-SERVER-IP:3001
302
It answered. Default deny incoming, an explicit deny rule on 3001 — and the page is served to the whole network regardless.
To prove the firewall itself is fine, I ran a plain non-Docker web server on the same machine on port 8000. I denied that port too, then asked from the laptop again:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://YOUR-SERVER-IP:8000
000
Timeout. Blocked, exactly as ordered. The firewall works — for everything except Docker.
Why
UFW writes its rules into iptables, Linux's packet rulebook. Docker writes its own port-forwarding rules into the same rulebook — at a point traffic reaches before UFW's rules are consulted. So for published ports, Docker's "let it in" wins and UFW's "deny" is never even read. Neither tool is broken. They're both writing in the same book, and Docker's rule is reached before UFW's deny rule. This bit confused me too, and it has quietly exposed a lot of home servers.
Step 5 — The fix: publish to localhost only
The port line in the compose file currently says "every address". Change it to "this machine only". Edit the file:
nano ~/uptime-kuma/compose.yaml
Change the ports line from "3001:3001" to:
ports:
- "127.0.0.1:3001:3001"
Apply the change — compose notices the difference and rebuilds only what changed:
docker compose up -d
Container uptime-kuma Recreate
Container uptime-kuma Started
Check it worked. On the server, note the PORTS column now names 127.0.0.1:
docker ps --format "table {{.Names}}\t{{.Ports}}"
NAMES PORTS
uptime-kuma 127.0.0.1:3001->3001/tcp
The server can still reach it:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001
302
And from the laptop, the same request that sailed through before:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://YOUR-SERVER-IP:3001
000
Timeout. The door only opens from inside the machine now. Your data survived the rebuild, by the way — that's the named volume doing its job.
The branch: but I wanted to reach it from my laptop
Two honest options:
- Keep 127.0.0.1 and add a doorman. Put a reverse proxy (like Caddy) or a VPN (like Tailscale) in front. This is the pattern I use and recommend; my tunnel and Tailscale guides cover it.
- Teach Docker to respect your firewall via the
DOCKER-USERiptables chain — a rules area Docker checks and lets you win in. It is the deeper fix, but it is hand-written iptables. The ufw-docker project documents it well. Advanced path; use only if you are ready to maintain iptables rules.
Something went wrong?
- You see
Abortedafterufw enable→ it means the y/n prompt was answered with anything buty(this bit me: my first scripted run aborted here and the firewall silently stayed off) → runsudo ufw status; if it saysStatus: inactive, runsudo ufw enableagain and typey. - You see
Skipping adding existing rule→ it means the rule was already there from an earlier attempt → harmless, carry on. - You see
000from curl on the laptop in step 4 where I saw302→ it means the trap didn't spring for you — check you're testing the Docker port (3001), not an ordinary one, and thatdocker psshows0.0.0.0:3001not127.0.0.1:3001.
Undo all of this
Tested for real. From the service folder:
Stop and remove the container and its network — data survives this:
cd ~/uptime-kuma
docker compose down
Container uptime-kuma Removed
Network uptime-kuma_default Removed
I checked: docker volume ls still showed uptime-kuma_kuma-data afterwards. To delete the data too, add -v:
docker compose down -v
Volume uptime-kuma_kuma-data Removed
The important line is Volume uptime-kuma_kuma-data Removed.
Remove the image and the folder:
docker image rm louislam/uptime-kuma:2
rm -r ~/uptime-kuma
Put the firewall back how you found it (this disables it and wipes the rules — skip if you're keeping UFW on):
sudo ufw reset
Check it worked. docker ps -a shows no uptime-kuma; docker volume ls shows no kuma-data; sudo ufw status says whatever you chose it to say.
Where to go next
- Know when things break: a tiny monitoring setup — Uptime Kuma returns, this time doing its actual job.
- Official reference: docs.docker.com/engine/network/packet-filtering-firewalls — Docker's own page on the iptables behaviour you just witnessed.
Last tested: 13 August 2026 on Ubuntu 24.04.4. Versions: Docker Engine 29.7.2, Compose v5.4.0, Uptime Kuma 2.5.0.
Tried it? Improved it?
Tell the forum what worked and what didn’t: real experience beats recommendations, and the best answers get folded back into this guide with credit.
Related guides
SSH keys, properly: never type a server password again
Your laptop will hold a small pair of files called an SSH key. Your server will trust it, so logging in never asks for a password — then we turn password logins off completely.
12 min read
First hour with a new Ubuntu server: locking the doors
A fully updated server, a personal account with admin rights, a firewall that blocks everything except SSH, and security updates that install themselves.
10 min read