I'm trying to secure a VPS running Docker containers so that their exposed ports are only accessible through a VPN interface (in my case it's Tailscale). In order to do that, I read about firewalld and came up with the following rules:
sudo firewall-cmd --reload sudo firewall-cmd --zone=trusted --add-interface=tailscale0 sudo firewall-cmd --zone=trusted --add-port=8080/tcp Before adding Docker into the mix, I tested my rules with a simple Python server:
python -m http.server 8080 Note: From now on, I'll refer to my Tailscale IP as 100.100.100.100 and my public IP as 1.2.3.4.
As expected, the connection succeeds from Tailscale:
➜ telnet 100.100.100.100 8080 Trying 100.100.100.100... Connected to 100.100.100.100. Escape character is '^]'. And fails from the public IP:
➜ telnet 1.2.3.4 8080 Trying 1.2.3.4... telnet: connect to address 1.2.3.4: Connection refused telnet: Unable to connect to remote host Then I tried with a Docker container:
docker run -p 8080:80 --name test-http-server --restart always nginx:alpine At first the server was accessible from anywhere, so I read a lot on the subject (see sources below for a subset of my readings) and managed to build the latest version of firewalld to date to get support for the (relatively new) StrictForwardPorts=yes option, which did indeed prevent the port from being exposed automatically.
But that also made my rules unable to forward the port unless I add a rule:
CONTAINER_IP=$(docker inspect -f '{{.NetworkSettings.Networks.bridge.IPAddress}}' test-http-server) sudo firewall-cmd --zone=trusted --add-forward-port=port=8080:proto=tcp:toport=80:toaddr=${CONTAINER_IP} Unfortunately now my server is accessible from all interfaces, even though the rules are in the trusted zone, where only the Tailscale interface is:
➜ telnet 1.2.3.4 8080 Trying 1.2.3.4... Connected to 1.2.3.4. Escape character is '^]'. ➜ telnet 100.100.100.100 8080 Trying 100.100.100.100... Connected to 100.100.100.100. Escape character is '^]'. After much reading, failed attempts, and discussing with ChatGPT, I'm running out of ideas. I seem to have narrowed it down to the port forward rule that somehow seems to get around the firewalld zones, but I couldn't find another way to achieve my goal.
What am I missing/doing wrong ?
Notes
Note #1: I know that I could (and should) bind my container to my Tailscale interface, and that it would achieve my goal:
docker run -d -p 100.100.100.100:8080:80 --name test-http-server --restart always nginx:alpine That's indeed what I intend to do when everything else is ready, but that's not enough for me, as I want my firewall rules to be foolproof against whatever mistake I could make with my containers, and against weak binding values my services (which might spawn containers on their own) might have hard-coded. The firewall should give me the "last word" over my packets.
Note #2: I guess I could achieve my goal using iptables, but I spent so much time already on this and, honestly, iptables is just too complicated for my needs. firewalld, on the other hand, seems to be a standard for easy-to-use firewalls.
Note #3: I tried changing the FirewallBackend setting from nftables (default) to iptables since Docker's firewall limitations mention that they are only compatible with iptables-nft and iptables-legacy and firewalld's homepage mentions that too at the bottom:
docker (iptables backend only)
I'm not sure what this means, but since there is iptables in both of these names and not in nftables, I figured this was worth a shot. To my greatest surprise, using this backend just brought me back to exactly the same spot I was with StrictForwardPorts=no: the server is accessible from both interfaces without the need to add the port forward rule.