Skip to main content

Self-Host Dewiride Analytics on Ubuntu Server — Part 2: Close the Ports Docker Opened Behind Your Firewall

· 17 min read
Jagdish Kumawat
Founder @ Dewiride

Your firewall says Default: deny (incoming). Your database is answering the internet anyway. This part shows the port scan that proves it on a real server, explains the iptables chain order that causes it, and fixes it with a Compose override file — not by fighting the firewall.

This is Part 2 of a 6-part series.

  1. Part 1: Run the Stack with Docker Compose
  2. Part 2: Close the Ports Docker Opened Behind Your Firewall (you are here)
  3. Part 3: Custom Domain and Free HTTPS with Caddy
  4. Part 4: Claim the Install and Add Your First Website
  5. Part 5: Read the Dashboard — Humans, Bots and Engagement
  6. Part 6: Backups, Updates and Day-2 Operations

The Problem You Cannot See From the Server

At the end of Part 1 the stack was running and docker compose ps looked like this:

Output (PORTS column)
api 127.0.0.1:8080->8080/tcp
clickhouse 0.0.0.0:8123->8123/tcp, 0.0.0.0:9000->9000/tcp
postgres 0.0.0.0:5432->5432/tcp
web 0.0.0.0:3000->3000/tcp

Three of those say 0.0.0.0, which means every network address this machine has — including the public one. One says 127.0.0.1, which means the machine's own loopback and nothing else.

That difference is not cosmetic. 0.0.0.0 is a database listening to the internet.

"But UFW is on," you reasonably think, "and it only allows 22, 80 and 443." So did I. Let us check from somewhere that is not the server.

Proof: Scan It From Outside

Run this from your own computer, not from the server — the whole point is to see the machine the way a stranger sees it. Replace the address with your server's.

Local Machine Terminal
for p in 22 80 443 3000 5432 8123 9000; do
printf "port %-5s : " "$p"
nc -z -G 4 SERVER_IP "$p" 2>/dev/null && echo "OPEN" || echo "closed/filtered"
done

nc is netcat. -z means "just check whether it connects, send nothing", and -G 4 gives up after four seconds.

A port scan from a laptop on the public internet showing ports 3000, 5432, 8123 and 9000 all OPEN while the firewall allowed only 22, 80 and 443

Output
port 22 : OPEN
port 80 : closed/filtered
port 443 : closed/filtered
port 3000 : OPEN
port 5432 : OPEN
port 8123 : OPEN
port 9000 : OPEN

Look carefully at that, because it is stranger than a plain failure.

Ports 80 and 443 are closed — and UFW allows those. They are closed simply because nothing is listening on them yet. So the firewall is working; it is not misconfigured, and it has not silently died.

And yet 3000, 5432, 8123 and 9000 — none of which UFW allows — are wide open.

It gets worse. ClickHouse's HTTP interface answers a health probe with no credentials at all:

Local Machine Terminal
curl -s http://SERVER_IP:8123/ping
Output
Ok.

That is a real analytics database, on a real public IP, confirming its own existence to anyone who asks. The password still protects the data — but you have just told every scanner on the internet exactly what you are running and which version, and you are one weak password or one unpatched release away from a much worse day.

danger

If you followed Part 1 and stopped there, your server is in this state right now. Finish this part before you do anything else. It takes about ten minutes.

Why the Firewall Did Not Help

This is not a bug, and it is not UFW failing. It is two pieces of software with different ideas about who owns the packet filter, and Docker getting there first.

To show it safely — without exposing a database again — start a throwaway web server on a port nothing uses:

Ubuntu Server Terminal
docker run -d --rm --name ufw-demo -p 8099:80 nginx:alpine

Now ask UFW what it thinks:

Ubuntu Server Terminal
sudo ufw status
Output
Status: active

To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere

No mention of 8099. Now try it from your laptop:

Local Machine Terminal
curl -sI http://SERVER_IP:8099/
Output
HTTP/1.1 200 OK
Server: nginx/1.31.3
Content-Type: text/html

