I hit a classic “it must be Docker” moment.
- The container was healthy, the port was clearly listening and
nc 127.0.0.1 22222said open. - But from outside the server, connections to
PUBLIC_IP:22222timed out. - And the weirdest part, if I stopped ferm, it worked instantly.
This post documents what was actually happening and the minimal, correct fix.
The setup
- Host is Debian 12
- Firewall, ferm (tight rules, default DROP)
- Provider, Linode with NAT
- Host SSH port,
22222 - Docker SFTP container,
atmoz/sftppublished as22222:22
Symptom, “Local open, remote timeout”
On the server:
sudo ss -lntp | egrep ':(22|22222)\b' || true
sudo docker ps --format 'table {{.Names}}\t{{.Ports}}'
nc -vz 127.0.0.1 22222
Code language: JavaScript (javascript)
Everything looked correct: Docker was listening on 0.0.0.0:22222, and localhost could connect.
But from outside, connections to port 22222 timed out.
First principle, don’t guess, but watch packets
On the server, I ran
sudo tcpdump -ni any tcp port 22222
Then I attempted to connect from outside.
I saw repeated SYN packets arriving (something like):
IP <client_ip>.<port> > <server_private_ip>.22222: Flags [S] ...
Code language: HTML, XML (xml)
That single observation is huge
- The NAT / provider network was not blocking the port.
- Packets reached the server.
- The server simply did not respond (no SYN-ACK), this is almost always a firewall/ruleset problem.
Why allowing INPUT is not enough for Docker-published ports
This is the key concept.
When you publish a Docker port like this:
- Host is
22222 - and Container is
22
The incoming packet flow is roughly
- Client connects to
PUBLIC_IP:22222 - Host receives the packet and Docker applies DNAT to the container:
- destination becomes
172.18.x.y:22
- destination becomes
- That packet must now traverse the FORWARD path to the Docker bridge interface (
br-...)
So even if you allow 22222 in INPUT, the connection can still fail if your firewall has:
FORWARD policy DROP(very common in hardened ferm setups)- and no exception for forwarding traffic to Docker networks
That was my case.
The fix, allow FORWARD to the Docker bridge (port 22 after DNAT)
After DNAT, the traffic is targeting container port 22 (not 22222).
So the correct fix is a FORWARD rule that allows forwarding to the Docker bridge interface for TCP dport 22.
Below is a complete example ferm.conf you can use as a reference.
Full ferm.conf example (with 22060 + 22061)
Replace:
YOUR_ADMIN_IPv4with your real public IP (or your office/VPN IP)YOUR_WG_IPv4if you have another trusted sourcebr-xxxxxxxxxxxxwith your Docker bridge name (find it viaip -br aordocker network ls+ip link)
domain (ip ip6) {
table filter {
chain INPUT {
policy DROP;
# Allow established connections and localhost
mod state state (ESTABLISHED RELATED) ACCEPT;
interface lo ACCEPT;
# Allow ping
proto icmp ACCEPT;
@if @eq($DOMAIN, ip6) {
proto ipv6-icmp ACCEPT;
}
# SSH and Docker SFTP from a trusted public IP (example)
@if @eq($DOMAIN, ip) {
# Admin/VPN/Office IP
proto tcp saddr YOUR_ADMIN_IPv4 dport 22221 ACCEPT; # SSH on 22221
proto tcp saddr YOUR_ADMIN_IPv4 dport 22222 ACCEPT; # Docker-published SFTP on 22061
# Optional second trusted source (example)
proto tcp saddr YOUR_WG_IPv4 dport 22222 ACCEPT;
}
# Optional IPv6 allow examples (fill as needed)
@if @eq($DOMAIN, ip6) {
# proto tcp saddr YOUR_ADMIN_IPv6 dport 22221 ACCEPT;
}
LOG log-prefix "ferm INPUT drop: " log-level warning;
DROP;
}
chain OUTPUT {
policy ACCEPT;
mod state state (ESTABLISHED RELATED) ACCEPT;
}
chain FORWARD {
policy DROP;
# Allow return traffic
mod state state (ESTABLISHED RELATED) ACCEPT;
# IMPORTANT:
# Docker port publishing (22222 -> container:22) needs FORWARD allowance to the Docker bridge.
# After DNAT, destination port is 22, and the packet is forwarded to br-xxxxxxxxxxxx.
@if @eq($DOMAIN, ip) {
# Allow ONLY from the same trusted admin IP
outerface br-xxxxxxxxxxxx proto tcp saddr YOUR_ADMIN_IPv4 dport 22 ACCEPT;
}
LOG log-prefix "ferm FORWARD drop: " log-level warning;
DROP;
}
}
}
Apply safely
- Dry-run syntax check
sudo ferm -n /etc/ferm/ferm.conf
- Restart ferm
sudo systemctl restart ferm
How to find the Docker bridge name
Run:
ip -br a
Look for an interface like:
br-93736b42dd62 UP 172.18.0.1/16
That br-93736b42dd62 is what you put into the FORWARD rule as outerface.
Verify the fix
From outside:
sftp -P 22222 user@PUBLIC_IP- or
ssh -p 22222 user@PUBLIC_IP(you should see SSH negotiation if reachable)
On the server, you can confirm handshake progress:
sudo tcpdump -ni any tcp port 22061
You should start seeing SYN followed by SYN-ACK and further packets.
Notes on “Do I need to whitelist the NAT?”
In this case: no.
tcpdump showed the real client IP as the source. The NAT was forwarding traffic to the server, and the server was dropping it on the forwarding path. The correct whitelist target is your client IP, and the correct fix is allowing FORWARD to Docker bridge.