Skip to content

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 sudo privileges

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:

docker-compose.yml
# 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:

docker-compose.yml
# 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.

terminal
ufw allow 22/tcp

Add rules for public app ports:

terminal
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:

terminal
ufw enable
ufw status verbose
expected output
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:

terminal
ssh -L LOCAL_PORT:127.0.0.1:REMOTE_PORT user@your-server

Common tunnels for this stack:

terminal
# 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:

terminal
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:

~/.bashrc or ~/.zshrc
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:

from a different machine
nc -zv your-server-ip 5432
expected output
nc: connect to your-server-ip port 5432 (tcp) failed: Connection refused

Verify the same port is reachable through the SSH tunnel:

local machine (with tunnel open)
psql -h localhost -p 5432 -U admin -d postgres
expected output
psql (16.x)
Type "help" for help.

postgres=#

Check the actual binding addresses on the server:

terminal (on the server)
ss -tlnp | grep -E '5432|:80'
expected output
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:


Comments