Docker on a VPS: UFW, Port Bindings, and SSH Tunnels
Putting a Docker Compose stack on a public VPS without thinking about network exposure is one of the most reliable ways to get your infrastructure compromised. Docker bypasses UFW by default. A 0.0.0.0 port binding on Postgres means your database is reachable from anywhere on the internet, regardless of what your firewall rules say.
This post covers the exact approach used across this series: which ports go on 0.0.0.0, which stay on 127.0.0.1, how UFW and Docker interact at the iptables level, and how to reach locked-down services from your local machine using SSH tunnels. No third-party tools, no complex firewall rules. Just two knobs that actually work.
Assumed knowledge: Linux basics, Docker Compose, what UFW is.
Difficulty: Intermediate
Assumes working knowledge of Docker, Linux, and basic networking.
Prerequisites¶
Set up Traefik first
This post assumes Traefik v3 is already running as your reverse proxy. If you have not done that yet, start with Traefik v3: Routing Multiple Docker Apps on One Server.
- Ubuntu 22.04+ VPS (works on any Debian-based distro where Docker writes its own iptables rules)
- Docker Engine 24+ and Docker Compose v2
- SSH access with a user that has
sudoprivileges
Work through this before you expose any ports on a public server. Starting with a locked-down baseline is far easier than closing holes you did not know were open.
Versions used in this post
Ubuntu 22.04, Docker Engine 26, UFW 0.36
This is not theoretical
Shodan and similar scanners index exposed databases within minutes of a port opening. Misconfigured Docker bindings on Postgres, Redis, and MinIO are actively probed. The fix takes ten minutes. An incident takes days.
Background¶
UFW manages iptables rules through a friendly interface. Docker also writes iptables rules, and Docker's rules take priority over UFW's. When you define ports: ["5432:5432"] in a Compose file, Docker inserts an ACCEPT rule into the DOCKER chain that allows traffic on port 5432 from any source. UFW's DENY rule for port 5432 sits in the INPUT chain, which Docker's rule bypasses entirely.
This is not a bug in either tool. It is a consequence of how netfilter rule ordering works: the DOCKER chain is evaluated before INPUT. The only reliable fix at the Docker layer is to not bind to 0.0.0.0 in the first place.
0.0.0.0:5432 -> reachable from anywhere, regardless of UFW
127.0.0.1:5432 -> reachable from the host machine loopback only
ufw status showing active does not mean Docker ports are blocked
This is the most dangerous misconception in this space. You enable UFW, add a DENY rule for port 5432, run ufw status, and it shows the rule. Your database is still publicly reachable if Docker bound it to 0.0.0.0. The only trustworthy check is ss -tlnp | grep 5432. If the output shows 0.0.0.0:5432, the port is open to the internet and the UFW rule is irrelevant.
Implementation¶
1. Classify every port before writing any config¶
Before writing Compose files or firewall rules, decide what each port needs to be. Revisiting these decisions after deployment is how misconfigurations happen.
| Service | Port | Public | Rationale |
|---|---|---|---|
| Traefik web | 80 | Yes | App traffic |
| Traefik app entrypoints | 8001-8005 | Yes | Per-app routing |
| Traefik dashboard | 8080 | No | SSH tunnel only |
| PostgreSQL | 5432 | No | Containers reach it over infra_net |
| Redis | 6379 | No | Containers reach it over infra_net |
| MinIO S3 API | 9000 | No | Containers reach it over infra_net |
| MinIO Console | 9001 | No | SSH tunnel only |
| Mailpit SMTP | 1025 | No | Containers use mailpit:1025 directly |
The internal services do not need host port bindings at all for container-to-container communication. They talk over infra_net using Docker DNS. Host bindings only exist for local tooling access from your development machine.
2. Port binding strategy in docker-compose.yml¶
Public bindings for services that must be reachable from the internet:
# Traefik public entrypoints
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:8001:8001"
- "0.0.0.0:8002:8002"
- "0.0.0.0:8003:8003"
- "0.0.0.0:8004:8004"
- "0.0.0.0:8005:8005"
Loopback bindings for everything that should only be reachable from your machine via SSH tunnel:
# Traefik dashboard
- "127.0.0.1:8080:8080"
# PostgreSQL
- "127.0.0.1:5432:5432"
# Redis
- "127.0.0.1:6379:6379"
# MinIO S3 API and Console
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
# Mailpit SMTP
- "127.0.0.1:1025:1025"
Remove host bindings entirely if you do not need local tooling access
If your app containers are the only consumers of Postgres, Redis, and MinIO, and they reach those services over infra_net, remove the host port bindings completely. No binding means no exposure, not even to localhost. You can still reach services through the Docker network if you have exec access to a running container.
3. UFW configuration¶
Add the SSH rule before enabling UFW. If you skip this step, you will lock yourself out of the server.
Add rules for public app ports:
ufw allow 80/tcp
ufw allow 8001/tcp
ufw allow 8002/tcp
ufw allow 8003/tcp
ufw allow 8004/tcp
ufw allow 8005/tcp
Enable UFW with default deny incoming:
Status: active
Default: deny (incoming), allow (outgoing)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
8001/tcp ALLOW IN Anywhere
8002/tcp ALLOW IN Anywhere
8003/tcp ALLOW IN Anywhere
8004/tcp ALLOW IN Anywhere
8005/tcp ALLOW IN Anywhere
UFW rules here cover ports that are not Docker-managed, primarily SSH. For Docker-managed ports, the binding address is the protection.
4. Reaching locked-down services via SSH tunnel¶
SSH port forwarding lets you access 127.0.0.1-bound services on the server as if they were running locally. The syntax is straightforward:
Common tunnels for this stack:
# Traefik dashboard -> http://localhost:8080/dashboard/
ssh -L 8080:127.0.0.1:8080 user@your-server
# PostgreSQL (for pgAdmin, DBeaver, or psql)
ssh -L 5432:127.0.0.1:5432 user@your-server
# Redis
ssh -L 6379:127.0.0.1:6379 user@your-server
# MinIO Console -> http://localhost:9001
ssh -L 9001:127.0.0.1:9001 user@your-server
Once the tunnel is open, connect as if the service were local:
psql -h localhost -p 5432 -U admin -d postgres
redis-cli -h localhost -p 6379 -a yourpassword ping
For the MinIO Console, open http://localhost:9001 in your browser.
5. Multi-tunnel alias¶
Opening four tunnels in separate terminals gets old quickly. Add a single alias to your local shell config:
alias infra-tunnel='ssh \
-L 8080:127.0.0.1:8080 \
-L 5432:127.0.0.1:5432 \
-L 6379:127.0.0.1:6379 \
-L 9001:127.0.0.1:9001 \
-N user@your-server'
The -N flag keeps the SSH session open without executing a remote shell command. Run infra-tunnel once and all four services are accessible locally until you close the terminal.
Testing¶
Verify a loopback-bound port is not reachable from the internet. Run this from a different machine, not the server itself:
Verify the same port is reachable through the SSH tunnel:
Check the actual binding addresses on the server:
LISTEN 0 128 127.0.0.1:5432 ... (loopback only, correct)
LISTEN 0 128 0.0.0.0:80 ... (public, correct)
Pitfalls¶
Trusting ufw status for Docker-managed ports
ufw status is the most reliable way to mislead yourself into thinking a port is protected when it is not. The only trustworthy verification is ss -tlnp | grep PORT. If the address column shows 0.0.0.0, the port is public. If it shows 127.0.0.1, it is loopback only. For Docker-managed ports, the UFW rule status does not matter.
Setting iptables: false in the Docker daemon config
Some guides recommend disabling Docker's iptables management globally via /etc/docker/daemon.json. This breaks container networking entirely: containers can no longer reach each other or the internet. Do not do this. The 127.0.0.1 binding strategy achieves the same security outcome without breaking anything.
Auditing bindings before the stack goes live
Run docker ps --format "table {{.Names}}\t{{.Ports}}" for a single-line view of all port bindings across every running container. Anything showing 0.0.0.0 that should not be public is a misconfiguration to fix before you go live.
Production considerations¶
Enable key-only SSH authentication. Set PasswordAuthentication no in /etc/ssh/sshd_config and reload the SSH daemon with systemctl reload ssh. Password auth on a public SSH port is a constant brute-force target. Key auth eliminates that attack surface entirely.
Install fail2ban to block IPs that repeatedly fail SSH authentication. The default configuration bans IPs after five failed attempts within ten minutes, which alone eliminates most automated scanning. Install it with apt install fail2ban and the defaults are effective without modification.
For teams where multiple engineers need access to internal services, Tailscale or WireGuard is a cleaner alternative to per-person SSH tunnels. Both create a private network overlay where all services are directly addressable by hostname, no tunnel commands or port forwarding required. The tradeoff is WireGuard key management overhead that SSH tunnels do not have.
Wrapping up¶
Docker's iptables integration makes UFW rules unreliable as the primary protection for port-level security. The correct approach is to bind internal services to 127.0.0.1, expose only what must be public on 0.0.0.0, and use SSH tunnels to reach everything else from your local machine. Verify with ss -tlnp, not ufw status.
Every port binding decision in this series follows this pattern:
- Traefik v3: Routing Multiple Docker Apps on One Server: public entrypoints on
0.0.0.0, dashboard on127.0.0.1 - MinIO as a Self-Hosted S3 Backend: S3 API and Console on
127.0.0.1 - Full source: github.com/joseph-fidelis