SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

UFW IPv6 Gap: How to Close Your VPS Open Door

Your Ubuntu 24.04 VPS fit expose services through IPv6 even when IPv4 dey blocked. See wetin dey listen and how to close the firewall gap.

The IPv6 firewall trap in one sentence

Your firewall dey protect IPv4. Your VPS almost certainly get public IPv6 address too, and many services dey listen on am by default. If your firewall only cover IPv4, or you rely on cloud firewall wey only filter IPv4, every one of those services fit reach from the whole internet through IPv6 while your IPv4 side dey look locked down. You test port with curl, see say connection refuse, and feel safe. Attacker connect to the same port through IPv6 and enter.

This guide go show you where this gap dey come from for normal Ubuntu 24.04 VPS, how to see exactly wetin you expose, and how to close am. UFW no be the villain here. For modern Ubuntu installation, UFW already dey handle IPv6. The exposure dey come from the layers around am, and from services wey you no know say dey listen.

Why your VPS dey use IPv6 for the first place

Almost every VPS today dey come with public IPv6 address, often one whole /64, together with IPv4 address. Check your own:

ip -6 addr show scope global
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
    inet6 2001:db8:2a::1/64 scope global

The 2001:db8:2a::1 for this guide na placeholder from 2001:db8::/32. Na prefix wey dem reserve for documentation, and dem no dey route am for internet. The real address wey your server print for that line fit receive traffic from anywhere for internet, just like your IPv4 address. Now check wetin dey listen:

sudo ss -tlnp
State   Recv-Q  Local Address:Port   Process
LISTEN  0       0.0.0.0:22           sshd
LISTEN  0       [::]:22              sshd
LISTEN  0       127.0.0.1:5432       postgres
LISTEN  0       [::]:8080            docker-proxy

Read the Local Address column well. 0.0.0.0:22 mean say e dey listen on every IPv4 address. [::]:22 mean say e dey listen on every IPv6 address. 127.0.0.1:5432 dey bound to loopback and e no public at all, so the Postgres line safe. The two [::] lines dey answer the whole internet over IPv6, and the docker-proxy one na the kind wey you fit forget say you start.

Most daemons dey bind to :: out of the box, because for Linux, :: socket normally dey accept IPv4 too. So the default setup for fresh server na “answer for both stacks, everywhere.” Na only your firewall dey block traffic before e reach am. Na why firewall wey dey see only one stack na real problem.

Wia di IPv6 gap really dey come from

Three common sources dey. For one particular box, you fit get one or several of dem at the same time.

1. Cloud firewall wey dey filter IPv4 only. Plenty provider firewalls and security-group products start from IPv4, so dem either ignore IPv6 or need separate IPv6 rules wey you must add by hand. If na only the firewall for your provider dashboard dey protect you and e no cover IPv6, your [::] services dey open, no matter wetin e talk about port 22 for IPv4. Read your provider firewall documentation and specifically look for the word IPv6.

2. Hand-rolled iptables without ip6tables. The iptables command dey affect IPv4 tables only. IPv6 get completely separate command, ip6tables, with separate rules of its own. If you write firewall script full of iptables -A INPUT ... lines but never write the matching ip6tables rules, your IPv6 firewall empty. Empty INPUT chain with default ACCEPT policy go allow everything:

sudo ip6tables -L INPUT -n
Chain INPUT (policy ACCEPT)
target     prot opt source               destination

That output show the whole trap for one screen. IPv4 dey filtered, but IPv6 dey accept everybody.

3. Docker dey publish ports straight past your firewall. When you run docker run -p 8080:80, Docker dey insert its own rules ahead of UFW rules. Because of that, person fit reach published port even when ufw status talk say dem deny that port. Current Docker dey manage ip6tables rules by default, so when IPv6 dey enabled for the container network, the same thing dey happen over IPv6. Why Docker dey bypass UFW, and how to filter container ports properly explain how e work and the fixes. See Docker Compose basics for VPS to understand how dem dey declare these published ports.

A note about UFW IPv6 switch. UFW dey handle IPv6, and the switch dey inside one file. Check am:

grep IPV6 /etc/default/ufw

Modern Ubuntu dey ship IPV6=yes, so UFW dey apply each rule to both stacks. If you see IPV6=no from old image or old guide, IPv6 no remain open. With that setting, active UFW go drop all IPv6 traffic except traffic for loopback interface. So the problem go happen the other way: anything wey you intend to serve over IPv6 go stop to answer. Set am to yes if you want UFW to filter IPv6 with your rules instead of blocking the traffic completely.

See exactly wetin you dey expose

No guess. Measure am from outside. First list your listeners and note every one wey bind to :::

sudo ss -tlnp | grep '::'

Then, from another machine, connect to the server public IPv6 address and try one port wey you believe say e dey closed:

curl -6 -v http://[2001:db8:2a::1]:8080/

If e return page or banner, the port dey open for IPv6. Closed port go give you Connection refused or timeout. These two failures no be the same signal, and the difference between refused and timed out go tell you whether the host answer and reject you, or firewall drop your packet silently. To get complete picture, scan the IPv6 address with nmap from outside the server:

nmap -6 2001:db8:2a::1

