There is a small Docker habit that causes a surprisingly large security problem on home servers:
ports:
- "8080:80"
It looks harmless. It looks like a normal Docker Compose example. It is in half the tutorials on the internet.
Then you check UFW and it says the firewall is active. Default incoming traffic is denied. Port 8080 is not allowed. Everything looks fine.
Except the container may still be reachable.
This is one of those Linux home server problems that feels like a bug the first time you see it. It is not really a bug. It is Docker doing exactly what Docker is designed to do: create its own firewall and NAT rules so published container ports work.
That is the part many people miss.
Last updated: June 2026
This guide is for the usual homelab setup: Ubuntu, Debian or a similar Linux server, rootful Docker Engine, Docker Compose, UFW enabled, and a few self-hosted services running on an old workstation, mini PC, laptop server or small VPS.
If you are building the whole setup from scratch, read this together with the Linux Home Server Security Guide, the UFW Firewall Rules for Home Servers post, and the Docker Security for Homelab Beginners guide.
1. The short version
UFW is still useful on a Docker host, but it does not mean what many people think it means once Docker publishes ports.
| What you do | What many people expect | What can actually happen |
|---|---|---|
sudo ufw default deny incoming |
All unsolicited inbound traffic is blocked. | Normal host services are blocked, but Docker-published ports may still be reachable through Docker's own rules. |
ports: "8080:80" |
Container is available only if UFW allows port 8080. | Docker publishes the port and creates firewall/NAT rules for it. |
expose: "80" |
Service is available from outside. | No. It is not published to the host. Other containers on the right network can use it. |
127.0.0.1:8080:80 |
Service is private to the host. | Usually yes. This is a good pattern for same-host reverse proxies and local testing. |
DOCKER-USER chain |
Advanced firewall detail. | This is where Docker expects user-defined filtering before Docker's own forwarding rules. |
The practical rule is simple:
Do not publish a Docker port unless you actually want something outside Docker to reach it.
For most home servers, publish only the reverse proxy on ports 80 and 443. Keep backend containers on Docker networks. Bind local-only services to 127.0.0.1. Use the DOCKER-USER chain only when you really need host-level filtering for Docker-published ports.
2. Why this happens
UFW is a friendly frontend for Linux firewall rules. On Ubuntu, it is the normal way to manage a host firewall without writing raw iptables or nftables rules all day. The Ubuntu Server firewall documentation describes UFW as the default firewall configuration tool for Ubuntu and a user-friendly way to create an IPv4 or IPv6 host-based firewall.
For a normal service running directly on the host, the mental model works well:
Internet or LAN
-> Linux host
-> UFW rules
-> SSH, Nginx, Samba, etc.
If UFW denies incoming traffic and you have not allowed a port, a normal host service should not be reachable from outside.
Docker changes the path.
When you publish a container port, Docker creates rules on the host so traffic can be redirected to the container. Docker's own packet filtering and firewalls documentation says that on Linux, Docker creates firewall rules for network isolation, port publishing and filtering. It also says that Docker and UFW use firewall rules in ways that make them incompatible with each other, because published container traffic is diverted before it goes through the UFW chains normally used for host input and output.
That means this Compose line is not just a bit of documentation:
ports:
- "8080:80"
It is a request to publish container port 80 on host port 8080.
Docker then makes that work by adding its own rules. If the host has a public IP, that can mean public access. If the host is behind a home router, that can mean LAN access and possibly internet access if the router forwards the port.
So the real path often looks closer to this:
Internet or LAN
-> Linux host network stack
-> Docker NAT / forwarding rules
-> container port
UFW still protects normal host services. It still matters for SSH. It still matters for services not handled by Docker publishing. But it is not the whole story for containers.
3. A quick test that proves the problem
Do not run this on an important public server without understanding it. Use a spare VM, a local test box, or a temporary container you can remove afterwards.
First, set a normal UFW baseline:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status verbose
You should see something like:
Status: active
Default: deny (incoming), allow (outgoing), deny (routed)
Now start a simple test container:
docker run -d --name ufw-docker-test -p 8080:80 nginx:alpine
Check what Docker thinks it published:
docker ps --format "table {{.Names}}\t{{.Ports}}"
You may see something like:
NAMES PORTS
ufw-docker-test 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
Now test from another machine, not from the Docker host itself:
curl -I http://SERVER_IP:8080
Or scan from another trusted machine:
nmap -Pn -p 8080 SERVER_IP
If the port is reachable, UFW did not save you from that published Docker port.
Clean up:
docker rm -f ufw-docker-test
Important detail: test from the right place. Testing from the Docker host itself does not prove what the LAN or internet can reach. For a home server, test from another LAN device and, if you have router port forwarding, from outside the home network as well.
4. The difference between ports, expose and Dockerfile EXPOSE
This is where many Compose files go wrong.
ports publishes a port to the host
services:
app:
image: example/app
ports:
- "8080:80"
This means:
host port 8080 -> container port 80
If you do not specify a host IP address, Docker normally binds the published port to all host addresses. On a dual-stack host, that may include IPv4 and IPv6. Docker's port publishing documentation is blunt about this: published container ports are insecure by default because they become available outside the Docker host as well.
This is the dangerous one.
127.0.0.1:8080:80 publishes only on loopback
services:
app:
image: example/app
ports:
- "127.0.0.1:8080:80"
This means:
localhost on Docker host port 8080 -> container port 80
That is useful when a reverse proxy on the same host needs to connect to the service through localhost, or when you want a temporary local test.
I still prefer Docker networks for reverse proxy setups, but loopback binding is much better than publishing to 0.0.0.0 by habit.
expose does not publish to the host
services:
app:
image: example/app
expose:
- "80"
This does not make the service reachable from the LAN or internet. It is mainly documentation for the container port and a hint for container-to-container setups.
Also, do not treat expose as a hard security boundary inside a Docker network. Containers on the same Docker network can usually reach each other by service name and container port. The real security improvement is that nothing has been published to the host.
Dockerfile EXPOSE is not publication either
EXPOSE 80
This is metadata in the image. It tells humans and tools what port the application expects to use. It does not open the port on the host by itself.
The line that changes your host exposure is usually ports in Compose or -p / --publish on the Docker command line.
5. The safest homelab pattern: publish less
The best fix is not clever firewall work. The best fix is to publish fewer things.
A good Docker home server layout looks like this:
Home router
-> forwards 80/tcp and 443/tcp only
-> Docker reverse proxy
-> private Docker network
-> backend containers with no host ports
Create a shared network for services that should sit behind the proxy:
docker network create proxy
Then put a backend service on that network without publishing it:
services:
uptime-kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
volumes:
- ./data:/app/data
expose:
- "3001"
networks:
- proxy
networks:
proxy:
external: true
Only the reverse proxy should publish public web ports:
services:
reverse-proxy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
volumes:
caddy_data:
caddy_config:
networks:
proxy:
external: true
That design is boring. Boring is the point.
You do not need UFW to rescue ten accidentally published admin dashboards if those dashboards are never published in the first place.
After changing Compose files, check the published ports:
docker compose up -d
docker ps --format "table {{.Names}}\t{{.Ports}}"
Good output looks like this:
NAMES PORTS
reverse-proxy 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
uptime-kuma 3001/tcp
postgres 5432/tcp
redis 6379/tcp
The backend ports are visible as container ports, but not published as host ports. That is the habit you want.
If you are choosing the proxy itself, the companion guide is Reverse Proxy for Home Servers: Caddy vs Nginx Proxy Manager vs Traefik. If you are running monitoring, the Uptime Kuma for Home Servers guide fits well here. Monitor the public URL through the reverse proxy and, when useful, monitor the backend from inside the LAN.
6. A practical UFW baseline for a Docker host
Even with Docker in the picture, I still use UFW on home servers. I just do not pretend it controls every Docker-published port.
A basic home server baseline might be:
# SSH from LAN only. Adjust the subnet.
sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp
# Public web traffic to the reverse proxy, if this server hosts public web services.
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
sudo ufw status verbose
If you do not expose public web services, do not allow 80 and 443. If SSH is only used through Tailscale, WireGuard or another private network, restrict it to that interface or source range instead of the whole LAN.
This baseline protects normal host services and documents your intended exposure. It also keeps you honest when you run:
sudo ufw status verbose
But for Docker, you still need to check:
docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo iptables -S DOCKER-USER
sudo iptables -t nat -S DOCKER
On some systems, especially when Docker is not using the userland proxy, ss -tulpn may not tell the whole story for published containers. Use Docker's port output and test from another host.
7. When loopback binding is the right fix
Sometimes a service needs to be reachable from the Docker host, but not from the LAN or internet.
Use loopback binding:
services:
adminer:
image: adminer:latest
restart: unless-stopped
ports:
- "127.0.0.1:8081:8080"
Now the service should be reachable from the Docker host at:
http://127.0.0.1:8081
But it should not be reachable from another device using:
http://SERVER_LAN_IP:8081
Verify that from another machine. Do not just trust the Compose file.
This is a good pattern for:
- temporary admin tools;
- database web UIs used through an SSH tunnel;
- local development services;
- apps that a same-host reverse proxy reaches through localhost.
Example SSH tunnel from your laptop:
ssh -L 8081:127.0.0.1:8081 user@server-ip
Then open:
http://127.0.0.1:8081
For SSH itself, harden it properly first. The SSH Hardening for Home Servers post covers keys, password login, root login and safe lockout prevention.
8. When to use the DOCKER-USER chain
The DOCKER-USER chain is the place Docker leaves for your own filtering rules before Docker's forwarding rules accept container traffic. Docker's Docker with iptables documentation describes DOCKER-USER as the user-defined chain that is processed before Docker's own forwarding chains.
Use it when you really need host-level filtering for Docker-published ports.
Examples:
- a VPS with Docker where published ports would otherwise be public;
- a home server where you want only LAN clients to reach Docker-published ports;
- a server where only 80/443 should be public, but some other published ports should be LAN-only;
- a temporary migration where you cannot clean every Compose file immediately.
Before adding rules, identify your external interface:
ip route get 1.1.1.1
You might see something like:
1.1.1.1 via 192.168.1.1 dev enp3s0 src 192.168.1.50
In that example, the interface is enp3s0.
You can also extract it like this:
EXT_IF=$(ip route get 1.1.1.1 | awk '{for (i=1; i<=NF; i++) if ($i=="dev") print $(i+1)}')
echo "$EXT_IF"
Here is a conservative example policy:
# Change this to match your real LAN.
LAN_CIDR="192.168.1.0/24"
# Change this if auto-detection picks the wrong interface.
EXT_IF=$(ip route get 1.1.1.1 | awk '{for (i=1; i<=NF; i++) if ($i=="dev") print $(i+1)}')
# Keep established connections working.
sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# Allow LAN clients to reach Docker-published ports.
sudo iptables -I DOCKER-USER 2 -i "$EXT_IF" -s "$LAN_CIDR" -j RETURN
# Allow public HTTP/HTTPS to a reverse proxy if you intentionally publish it.
# This assumes the proxy container receives traffic on container ports 80 and 443.
sudo iptables -I DOCKER-USER 3 -i "$EXT_IF" -p tcp -m multiport --dports 80,443 -j RETURN
# Drop other new external connections to Docker containers.
sudo iptables -I DOCKER-USER 4 -i "$EXT_IF" -j DROP
Read that twice before using it.
It says:
- existing connections are allowed to continue;
- LAN clients can reach published container ports;
- public HTTP and HTTPS can reach the reverse proxy;
- other new external connections to Docker containers are dropped.
If you do not run a public reverse proxy, remove the 80/443 allow rule. If your LAN is not 192.168.1.0/24, change it. If your public proxy maps unusual host ports to different container ports, do not blindly reuse the 80/443 rule.
Check the result:
sudo iptables -S DOCKER-USER
Then test from another LAN device and from outside the network.
There is an important technical detail here: by the time traffic reaches DOCKER-USER, Docker may already have performed DNAT. That means simple --dport matching may see the container port, not the original host port. Docker's documentation notes that matching the original destination IP and port requires conntrack original-destination matching. For a homelab, the cleaner answer is usually to avoid odd port mappings and publish only the proxy on normal ports.
Making DOCKER-USER rules persistent
Do not rely on commands typed once into a shell. They will disappear after a reboot unless something restores them.
One practical approach is a small systemd unit that runs after Docker starts. This keeps the rules visible and easy to remove.
Create a script:
sudo nano /usr/local/sbin/docker-user-firewall.sh
Example script:
#!/bin/sh
set -eu
LAN_CIDR="192.168.1.0/24"
EXT_IF="$(ip route get 1.1.1.1 | awk '{for (i=1; i<=NF; i++) if ($i=="dev") print $(i+1)}')"
# This script owns DOCKER-USER on this host. Do not use it if you already keep
# other custom rules there unless you merge them carefully.
iptables -N DOCKER-USER 2>/dev/null || true
iptables -F DOCKER-USER
iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A DOCKER-USER -i "$EXT_IF" -s "$LAN_CIDR" -j RETURN
iptables -A DOCKER-USER -i "$EXT_IF" -p tcp -m multiport --dports 80,443 -j RETURN
iptables -A DOCKER-USER -i "$EXT_IF" -j DROP
iptables -A DOCKER-USER -j RETURN
Make it executable:
sudo chmod 700 /usr/local/sbin/docker-user-firewall.sh
Create the service:
sudo nano /etc/systemd/system/docker-user-firewall.service
Unit file:
[Unit]
Description=Apply DOCKER-USER firewall rules
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-user-firewall.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Enable and run it:
sudo systemctl daemon-reload
sudo systemctl enable --now docker-user-firewall.service
sudo systemctl status docker-user-firewall.service
sudo iptables -S DOCKER-USER
This is intentionally boring. A small script you understand is better than a mysterious firewall state you cannot rebuild.
Again: this is an example policy, not a universal one. Firewalls are one of those places where copy-paste confidence can become a very long evening.
9. What I would not do first
I would not disable Docker's iptables integration as the first fix
You will see advice telling you to set Docker's iptables option to false.
I do not recommend that as a default homelab fix.
Docker's own documentation warns that preventing Docker from creating most of its firewall rules is not appropriate for most users and is likely to break container networking. You can make it work if you know exactly which rules you need to replace, but that is not where I would start on a home server.
I would not publish databases to the LAN because “it is only at home”
This is bad:
services:
postgres:
image: postgres:16
ports:
- "5432:5432"
Most applications that need Postgres can reach it over a Docker network:
services:
app:
image: example/app
environment:
DATABASE_URL: postgres://app:password@postgres:5432/app
networks:
- internal
postgres:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: change-me
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- internal
networks:
internal:
driver: bridge
No host port is needed there.
I would not use network_mode: host casually
network_mode: host removes a large part of Docker's network isolation. The container shares the host network stack. For some monitoring or low-level network tools, that can be the right answer. For a random dashboard copied from a Compose example, it is usually not.
If you use host networking, treat the service like a host service. Check what it listens on. Make sure UFW allows only what you intend. Do not assume Docker port mapping rules apply, because there is no normal Docker port mapping in host network mode.
10. IPv6: the part people forget
IPv6 makes this easier to get wrong.
Many home networks now have IPv6. Some servers have public IPv6 even when IPv4 is behind NAT. Docker's documentation notes that when a container port is mapped without a specific host address, Docker publishes to all host addresses by default, including 0.0.0.0 and [::].
So check both address families:
docker ps --format "table {{.Names}}\t{{.Ports}}"
ss -tulpn -4
ss -tulpn -6
sudo ufw status verbose
If you test from outside, test IPv4 and IPv6 where possible.
If your firewall policy only handles IPv4 but your service is reachable over IPv6, you have not blocked the service. You have only blocked one route to it.
11. A better Compose habit for every new service
Before adding a new container, ask one question:
Who needs to connect to this service?
| Who needs access? | Preferred Compose pattern | Example |
|---|---|---|
| Only another container | No ports. Same Docker network. |
App to database, app to Redis. |
| Only the Docker host | Bind to 127.0.0.1. |
Temporary admin UI through SSH tunnel. |
| LAN only | Prefer VPN/private access, or publish carefully and restrict with DOCKER-USER. |
Local dashboard, internal wiki. |
| Public web users | Publish reverse proxy only on 80/443. | Website, public status page. |
| Nobody anymore | Stop and remove it. | Old test container from six months ago. |
This one habit catches most problems before firewall rules are needed.
I like to add a small comment in Compose files:
# No ports here on purpose.
# This service is only reached by the reverse proxy over the proxy network.
That comment is not for Docker. It is for future me, tired and troubleshooting something at midnight.
12. Monthly Docker firewall audit
This belongs in the same routine as updates, backups and log checks.
Run:
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"
sudo ufw status verbose
sudo iptables -S DOCKER-USER
sudo iptables -t nat -S DOCKER
docker network ls
Then ask:
- Which containers publish ports?
- Are any databases published?
- Are any admin dashboards published?
- Are services bound to
0.0.0.0when they could be bound to127.0.0.1? - Does the router forward anything besides what I expect?
- Do IPv6 rules match the IPv4 rules?
- Can I rebuild the firewall policy after a reboot?
If the answer is “I have no idea what that port is”, stop and investigate.
Also back up the Compose files before changing things. The Backing Up Docker Containers post covers the practical side: Compose files, bind mounts, named volumes, databases and restore notes.
13. Troubleshooting notes
UFW says deny, but the container is reachable
That is the exact issue this post is about. Check whether Docker published the port:
docker ps --format "table {{.Names}}\t{{.Ports}}"
If you see 0.0.0.0:8080->80/tcp, Docker published it on all IPv4 interfaces. If you see [::]:8080->80/tcp, IPv6 is involved too.
I changed UFW rules and nothing changed
You may be changing host input rules while the traffic is being forwarded to Docker containers. Inspect DOCKER-USER and Docker's NAT rules.
sudo iptables -S DOCKER-USER
sudo iptables -t nat -S DOCKER
The service is reachable from the host but not from another machine
Check whether it is bound to 127.0.0.1. That may be correct. It means only the Docker host can reach that host port.
The service is reachable on the LAN but not the internet
Check the router. On a home network, the router normally controls what the internet can reach on IPv4. If there is no port forward, the public internet should not reach random LAN ports. IPv6 can be different, so do not skip IPv6 testing.
I added DOCKER-USER rules and broke something
List the rules:
sudo iptables -S DOCKER-USER
If you need to temporarily remove a test policy:
sudo iptables -F DOCKER-USER
sudo iptables -A DOCKER-USER -j RETURN
If you created the systemd service from this post, disable it before rebooting into the same broken policy:
sudo systemctl disable --now docker-user-firewall.service
Then fix the script and test again.
14. My recommended policy for a normal home server
For a normal Linux home server running Docker, I would use this policy:
- UFW enabled.
- SSH allowed only from LAN, VPN or a trusted admin network.
- No database ports published to the host.
- No admin dashboards published directly to the internet.
- Reverse proxy publishes 80 and 443 if public web services are needed.
- Backend containers sit on user-defined Docker networks with no host ports.
- Temporary admin tools bind to
127.0.0.1and are reached through SSH tunnel or private access. DOCKER-USERrules added only if the host needs a real Docker-aware firewall policy.- External testing after every major network change.
- Compose files and firewall scripts backed up.
That gives you a setup that is easy to understand later.
Not perfect. Not enterprise. But a lot better than a folder full of Compose files publishing random ports to the world while UFW politely says everything is fine.
Frequently asked questions
Does Docker bypass UFW?
For published container ports, yes, it can effectively bypass the UFW rules people expect to apply. Docker creates its own firewall and NAT rules for port publishing, and Docker's documentation specifically warns that Docker and UFW are incompatible in this area.
Is UFW useless on a Docker server?
No. UFW is still useful for normal host services such as SSH and for documenting your intended baseline. It just should not be treated as complete protection for Docker-published ports.
Is ports the same as expose in Docker Compose?
No. ports publishes a container port to the host. expose does not publish it to the host. For most backend services behind a reverse proxy, avoid ports and use Docker networks instead.
Should I bind Docker ports to 127.0.0.1?
Use 127.0.0.1 binding when only the Docker host should reach the service. It is useful for SSH tunnels, local admin tools and some same-host proxy patterns. For container-to-container traffic, a Docker network is usually cleaner.
Should I use the DOCKER-USER chain?
Use it when you need Docker-aware firewall filtering for published ports. Do not use it as a substitute for good Compose design. The best first fix is usually to remove unnecessary ports entries.
Should I set Docker's iptables option to false?
Usually no. Docker warns that preventing Docker from creating its firewall rules is not appropriate for most users and is likely to break container networking unless you replace the needed rules yourself.
What is the safest Docker port setup for a homelab?
Publish only what must be reached from outside Docker. For public web apps, publish a reverse proxy on 80 and 443. Keep backend apps, databases and admin tools on private Docker networks or bind them to localhost when needed.
Read next
- Docker Security for Homelab Beginners
- UFW Firewall Rules for Home Servers
- Linux Home Server Security Checklist
- SSH Hardening for Home Servers
- Backing Up Docker Containers
- Reverse Proxy for Home Servers: Caddy vs Nginx Proxy Manager vs Traefik
- Uptime Kuma for Home Servers
- Start Here: Linux Homelab and Security
Written by MS
MS is a Linux homelab and cybersecurity enthusiast who documents practical experiments with home servers, Docker, firewalls, backups, Lynis, Fail2ban, honeypots and old hardware. The guides on IT Random Stuff are based on hands-on testing, real configurations and lessons learned from running Linux systems at home.
Comments
Post a Comment