Docker iptables filtering fixed in Docker 17.06

A huge pain point with Docker since day one has always been the ability to do iptables filtering of incoming traffic. Exposed ports were not filtered, rules could not persist a Docker daemon restart and no easy way to do filtering based on IP etc. The actual problem is better described at the first Google search result for docker iptables.

Running with --iptables=false never worked quite well, at least not for me with multiple Docker networks.

This problem is no more since the release of Docker 17.06.

I don't think the release notes mention the fix but this issue does and here is the actual documentation update.

This new release introduces a new iptables chain that Docker adds to the FORWARD chain. This is added as the first rule in the FORWARD chain and it's named DOCKER-USER.

Chain FORWARD (policy DROP 0 packets, 0 bytes)  
num   pkts bytes target     prot opt in     out  source   destination  
1     167K   68M DOCKER-USER  all  --  any    any   anywhere anywhere  
2     155K   61M DOCKER-ISOLATION  all  --  any  any anywhere anywhere  

The new DOCKER-USER chain is NOT cleared when the Docker daemon restarts. This allows you as a user to add your own iptables rules which survives a restart of the Docker engine. Before Docker 17.06 you had to inject your own chain at the beginning of FORWARD after each Docker restart to get the same ability to do filtering. For example if the Docker package were upgraded and the daemon restarted Docker would add it's own rules before yours and the filtering rules were no longer working. Dangerous and error prone.

I'm using the iptables-persistent package to auto load my rules on boot. I'm in no way an iptables expert but here is an example setup that seems to work good for me. The ipv4 rule file is located at /etc/iptables/rules.v4.

*filter
:INPUT ACCEPT [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER-USER - [0:0]

##
# INPUT
##

# Allow localhost
-A INPUT -i lo -j ACCEPT

# Allow established connections
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# Allow ICMP ping
-A INPUT -p icmp -m icmp --icmp-type 8 -j ACCEPT

# SSH
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT

# INPUT default DROP
-A INPUT -j DROP

##
# DOCKER-USER rules
##

# Allow established connections
-A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# SMTP
-A DOCKER-USER -i eth0 -p tcp -m tcp --dport 25 -j ACCEPT

# http
-A DOCKER-USER -i eth0 -p tcp -m tcp --dport 80 -j ACCEPT
# https
-A DOCKER-USER -i eth0 -p tcp -m tcp --dport 443 -j ACCEPT

# DOCKER-USER default DROP
-A DOCKER-USER -i eth0 -j DROP

COMMIT  

This setup uses the new DOCKER-USER chain to do filtering. It allows SSH and ICMP ping to the base system and defaults to drop the rest of the incoming traffic. The Docker traffic rules allows SMTP, HTTP and HTTPs but denies all other traffic. This way no external traffic can reach Docker containers with exposed ports without you adding explicit iptables rules to allow it.

This also allows you to do host based filtering. For example only allow HTTPs from source IP 1.2.3.4.

-A DOCKER-USER -i eth0 -p tcp -s 1.2.3.4 -m tcp --dport 443 -j ACCEPT

One thing to note here is that I'm using -i eth0 for all DOCKER-USER rules. This tells iptables only to apply the rules on my external network interface eth0. If this weren't included iptables denied traffic that wasn't supposed to be denied. Since I'm only interested in filtering external traffic I guess this is fine.

In order to completely clear and reload my rules I used a small shell script. This is just included as an example if you need to do the same. Note that this flushes iptables completely so make sure you got a way to reach your machine if anything goes wrong.

#!/usr/bin/env bash

echo "* Flushing iptables" &&  
sudo iptables -F -t nat &&  
sudo iptables -F &&  
echo "* Reloading iptables" &&  
sudo /etc/init.d/iptables-persistent start &&  
echo "* Restarting docker" &&  
sudo service docker restart  

When the rules are added to iptables verify that everything looks fine with iptables --list --line-number -v. The chain DOCKER-USER should now contain your own rules and it should be the first chain called in the FORWARD chain.

It should now be safe to restart Docker and your iptables rules should persist and still work when the Docker daemon reloads and recreates the iptables rules.