A web server the firewall never agreed to, answering the internet.

The chain order that explains it

Ubuntu Server Terminal
sudo iptables -L FORWARD -n --line-numbers

The iptables FORWARD chain listing DOCKER-USER and DOCKER-FORWARD at positions 1 and 2, ahead of every ufw chain

Output
Chain FORWARD (policy DROP)
num target prot opt source destination
1 DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0
2 DOCKER-FORWARD all -- 0.0.0.0/0 0.0.0.0/0
3 ufw-before-logging-forward all -- 0.0.0.0/0 0.0.0.0/0
4 ufw-before-forward all -- 0.0.0.0/0 0.0.0.0/0
5 ufw-after-forward all -- 0.0.0.0/0 0.0.0.0/0

There it is, in one screen. Docker's chains are at positions 1 and 2. UFW's do not start until position 3. Rules are evaluated top to bottom and stop at the first match, so a packet Docker accepts never reaches a UFW rule at all.

Two more details complete the picture:

Ubuntu Server Terminal
sudo iptables -t nat -L DOCKER -n
sudo iptables -t filter -L DOCKER -n
Output (excerpt)
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8099 to:172.17.0.2:80
ACCEPT tcp -- 0.0.0.0/0 172.17.0.2 tcp dpt:80

The first line rewrites the destination before any filtering happens, turning a packet addressed to your server into a packet addressed to the container. That reclassifies it from input traffic (destined for this machine) into forwarded traffic (passing through it) — and UFW's allow/deny rules that you wrote are input rules. They were never going to be consulted.

The second line then accepts it from 0.0.0.0/0 — anywhere at all.

Clean up the demonstration:

Ubuntu Server Terminal
docker stop ufw-demo

The container was started with --rm, so stopping it removes it.

The Fix: Do Not Publish What Nothing Needs

You could fight this by writing rules into the DOCKER-USER chain, which is the one hook Docker leaves for you at position 1. We will come back to that. But the better fix is to notice that three of those four ports never needed publishing in the first place.

Containers in a Compose project share a private network and find each other by service name. The engine reaches PostgreSQL at postgres:5432 and ClickHouse at clickhouse:8123 over that network, which does not involve the host's ports at all. Publishing them to the host is a convenience for a developer who wants to point a database tool at their laptop — and this is not a laptop.

So: publish nothing, except the one thing that must be reachable, and bind even that to loopback.

note

The compose.yaml in the repository says as much in its own opening comment — "sized for a laptop". It is not wrong; it is written for the machine most people first run it on. Moving to a server is what this file is for.

Step 1: Write a production override

Do not edit compose.yaml. Editing a tracked file means every future git pull is a merge conflict. Compose is designed to be layered instead: a second file whose values are merged over the first.

Ubuntu Server Terminal
cd ~/dewiride-analytics
nano compose.prod.yaml

The compose.prod.yaml override file resetting the PostgreSQL and ClickHouse port lists and rebinding the dashboard to the loopback address

compose.prod.yaml
# What changes when this stack stops being a laptop and becomes a server on the internet.
#
# Compose merges this file over compose.yaml. Nothing here adds a service: every entry replaces
# one setting on a service the main file already describes.

services:
# Neither store is reached from outside the stack. The engine finds them by service name on the
# private network Compose creates, which is where they were always meant to be answered.
#
# !reset empties the list rather than adding to it. Without it Compose merges the two lists and
# publishes the original ports as well, which is the opposite of what this file is for.
postgres:
ports: !reset []

clickhouse:
ports: !reset []

# The dashboard answers on this machine only. A reverse proxy is the single thing allowed in
# front of it, and in Part 3 that proxy runs on the host.
web:
ports: !override
- "127.0.0.1:${WEB_PORT:-3000}:3000"

!reset and !override are the whole trick, and they are easy to get wrong.

By default Compose merges lists rather than replacing them. Write ports: ["127.0.0.1:5432:5432"] in an override file and you get both bindings — the original 0.0.0.0 one and your new one — which achieves exactly nothing while looking like it worked. !reset [] empties the list. !override replaces it with what follows.

