Beyond Docker + ufw: Using iptables DOCKER-USER Chain for IP Whitelisting
Background
After deploying PostgreSQL with Docker on a server, I noticed frequent login attempts from unknown IPs in the logs. I wanted to use ufw to restrict access, but discovered that Docker's -p parameter directly manipulates iptables, bypassing ufw rules.
Solution Comparison
| Solution | Pros | Cons |
|---|---|---|
| Bind 127.0.0.1 + SSH tunnel | Most secure, port not exposed to public | Need to open tunnel for each connection |
| pg_hba.conf whitelist | Database-level control | Need to modify config and restart when IP changes |
| Disable Docker iptables | ufw works normally | Need to manually manage container networking |
| DOCKER-USER chain | Doesn't affect existing deployment, flexible | Requires iptables knowledge |
| Tailscale/WireGuard | Zero-config VPN, stable IP | All nodes need installation |
This article uses the DOCKER-USER chain approach, suitable for existing deployments with fixed server IPs.
Steps (Debian/Ubuntu)
1. Confirm DOCKER-USER Chain Exists
iptables -L DOCKER-USER -n -v
Normal output shows a RETURN rule, indicating Docker has created this chain.
If you see iptables-legacy tables present warning, run iptables-legacy -L DOCKER-USER -n -v to check. If legacy doesn't have DOCKER-USER chain, Docker is using nftables backend - continue using iptables command.
2. Add Whitelist Rules
Assuming you need to allow these IPs to access PostgreSQL (port 5432):
- k3s nodes:
203.0.113.10,203.0.113.11 - Other servers:
198.51.100.20,198.51.100.21 - Home network:
192.0.2.100 - VPN exit:
192.0.2.200
Add ACCEPT rules one by one:
iptables -I DOCKER-USER -p tcp --dport 5432 -s 203.0.113.10 -j ACCEPT
iptables -I DOCKER-USER -p tcp --dport 5432 -s 203.0.113.11 -j ACCEPT
iptables -I DOCKER-USER -p tcp --dport 5432 -s 198.51.100.20 -j ACCEPT
iptables -I DOCKER-USER -p tcp --dport 5432 -s 198.51.100.21 -j ACCEPT
iptables -I DOCKER-USER -p tcp --dport 5432 -s 192.0.2.100 -j ACCEPT
iptables -I DOCKER-USER -p tcp --dport 5432 -s 192.0.2.200 -j ACCEPT
3. Add DROP Rule
iptables -A DOCKER-USER -p tcp --dport 5432 -j DROP
- ACCEPT rules use
-I(insert at beginning) - DROP rule uses
-A(append to end)
This ensures ACCEPT comes before DROP.
4. Verify Rule Order
iptables -L DOCKER-USER -n -v --line-numbers
Correct output should be:
num pkts bytes target prot opt in out source destination
1 0 0 ACCEPT tcp -- * * 192.0.2.200 0.0.0.0/0 tcp dpt:5432
2 0 0 ACCEPT tcp -- * * 192.0.2.100 0.0.0.0/0 tcp dpt:5432
3 0 0 ACCEPT tcp -- * * 198.51.100.21 0.0.0.0/0 tcp dpt:5432
4 0 0 ACCEPT tcp -- * * 198.51.100.20 0.0.0.0/0 tcp dpt:5432
5 0 0 ACCEPT tcp -- * * 203.0.113.11 0.0.0.0/0 tcp dpt:5432
6 0 0 ACCEPT tcp -- * * 203.0.113.10 0.0.0.0/0 tcp dpt:5432
7 0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432
8 xxx xxxxx RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
Key point: DROP must be before RETURN, otherwise it won't take effect.
If DROP is after RETURN, adjust:
# Delete incorrectly positioned DROP
iptables -D DOCKER-USER -p tcp --dport 5432 -j DROP
# Insert before RETURN (assuming RETURN is at position 7)
iptables -I DOCKER-USER 7 -p tcp --dport 5432 -j DROP
5. Persist Rules
apt install iptables-persistent -y
During installation, you'll be prompted to save current rules - select Yes.
Manual save:
netfilter-persistent save
Rules are saved in /etc/iptables/rules.v4.
6. Verify Persistence
grep -A 10 "DOCKER-USER" /etc/iptables/rules.v4
You should see your added rules.
Daily Maintenance
Add New IP
# Insert before DROP rule (check DROP's line number and subtract 1)
iptables -I DOCKER-USER 7 -p tcp --dport 5432 -s NEW_IP -j ACCEPT
netfilter-persistent save
Remove an IP
# First check line numbers
iptables -L DOCKER-USER -n --line-numbers
# Delete specific line
iptables -D DOCKER-USER LINE_NUMBER
netfilter-persistent save
View Blocked Connections
iptables -L DOCKER-USER -n -v
The pkts and bytes columns of the DROP rule show blocked packet counts.
What About Dynamic IPs?
If your home network IP changes frequently, use SSH tunnel:
# Run locally
ssh -L 5432:127.0.0.1:5432 user@SERVER_IP
# Then connect client to local 127.0.0.1:5432
Or configure in ~/.ssh/config:
Host mydb
HostName SERVER_IP
User YOUR_USERNAME
LocalForward 5432 127.0.0.1:5432
Then ssh mydb automatically establishes the tunnel.
Other Ports
The same method works for other Docker-exposed ports - just modify the --dport parameter:
# Example: restrict Redis port 6379
iptables -I DOCKER-USER -p tcp --dport 6379 -s ALLOWED_IP -j ACCEPT
iptables -A DOCKER-USER -p tcp --dport 6379 -j DROP