GRE (Generic Routing Encapsulation) wraps IP packets inside other IP packets, giving you a virtual point-to-point link between two hosts over any IP network. It is simple, fast and supported by every Linux kernel, but it does not encrypt anything. In this tutorial you will build a GRE tunnel between two Ubuntu 24.04 servers, route a private subnet through it, tune the MTU, make it survive reboots with Netplan and, optionally, encrypt it with IPsec using strongSwan.

Prerequisites

To follow this guide you need:

  • Two servers running Ubuntu 24.04 LTS, for example two CubePath VPS or bare metal servers in different locations.
  • A non-root user with sudo privileges on both.
  • Public IPv4 addresses on both servers that can reach each other.
  • Optional: a private subnet behind each server if you want to route site-to-site traffic.

The examples use these addresses. Replace them with your own values everywhere:

Server AServer B
Public IP203.0.113.10198.51.100.20
Tunnel IP10.0.0.1/3010.0.0.2/30
Private subnet behind the server192.168.1.0/24192.168.2.0/24

Find the name of your public interface (often eth0 or ens18) with:

ip -br addr

The rest of the guide calls it eth0.

Step 1 - Allowing GRE through the firewall

GRE is not TCP or UDP: it is its own IP protocol, number 47. If UFW is enabled, it drops GRE packets unless you allow them explicitly. The most precise way is to add a rule to /etc/ufw/before.rules that accepts GRE only from the other server.

On Server A, open the file:

sudo nano /etc/ufw/before.rules

Find the line # ok icmp codes for INPUT and add this block just above it:

# allow GRE from Server B
-A ufw-before-input -p gre -s 198.51.100.20 -j ACCEPT

On Server B, add the same rule with Server A's address (-s 203.0.113.10). Then reload UFW on both servers:

sudo ufw reload

If UFW is inactive (sudo ufw status shows Status: inactive), you can skip this step.

Step 2 - Creating the tunnel by hand

Build the tunnel with ip first so you can test it before making anything permanent. The kernel module ip_gre loads automatically when you create a GRE interface.

Do not name the interface gre0: the kernel creates a fallback device with that name as soon as the module loads, and adding your own gre0 fails with File exists. This guide uses gre1.

On Server A:

sudo ip link add gre1 type gre local 203.0.113.10 remote 198.51.100.20 ttl 255
sudo ip addr add 10.0.0.1/30 dev gre1
sudo ip link set gre1 up

On Server B, swap local and remote and use the other tunnel address:

sudo ip link add gre1 type gre local 198.51.100.20 remote 203.0.113.10 ttl 255
sudo ip addr add 10.0.0.2/30 dev gre1
sudo ip link set gre1 up

Check the interface details on either server:

ip -d link show gre1
5: gre1@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1476 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/gre 203.0.113.10 peer 198.51.100.20 promiscuity 0 allmulti 0 minmtu 0 maxmtu 0
    gre remote 198.51.100.20 local 203.0.113.10 ttl 255 ...

Now ping the other end of the tunnel from Server A:

ping -c 3 10.0.0.2
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=18.4 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=18.2 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=18.3 ms

If the ping fails, jump to the Troubleshooting section before continuing.

Step 3 - Checking the MTU

GRE adds 24 bytes to every packet (a 20-byte outer IPv4 header plus a 4-byte GRE header). On a normal 1500-byte network that leaves 1476 bytes for the inner packet, which is why the kernel set mtu 1476 on gre1 in the previous step.

Confirm that a full-size packet passes without fragmentation. A 1448-byte ICMP payload plus 28 bytes of ICMP and IP headers equals 1476:

ping -c 3 -M do -s 1448 10.0.0.2

If this works but -s 1449 fails with message too long, the MTU is correct. If even 1448 fails, the path between the servers has a smaller MTU; lower the tunnel MTU until it passes, for example:

sudo ip link set gre1 mtu 1400

Write down the value that works: you will use it in the persistent configuration.