Both tags need Compose v2.24 or newer. Part 1 installed v5.5.0, so you are fine.

Step 2: Tell Compose to use both files

You could type -f compose.yaml -f compose.prod.yaml on every command forever, and forget it exactly once. Instead, put it in .env:

Ubuntu Server Terminal
echo "COMPOSE_FILE=compose.yaml:compose.prod.yaml" >> .env

COMPOSE_FILE is read by the docker compose command itself, not by the containers. From now on a plain docker compose up uses both files, in that order, and there is nothing to remember.

Check the merge before applying it:

Ubuntu Server Terminal
docker compose config | grep -A3 "published"

docker compose config prints the fully merged configuration without starting anything. If the postgres and clickhouse services still show published ports, your override is not being read — check the COMPOSE_FILE line for typos.

Step 3: Apply it

Ubuntu Server Terminal
docker compose up -d --wait

Compose notices that three containers now have different port settings, recreates those three, and leaves the rest alone. Your data is in volumes and is untouched.

Ubuntu Server Terminal
docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"

The docker compose ps output after the override, showing PostgreSQL and ClickHouse with no host binding at all and the dashboard bound to 127.0.0.1

Output
SERVICE STATUS PORTS
api Up 12 seconds (healthy) 127.0.0.1:8080->8080/tcp
clickhouse Up 13 seconds (healthy) 8123/tcp, 9000/tcp, 9009/tcp
postgres Up 13 seconds (healthy) 5432/tcp
web Up 6 seconds (healthy) 127.0.0.1:3000->3000/tcp

Compare that with the output at the top of this page. postgres now says 5432/tcp with no address in front of it — meaning the container listens on that port internally and the host publishes nothing. Same for ClickHouse. The dashboard is on loopback.

Step 4: Scan again

From your laptop, exactly the same command as before:

Output
port 22 : OPEN
port 80 : closed/filtered
port 443 : closed/filtered
port 3000 : closed/filtered
port 5432 : closed/filtered
port 8123 : closed/filtered
port 9000 : closed/filtered
Local Machine Terminal
curl -s -m 8 http://SERVER_IP:8123/ping || echo "(no answer)"
Output
(no answer)

One port open on the entire machine, and it is SSH. That is what a server running four services should look like from outside.

So How Do You Look at the Dashboard Now?

The dashboard is on 127.0.0.1:3000, which your browser cannot reach. Part 3 gives it a real domain. Until then, the correct tool is an SSH tunnel, and it is worth knowing regardless.

Run this on your own computer and leave it running:

Local Machine Terminal
ssh -N -L 3300:127.0.0.1:3000 username@server_ip
  • -L 3300:127.0.0.1:3000 — "listen on port 3300 on my machine, and forward anything arriving there to 127.0.0.1:3000 as seen from the server."
  • -N — do not run a shell; this connection exists only to carry the tunnel.

Now open http://localhost:3300 in your browser.

The Dewiride Analytics sign-in page loaded at localhost:3300 through an SSH tunnel, showing the email and password fields

The dashboard appears, over an encrypted connection, with nothing published to the internet to make it happen. Press Ctrl+C in that terminal to close the tunnel when you are done.

tip

If port 3300 is busy on your machine, pick another — -L 4300:127.0.0.1:3000 and browse to localhost:4300. The left-hand number is yours to choose; the right-hand pair describes the server and must stay 127.0.0.1:3000.

What About DOCKER-USER and ufw-docker?

Two other approaches come up whenever this is discussed, and both are legitimate. Here is the honest comparison.

ApproachHow it worksWhy we did not use it
Bind to 127.0.0.1 (this guide)The port is never published to a public address, so no rule is neededNothing to bypass. Works identically on every host, and survives a Docker upgrade
Rules in DOCKER-USERDocker leaves that chain at position 1 and never touches it, so your rules run firstCorrect, but it is a rule you must write, keep, and remember when adding services. A mistake fails open
ufw-docker helper scriptA third-party script that generates DOCKER-USER rules from UFW-style commandsConvenient, but a third-party dependency in your firewall's path, and still a deny-list

