Introduction
Docker uses netfilter (the Linux firewall) rules to create its internal container network and port bindings to the host system.
These rules have precedence over regular rules and may make it harder to filter traffic to containers not to mention the risk of unexpected behavior on container traffic.
Podman works differently and poses different possible firewall issues. We'll probably release a similar article about Podman later.
Example issue
Let's say you have a container running with a global port binding, for instance port TCP 22 for SSH.
You try blocking that port using a rule in the INPUT chain, e.g.:
iptables -A INPUT -p tcp -s !172.20.0.0/16 --dport 22 -j REJECT
It doesn't matter whether that rule is inserted before all the Docker rules or not, it won't block traffic and the port will still be fully accessible, which may be a security issue.
There's a warning on the Docker documentation page about installing Docker on Debian underlining this very issue:
They mention ufw and firewalld but the issue will happen with basic iptables as well unless you use a very specific iptables chain as described later in the article.
The Docker iptables chains
Docker creates several of its own iptables chains.
You can see it using:
iptables -nL
Example listing:
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0
DOCKER-ISOLATION-STAGE-1 all -- 0.0.0.0/0 0.0.0.0/0
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
DOCKER all -- 0.0.0.0/0 0.0.0.0/0
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Chain DOCKER (1 references)
target prot opt source destination
ACCEPT tcp -- 0.0.0.0/0 172.17.0.2 tcp dpt:5667
Chain DOCKER-ISOLATION-STAGE-1 (1 references)
target prot opt source destination
DOCKER-ISOLATION-STAGE-2 all -- 0.0.0.0/0 0.0.0.0/0
RETURN all -- 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-ISOLATION-STAGE-2 (1 references)
target prot opt source destination
DROP all -- 0.0.0.0/0 0.0.0.0/0
RETURN all -- 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-USER (1 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0
In regular cases, the port bindings should appear in the DOCKER chain, as seen with the port 5667 in the example listing above.
Prerequisite: persistent firewall rules
Adding rules with iptables doesn't make them persistent. Rules are compiled as an in-memory set ready to use for the kernel.
There are multiple ways to persist firewall rules. As of today and for the Debian distribution, we believe the easiest way is to use a special package called iptables-persistent.
First you need to install "iptables" as the utility isn't present by default because Debian 11 uses nftables (with the utility nft) which we may dedicate a future blog article to.
After Docker is installed, you can install these packages:
apt install iptables iptables-persistent
The config wizard will ask you if you want to save the current firewall rules now and you should always say yes, unless, for instance, Docker isn't installed yet.
If you need to manually persist the rules, you can always use the iptables-save utility:
iptables-save > /etc/iptables/rules.v4
Obviously only for the ipv4 rules. Make sure to have full coverage of rulesets if you use ipv6 for public access as well.
Do note that you still have to manually save the iptables rules with the command shown above when you add, delete or edit rules or they wont be restored at the next system start.
The DOCKER-USER chain
The DOCKER-USER chain is meant to hold your own firewall rules that are processed before all of the other rules and especially before the other Docker-related chains.
However, using it to filter by source IP address or range, which is almost always what we'll want to do, isn't that easy.
It requires using kernel connection tracking which isn't the fastest thing in the world but will work for your basic needs.
Allow everyone but some source IP addresses/ranges
First we need to allow already established traffic or nothing will work, the following rule has to be added first, and only once (don't forget to persist your rules using iptables-save when relevant):
iptables -I DOCKER-USER -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
Now if you want to allow everyone to connect to a specific port except for, let's say, IP range 192.168.0.0/24, you could just add the following rule:
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigsrc 192.168.0.0/24 --ctorigdstport 80 -j DROP
And it'll work, except what we usually want to do is block everyone by default and only allow certain IP addresses or ranges.
Also, while we're at it, do note that we need to specify the protocol (tcp or udp) and you'll have to duplicate the rule when you need to support both.
Block everyone but some source IP addresses/ranges
A more classical use case is only allowing specific IP addresses or ranges to reach the exposed container port.
Since rules are processed from top to bottom with the first matching rule getting applied, we need some kind of DROP or REJECT rules for the port at the very end of the ruleset, order is very important.
The DOCKER-USER chain should already come with a RETURN rule that can stay there provided it's always the very last rule:
~# iptables -L DOCKER-USER
Chain DOCKER-USER (1 references)
target prot opt source destination
RETURN all -- anywhere anywhere
To create the initial setup you can use the "-I" option of iptables (to insert) in that order so that the first rule will appear last (well, right before the initial RETURN rule):
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 80 -j DROP
iptables -I DOCKER-USER -p tcp -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I DOCKER-USER -s 172.17.0.0/16 -j ACCEPT
Where the container port in this example is TCP 80, make sure to change it to your needs.
We're always allowing the whole 172.17.0.0/16 range because without that, the containers won't be able to use the blocked port (port 80 in this example) at all for any destination IP address in the outside and that's pretty much never desired.
172.17.0.0/16 is the Docker network range on that host, it can sometimes be set to something else. Check that it's correct by looking at the assigned IP address and network on interface docker0, for instance by issuing the command:
ip -c a
Now when we want to allow some IP address or range to reach that port, we can just insert a rule above like so:
iptables -I DOCKER-USER -s <IP_ADDRESS_OR_RANGE> -p tcp -m conntrack --ctorigdstport 80 -j ACCEPT
Ruleset for DOCKER-USER should now look like so:
~# iptables -L DOCKER-USER
Chain DOCKER-USER (1 references)
target prot opt source destination
ACCEPT tcp -- 192.168.77.177 anywhere ctorigdstport 80
ACCEPT all -- 172.17.0.0/16 anywhere
ACCEPT tcp -- anywhere anywhere ctstate RELATED,ESTABLISHED
DROP tcp -- anywhere anywhere ctorigdstport 80
RETURN all -- anywhere anywhere
Where I allowed 192.168.77.177 to reach the Docker port 80 in this example.
Add more of these rules to allow more traffic to the ports you want.
Note: the Docker host should still be able to reach the port as its traffic doesn't seem to go through the DOCKER-USER chain. Please correct me in the comments if that's wrong.
You can find some more info about iptables filtering on the official documentation.
Conclusion & alternatives
There are alternatives to the solution presented here.
However, you probably now understand that Docker networking isn't that simple.
It's possible to use another dedicated bridge or even the "host" network for Docker container (the later is sometimes used for performance reasons) as well as more intricated firewall rules or userland forwarding / proxy programs, but that'll be a subject for another time.
In the meantime when you just want to bind a Docker container to a local port on the Docker host, you can specify 127.0.0.1 in the port bindings to make it only bind to the loopback interface.
E.g. for a simple Nginx container:
docker run -d --name nginx-server -p 127.0.0.1:80:80 nginx