SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

UFW IPv6 Gap: Why Your VPS Still Dey Expose Ports

Your IPv4 port test fit show “connection refused” while IPv6 still dey open. See how Ubuntu 24.04 VPS expose services and close the UFW 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.

Wetin make your VPS dey use IPv6 from beginning

Almost every VPS today dey come with public IPv6 address, and often one whole /64, together with its 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

That 2001:db8:2a::1 fit receive route 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

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

Most daemons dey bind to :: by default, because for Linux, :: socket usually dey accept IPv4 too. So fresh server default posture na “answer for both stacks, everywhere.” Na your firewall be the only thing wey dey in front of that, na why firewall wey dey see only one stack na serious problem.

Where the IPv6 gap actually comes from

Na four common sources dey cause this gap. For one server, 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 with 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 you get, 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 its own separate rules. If you write firewall script wey full of iptables -A INPUT ... lines but you 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 problem for one screen. IPv4 dey filtered, but IPv6 dey accept traffic from everywhere.

3. Docker dey publish ports past your firewall directly. When you run docker run -p 8080:80, Docker dey insert its own rules before UFW rules. Because of that, people fit reach published port even when ufw status say that port deny. For modern Docker, the same thing apply over IPv6. Why Docker dey bypass UFW, and how to filter container ports properly explain how e work and how to fix am. See Docker Compose basics for VPS to understand how dem declare these published ports.

4. UFW get IPv6 switched off. UFW fit handle IPv6, but e go only do am when you enable am. Check the switch:

grep IPV6 /etc/default/ufw

Modern Ubuntu ships IPV6=yes, so UFW dey apply each rule to both stacks. If you see IPV6=no, maybe from old image or old guide, every UFW rule wey you write dey apply to IPv4 only, and IPv6 remain unmanaged.

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 e 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 or time out now, and nmap suppose report say e filtered or closed. If port still open, trace the four sources wey dey above again: service wey still bind to :: with no rule in front, Docker rule wey dey before UFW, or provider firewall wey never see IPv6 at all.

To keep sensitive services completely away from public internet dey even stronger. Put SSH and admin panels behind a WireGuard VPN and firewall their ports so dem go answer only through the tunnel. Then the 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 a default-deny firewall.

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 installation, yes. UFW dey read IPV6=yes from /etc/default/ufw and apply each rule to both IPv4 and IPv6, while ufw status dey show the IPv6 rules with (v6) suffix. The wahala dey happen when IPV6=no (from old image or old tutorial), when you dey depend on provider firewall wey only filter IPv4, or when Docker publish port pass UFW. 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 one 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 talk say e block am?

Docker dey put e own firewall rules before UFW rules when you publish port with -p. So, person fit reach the published port even though ufw status list am as denied. This one 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 dey affect IPv6 traffic. If your VPS get public IPv6 address, and almost all of dem get one, 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 e 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 [::].