Every port wey nmap report as open over IPv6 na port the whole internet fit reach, no matter wetin your IPv4 scan show. Compare the IPv4 and IPv6 scans side by side. Na the fastest way to find the gap: anything wey open for -6 but closed for IPv4 na service wey your firewall dey miss.

Close the gap

Make UFW cover both stacks, and default to deny. Confirm the switch, then set a default-deny inbound policy and allow only wetin you need:

sudo sed -i 's/^IPV6=no/IPV6=yes/' /etc/default/ufw
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verbose

If UFW bin already active when you flip IPV6=yes, the change no go take effect until you run sudo ufw reload.

ufw status dey list each rule twice, one time plain and one time with (v6) suffix. When you see (v6) lines, UFW dey filter IPv6:

22/tcp                     ALLOW IN    Anywhere
22/tcp (v6)                ALLOW IN    Anywhere (v6)

If you manage iptables by hand, mirror every rule in ip6tables, or move go nftables, wey inet tables cover IPv4 and IPv6 for one place and remove this whole kind mistake. One nftables inet filter table na the cleanest fix when you dey write rules yourself. If your VPS dey run Rocky or AlmaLinux instead of Ubuntu, UFW no dey there to configure, and firewalld na the front end wey you manage instead, wey dey apply its zone rules to both stacks at once.

Bind services you no want public to loopback. Database, admin panel, or metrics endpoint hardly ever need public address. Bind am to 127.0.0.1 and ::1 so e no go ever listen on routable address from the start. For Postgres, set listen_addresses = 'localhost'. For app server, bind am to 127.0.0.1 and put reverse proxy for front. Closing the listener better pass firewalling am, because then nothing dey there to reach.

No trust UFW to guard Docker published ports. Publish container ports to specific address instead of every interface, for example -p 127.0.0.1:8080:80, so na only host and anything wey you deliberately proxy to am fit reach the port. When container truly need public access, put am behind Traefik reverse proxy and publish only the proxy, not every app.

Add IPv6 rules to your provider firewall, or accept say e no be your firewall for IPv6 and make UFW or nftables for the host handle the work instead.

Verify say you really close

Run the same outside test again after your changes:

curl -6 -v http://[2001:db8:2a::1]:8080/
nmap -6 2001:db8:2a::1

The port wey answer before suppose reject connection now or time out, and nmap suppose report am as filtered or closed. If port still dey open, go back through the three sources above: service still dey bind to :: with no rule in front, Docker rule dey ahead of UFW, or provider firewall never see IPv6 at all.

To keep sensitive services completely off public internet na even stronger option. Put SSH and admin panels behind a WireGuard VPN and firewall their ports so dem go answer only through the tunnel. This one mean say IPv6 exposure question no longer apply to dem. To slow down brute-force scans wey dey hit anything wey remain public, put Fail2ban in front of SSH on top of firewall wey dey deny by default.

If ports still new to you, wetin ports be and how services dey listen na the primer wey you suppose read first.

FAQ

UFW dey block IPv6 by default?

For modern Ubuntu 24.04 install, yes. UFW dey read IPV6=yes from /etc/default/ufw and apply each rule to both IPv4 and IPv6. ufw status dey show the IPv6 rules with (v6) suffix. The problem fit show when you depend on provider firewall wey only filter IPv4, when hand-written iptables rules no get ip6tables equivalent, or when Docker publish port pass UFW. IPV6=no (from old image or old tutorial) no open IPv6. With am, active UFW go drop all IPv6 traffic except traffic for loopback. Check the switch with grep IPV6 /etc/default/ufw.

How I fit check wetin my VPS dey expose for IPv6?

Run sudo ss -tlnp and note every listener wey local address start with [::]. This mean say e dey answer on every IPv6 interface. Then, from another machine, test the server public IPv6 address directly with curl -6 -v http://[YOUR:IPV6::ADDR]:PORT/, or scan am with nmap -6 YOUR:IPV6::ADDR. Any port wey open for IPv6 scan but close for IPv4 na the gap.

Why I fit reach my Docker container port when UFW say say e block am?

Docker dey put im own firewall rules before UFW rules when you publish port with -p. So, the published port dey reachable even though ufw status list am as denied. This dey happen for IPv4, and for IPv6 too when Docker IPv6 support dey on. Publish to specific address like -p 127.0.0.1:8080:80, or put the container behind reverse proxy and publish only the proxy.

I still need IPv6 firewall if my IPv4 firewall strong?

Yes. IPv4 and IPv6 na separate network stacks with separate firewall rules. Perfect IPv4 rules no go do anything for IPv6 traffic. If your VPS get public IPv6 address, and almost all of dem get am, any service wey dey listen on :: go remain reachable through IPv6 until IPv6 firewall rule or loopback binding stop am.

How I fit make service listen for IPv4 only, or localhost only?

Set the service bind address for im own config. Bind to 127.0.0.1 for IPv4 loopback only, or 0.0.0.0 for all IPv4 addresses without IPv6 listener. Postgres dey use listen_addresses, SSH dey use ListenAddress, and most app servers get host or bind flag. Confirm the result with sudo ss -tlnp and check say Local Address no longer show [::].