DOCKER-USER is genuinely the right tool when a container must be reachable from elsewhere but only from certain addresses — a database replica, say. For a stack where the only public surface is a web dashboard, not publishing the port is simpler and cannot be got wrong.

The principle underneath: a closed port needs no firewall rule. Prefer arrangements where the dangerous thing does not exist over arrangements where it exists and is guarded.

Common Mistakes

MistakeWhat happensFix
Adding ports: in the override without !resetCompose merges the lists — the 0.0.0.0 binding is still thereUse ports: !reset [] or ports: !override [...]
Editing compose.yaml directlyEvery git pull is now a merge conflictPut changes in compose.prod.yaml and leave the tracked file alone
Forgetting COMPOSE_FILECommands silently use only compose.yaml, and the override does nothingAdd it to .env, then confirm with docker compose config
Testing from the server itselfcurl localhost:8123 works even when the port is closed to the world, so everything looks fineAlways scan from a different machine
Assuming UFW covers DockerDatabases exposed for months without anyone noticingScan from outside after every change that touches ports
Using docker run -p out of habitPublishes to 0.0.0.0 again, bypassing all of thisWrite -p 127.0.0.1:8099:80 when you mean loopback

Troubleshooting

SymptomLikely causeWhat to do
Override appears to do nothingCOMPOSE_FILE not set, or a typo in the filenamedocker compose config shows exactly which files were merged
services.postgres.ports: unknown tag !resetCompose is older than v2.24docker compose version. Upgrade the plugin
Ports still open after up -dCompose did not recreate the containersdocker compose up -d --force-recreate
Dashboard unreachable and the tunnel errorsSomething already uses that port on your own machineChoose a different left-hand port for -L
bind: address already in use on the serverAnother process holds 3000Change WEB_PORT in .env
The engine cannot reach the databases after the changeYou removed the wrong thingThe engine uses the internal network, so postgres:5432 must still work. docker compose logs api will say

FAQ

Is this a Docker bug? No, and it is documented behaviour. Docker manages its own iptables rules because it has to — it creates networks and containers dynamically, and cannot wait for an administrator to add rules. The surprise is only that most firewall tutorials never mention it.

Does iptables=false in the Docker daemon config fix it? It stops Docker writing rules, and it also stops container networking working until you write every rule yourself. That is a real option for people who manage their own packet filter. It is not a good first move.

Was my database actually compromised? Probably not — the password still applied. But you cannot know, and internet-wide scanners find a new open port in minutes, not days. If it was exposed for any length of time, rotate both passwords: change them in .env, then docker compose down --volumes and start fresh, accepting that this deletes measured data. There is no way to change a database password without either an admin session or a rebuild.

Should I close port 22 too? Not unless you have another way in. Do harden it: use SSH keys and disable password authentication, and consider moving it off port 22 to quieten the logs.

What if I genuinely need to reach PostgreSQL from my laptop? Tunnel it, exactly as with the dashboard: ssh -N -L 5433:127.0.0.1:5432 username@server_ip, then point your database tool at localhost:5433. That works even with nothing published, because SSH connects from inside the server — and it is encrypted, authenticated, and leaves no port open.

Does this apply to other Compose stacks? Every one of them. Any docker run -p or ports: entry without an address in front publishes to 0.0.0.0. It is worth scanning any server you already run.

Conclusion

Your firewall was never broken. It was simply never asked, because Docker's rules sit two positions ahead of it in the chain and rewrite the packet's destination before filtering begins.

The fix was not to fight that. It was to stop publishing three ports that only ever needed to exist on a private network, and to bind the fourth to loopback — so there is nothing for a rule to guard.

The dashboard is now safely unreachable, which is a slightly awkward kind of success. Part 3 gives it a real address and a padlock.

← Previous: Part 1: Run the Stack with Docker Compose Next: Part 3: Custom Domain and Free HTTPS with Caddy →

Additional Resources

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.