Restricting Access to Docker Containers using iptables

For the longest time I’ve been using UFW on my Ubuntu/Debian machines to configure firewall rules. It’s nice, and it generally just works. All good things for a firewall in my opinion.

However, recently I found out the hard way that UFW does not work as expected with Docker. Try it for yourself - run ufw default deny incoming on a host with Docker installed and spin up a container with a port mapping. Now try to access the container via the port and you’ll see that UFW is not blocking the connection as expected.

To understand why this is happening, we need to dig a bit deeper into how UFW works. UFW is a frontend to iptables written in Python. Then what’s iptables? Iptables is a utility for configuring rules of the Linux kernel firewall. In essence, both UFW and iptables allow the user to configure firewall rules, however iptables is much more complicated and has a steeper learning curve than UFW. This is the whole point of UFW - it’s an abstraction layer which makes iptables easier to configure by an end user.

Whilst UFW is a frontend to iptables, it does not exclusively manage iptables rules. This is why it doesn’t affect Docker containers - Docker inserts its own firewall rules into iptables before the UFW ones. This results in connections being accepted by the Docker rules as they are processed before the UFW rules, in this example the default deny rule.

I have no idea why the Docker rules have to run before the UFW rules, but having completed many hours of research, I have learned that there are a number of ways to fix this behaviour. The first option is to tell Docker not to touch iptables rules, however this will likely break a whole load of Docker networking functionality, so I did not consider this a viable solution. Another solution could be to run a seperate firewall in front of the Docker host, but I considered this too complicated as I’d rather keep everything on the host itself if possible.

Then, I discovered that Docker creates a place within iptables where user-defined rules can be configured which run before Docker’s automatic rules (which as mentioned previously are required). Uh oh, I thought, maybe now I’m finally going to have to learn how to use iptables! Unfortunately I was correct, but it didn’t turn out to be too complicated once I figured out what I was doing.

I already had sound knowledge of firewall concepts: I consider myself an expert user of pfSense and have made extensive use of its advanced firewall features in a number of deployments, but I’ve never dug properly into iptables, perhaps due to the simplicity of UFW and it historically being sufficient for my requirements. Until now. I decided to write this post to document how I got this working, as I found it hard to find information on the subject (at least that I could understand with my limited knowledge of iptables!). Also, this will serve as my own documentation when I inevitably have to do this again!

Let’s dig in. The place that Docker creates for the user-defined rules is called the DOCKER-USER chain, which is essentially a table which contains the rules. Another way in which iptables and UFW differ is that iptables rules do not persist by default after a reboot. This is super annoying. To get started, let’s install a package called iptables-persistent which allows rules to stick after a reboot.

sudo apt-get install iptables-persistent

When installing, do NOT copy existing IPv4 or IPv6 rules into the iptables-persistent configuration. Doing this will make our lives much harder as we’ll have to edit all the UFW-defined ones. Trust me, it’s much better to just start from scratch.

While we’re here, we may as well uninstall UFW, as we’re now manipulating iptables rules directly, and UFW is only going to add to the hot pile of mess if we’re not careful.

sudo apt-get remove --purge ufw

Once iptables-persistent is installed, we can edit a new file at /etc/iptables/rules.v4. As with most of the rest of the Internet, I’m not currently using IPv6. If, however, you’re reading this in 1000 years or you’ve already adopted IPv6, you can also create a file at /etc/iptables/rules.v6 for those rules, but I’ll let you figure that out for yourself. Anyway, let’s get on with the rules.v4 file.

My “baseline” file looks something like this, I adapted it from the excellent article here:

*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

# Allow SSH from our local subnet
-A INPUT -p tcp -m tcp --dport 22 -s 192.168.1.0/24 -j ACCEPT

# INPUT default DROP
-A INPUT -j DROP

##
# DOCKER-USER rules
##

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

# Define rules for Docker containers here

# Allow HTTP from anywhere
-A DOCKER-USER -i ens+ -p tcp -m tcp --dport 80 -j ACCEPT

# DOCKER-USER default DROP
-A DOCKER-USER -i ens+ -j DROP

COMMIT

Some things worth explicitly explaining:

  1. You need to specify your LAN-connected interface after every -i flag. You can find this out by running the ip addr command. In my example I’m using ens+ becuase according to the iptables man page this will match any interface with a name starting ens.
  2. Specify all rules that you want to apply to Docker containers in the section after the Define rules for Docker containers here line.
  3. The --dport should be set to the port of the service running inside the container. For example, if you map port 8080 on the host to 80 inside a container, i.e. -p 8080:80, the correct port to use here is 80, NOT 8080. This is because Docker creates NAT rules before these rules to handle the port translation, so because that has already happened by this point we have to use the port of the service inside the container. Apparently it is possible to write rules targeting the host-bound port, however in my use case specifying the internal container port is not causing me any problems.

You can adapt these rules to your needs, but I hope this serves as a good starting point. Once you’ve finished editing the file, reboot the host and you should find that the rules are applying as expected, with inbound traffic to containers now being properly filtered.

In conclusion, I now appreciate the simplicity of UFW much more than I did previously! UFW is a fantastic tool, however I’ve learned that sometimes it is necessary to directly manipulate iptables in order to perform more advanced configuration. That being said, I’m definitely not an iptables expert yet, but I’m glad that I’ve finally had the purpose and opportunity to start learning it somewhere. Also, I’m very happy that the original issue I was faced with is now solved! I hope that this guide might help you too.