Hairpin NAT with nftables & a dynamic IP address

nftables, systemd, networking

Hairpin NAT is a bit of a hack to allow your local users to reach externally exposed resources even from within the same network as the services are running in. This gets a bit tricky when using a residental, dynamic IP address, because you typically need to match on your external address and if it gets changed everything might break until you’ve been able to update the address in your firewall ruleset.

Below I’ll be describing how you can workaround this issue with nftables sets, awk and a small shell script.

Overview #

Lets say we have a firewall that protects several networks, in the image below called VLAN5 and VLAN20. We have our users in VLAN5, and we have a web server VLAN20 that is exposed to the internet through the firewall (port 80/443). No split horizion DNS is allowed, so will resolve to our external IP.

Example network layout

There are multiple explanations on how hairpin NAT works which explains this in detail, reading and understanding those is left as an exercise for the reader. This post will only covers one way you can solve this when having a dynamic IP address.

Solution #

The overall idea is to create a named IPv4 address set that will store our external IP. We’ll then use this named set to create the rule that will perform the hairpin NAT necessary to make this work, and then finally create a script (+ systemd service) that will add our external IP to said set.

nftables #

An incomplete example nftables.conf:

define host_webserver =
define net_clients =
define if_wan = eth0

table inet firewall {
  # ...
  set wan_ip {
    type ipv4_addr

  # other necessary chains

  chain prerouting {
    type nat hook prerouting priority 0;

    # Here we match on any address in the wan_ip named set
    ip daddr @wan_ip dnat ip to tcp dport map {
      80 : $host_webserver,
      443 : $host_webserver

  chain postrouting {
    type nat hook postrouting priority 100; policy accept

    # normal NAT
    ip saddr oifname $if_wan masquerade

    # hairpin
    ip saddr $net_clients ip daddr $host_webserver tcp dport { http, https } masquerade

The above should be enough to setup hairpin NAT. Adjust to your configuration as necessary. For a primer in nftables, I have an older blog entry going through the basics.

After reloading the ruleset the named set will be empty, so the hairpin won’t work until the script has been run once. I’ve just lived with this fact, but you can persist the external IP address to a file which you include in your nftables.conf (define wan_ip = and have the script rewrite this file), but then you introduce more complexity and risk of breakage.1

bash script #

The script will parse our current external IP and add it to the named set wan_ip in nftables. This is a cheap operation so we’ll just run it every few minutes.

#!/usr/bin/env bash

current_ip=$(ip -4 -o addr show eth0 | awk -F'[ /]+' '/inet / {print $4}')
nft add element inet firewall wan_ip { ${current_ip} } && exit 0

echo "Failed to update wan ip?"
exit 1

Save it where you like but in this example I assume you save it to /usr/local/bin/ – don’t forget to make it executable (chmod 0700 /usr/local/bin/

systemd service & timer #

This service & timer can be replaced by a cronjob, but I prefer systemd services/timers due to reasons.

# /etc/systemd/system/update-ip.service
Description=Update and store WAN IP

# /etc/systemd/system/update-ip.timer
Description=Run update IP timer


Reload systemd and enable the timer:

systemctl daemon-reload
systemctl enable --now update-ip.timer

Alternative: cronjob #

Below is untested but should work. :-)

Assuming you are using Ubuntu 22.04 (which is the latest LTS relase at the time of writing), run below to create a cronjob to run the script:

echo "*/3 * * * * root /usr/local/bin/" > /etc/cron.d/updateIP

As cronjob implementations seems differs slightly you might have to consult the manpage for your cron implementation for the exact syntax.

man crontab

Conclusion #

I’ve used this setup for a year or so at home. I initially tried to use split horizion DNS, but this didn’t work with my company issued laptop hence me doing this setup instead. No real issues this far.

  1. If you go this route, the file where you persist your external IP always have to pass nftables validation, or your whole ruleset won’t load. This might happen if you don’t have a valid IP on your external interface and the script runs, and persist something broken to said file. Don’t ask me how I know this. :-) ↩︎