Step 4 - Routing private subnets through the tunnel

A tunnel between two hosts is useful on its own, but the typical use case is connecting two networks. For that, each server needs a route to the other side's subnet and must forward packets.

Enable IPv4 forwarding permanently on both servers with a file in /etc/sysctl.d:

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-gre-forwarding.conf
sudo sysctl --system

Add a temporary route on Server A:

sudo ip route add 192.168.2.0/24 via 10.0.0.2 dev gre1

And on Server B:

sudo ip route add 192.168.1.0/24 via 10.0.0.1 dev gre1

UFW blocks forwarded traffic by default. Allow traffic that enters or leaves through the tunnel on both servers:

sudo ufw route allow in on gre1
sudo ufw route allow out on gre1

From Server A, check that a host in Server B's subnet answers and that the first hop is the tunnel:

ping -c 3 192.168.2.10
ip route get 192.168.2.10
192.168.2.10 via 10.0.0.2 dev gre1 src 10.0.0.1 uid 1000

Hosts inside each private subnet also need a route to the remote subnet pointing at their local GRE server, or they must use that server as their default gateway.

Clamping TCP MSS for forwarded traffic

Hosts behind the tunnel still think their MTU is 1500, and if ICMP "fragmentation needed" messages are filtered somewhere on the path, large TCP transfers hang. Clamping the TCP MSS on the GRE servers avoids this. Add a mangle table block at the very top of /etc/ufw/before.rules, before the existing *filter line:

sudo nano /etc/ufw/before.rules
*mangle
:FORWARD ACCEPT [0:0]
-A FORWARD -o gre1 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
COMMIT

Reload UFW and confirm the rule is loaded:

sudo ufw reload
sudo iptables -t mangle -L FORWARD -n
Chain FORWARD (policy ACCEPT)
target     prot opt source               destination
TCPMSS     6    --  0.0.0.0/0            0.0.0.0/0            tcp flags:0x06/0x02 TCPMSS clamp to PMTU

Step 5 - Making the tunnel persistent with Netplan

Everything you created with ip disappears on reboot. Ubuntu 24.04 manages networking with Netplan, which supports GRE tunnels natively. First remove the manual interface so Netplan can create it cleanly:

sudo ip link del gre1

On Server A, create a new Netplan file:

sudo nano /etc/netplan/60-gre1.yaml
network:
  version: 2
  tunnels:
    gre1:
      mode: gre
      local: 203.0.113.10
      remote: 198.51.100.20
      addresses:
        - 10.0.0.1/30
      mtu: 1476
      routes:
        - to: 192.168.2.0/24
          via: 10.0.0.2

On Server B, use the same file with local and remote swapped, 10.0.0.2/30 as the address, and a route to 192.168.1.0/24 via 10.0.0.1. If you lowered the MTU in Step 3, use that value instead of 1476.

Netplan refuses world-readable configuration files, so restrict the permissions and apply the configuration:

sudo chmod 600 /etc/netplan/60-gre1.yaml
sudo netplan apply

Verify that the interface and the route are back:

ip -br addr show gre1
ip route show dev gre1
gre1@NONE        UNKNOWN        10.0.0.1/30
10.0.0.0/30 proto kernel scope link src 10.0.0.1
192.168.2.0/24 via 10.0.0.2 proto static

Reboot one server with sudo reboot and repeat the ping from Step 2 once it is back to confirm the tunnel comes up on its own.

Step 6 - Encrypting the tunnel with IPsec (optional)

GRE sends its payload in clear text. If the tunnel crosses the public internet and carries anything sensitive, protect it with IPsec in transport mode: strongSwan encrypts only the GRE packets (IP protocol 47) between the two public IPs, and the tunnel itself keeps working exactly as before.

Install strongSwan with the modern swanctl interface on both servers:

sudo apt update
sudo apt install strongswan-swanctl charon-systemd

Generate a strong pre-shared key once and copy it to both servers:

openssl rand -base64 32

On Server A, create the connection file:

sudo nano /etc/swanctl/conf.d/gre1.conf
connections {
    gre1 {
        version = 2
        local_addrs = 203.0.113.10
        remote_addrs = 198.51.100.20
        local {
            auth = psk
            id = 203.0.113.10
        }
        remote {
            auth = psk
            id = 198.51.100.20
        }
        children {
            gre {
                mode = transport
                local_ts = dynamic[gre]
                remote_ts = dynamic[gre]
                start_action = trap
            }
        }
    }
}

secrets {
    ike-gre1 {
        id-a = 203.0.113.10
        id-b = 198.51.100.20
        secret = "your_pre_shared_key"
    }
}

Replace your_pre_shared_key with the key you generated. On Server B, use the same file with local_addrs/remote_addrs and the two id values swapped. The secrets block can stay identical. Protect the file because it contains the key:

sudo chmod 600 /etc/swanctl/conf.d/gre1.conf

IKE uses UDP 500 and 4500, and the encrypted packets use ESP (IP protocol 50). Allow them from the peer on each server (use the other server's IP on Server B):

sudo ufw allow from 198.51.100.20 to any port 500,4500 proto udp

Then add an ESP rule next to the GRE rule in /etc/ufw/before.rules:

-A ufw-before-input -p esp -s 198.51.100.20 -j ACCEPT

Reload UFW, restart strongSwan and load the configuration:

sudo ufw reload
sudo systemctl restart strongswan
sudo swanctl --load-all

start_action = trap makes the kernel negotiate the IPsec session as soon as GRE traffic appears. Send a ping through the tunnel, then list the security associations:

ping -c 3 10.0.0.2
sudo swanctl --list-sas
gre1: #1, ESTABLISHED, IKEv2, ...
  local  '203.0.113.10' @ 203.0.113.10[500]
  remote '198.51.100.20' @ 198.51.100.20[500]
  gre: #1, reqid 1, INSTALLED, TRANSPORT, ESP:AES_GCM_16-256
    203.0.113.10/32[gre] === 198.51.100.20/32[gre]

To prove the traffic is encrypted, capture on the public interface while pinging. You should see ESP packets and no GREv0 packets:

sudo tcpdump -ni eth0 host 198.51.100.20

ESP adds roughly 30 to 60 more bytes per packet, so lower the tunnel MTU to 1400 in the Netplan file and run sudo netplan apply again.

Troubleshooting

The tunnel is up but ping to 10.0.0.2 fails. Check that the two public IPs reach each other (ping 198.51.100.20), then capture GRE packets on both servers while pinging:

sudo tcpdump -ni eth0 ip proto gre

If Server A sends packets but Server B never sees them, a firewall on the path or on Server B is dropping protocol 47. If Server B sees them but does not answer, check the before.rules rule and that local/remote are not swapped.

RTNETLINK answers: File exists when creating the interface. The name is already in use, usually because you chose gre0. Use another name such as gre1.

Small pings work but SSH or HTTPS through the tunnel hangs. This is an MTU problem. Repeat the test from Step 3 and make sure the MSS clamping rule from Step 4 is loaded.

Routed subnets are unreachable while the tunnel IPs answer. Confirm sysctl net.ipv4.ip_forward returns 1, that the ufw route rules exist (sudo ufw status), and that the hosts in each subnet have a route back to the remote subnet.

swanctl --list-sas shows nothing. Check the logs with sudo journalctl -u strongswan -n 50. The most common causes are a mismatched pre-shared key, swapped id values, or UDP 500/4500 blocked between the servers.

Conclusion

You now have a GRE tunnel between two Ubuntu 24.04 servers that routes private subnets, handles the MTU overhead correctly, comes back after a reboot through Netplan and can be encrypted with IPsec. From here you can run a dynamic routing protocol such as BGP or OSPF over the tunnel with FRRouting, add a second tunnel through another provider for redundancy, or replace GRE plus IPsec with WireGuard if you only need encrypted IP connectivity.