UFW and IPv6: the open door on your VPS
Your UFW rules and cloud firewall may only cover IPv4, leaving services wide open on IPv6. See why it happens on a VPS and how to close the gap.
The IPv6 firewall trap in one sentence
Your firewall protects IPv4. Your VPS almost certainly also has a public IPv6 address, and many services listen on it by default. If your firewall only covers IPv4, or if you lean on a cloud firewall that filters IPv4 only, every one of those services is reachable from the whole internet over IPv6 while your IPv4 side looks locked down. You test a port with curl, see a refused connection, and feel safe. An attacker connects to the same port over IPv6 and walks in.
This guide shows where that gap comes from on a normal Ubuntu 24.04 VPS, how to see exactly what you are exposing, and how to close it. UFW is not the villain here. On a modern Ubuntu install UFW already handles IPv6. The exposure comes from the layers around it, and from services you did not know were listening.
Why your VPS is on IPv6 in the first place
Nearly every VPS today ships with a public IPv6 address, often a whole /64, alongside its IPv4 address. Check yours:
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 is routable from anywhere on the internet, exactly like your IPv4 address. Now look at what is listening:
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 closely. 0.0.0.0:22 means "listen on every IPv4 address." [::]:22 means "listen on every IPv6 address." 127.0.0.1:5432 is bound to loopback and is not public at all, so the Postgres line is safe. The two [::] lines answer the whole internet over IPv6, and the docker-proxy one is the kind you forget you started.
Most daemons bind to :: out of the box, because on Linux a :: socket usually accepts IPv4 as well. So the default posture of a fresh server is "answer on both stacks, everywhere." Your firewall is the only thing standing in front of that, which is why a firewall that sees only one stack is a real problem.
Where the IPv6 gap actually comes from
There are four common sources. On a given box you may have one of them or several at once.
1. A cloud firewall that filters IPv4 only. Many provider firewalls and security-group products grew up around IPv4 and either ignore IPv6 or need separate IPv6 rules you have to add by hand. If your only firewall is the one in the provider dashboard and it does not cover IPv6, your [::] services are open no matter what it says about port 22 on IPv4. Read your provider's firewall documentation and look for the word IPv6 specifically.
2. Hand-rolled iptables with no ip6tables. The iptables command touches the IPv4 tables only. IPv6 has a completely separate command, ip6tables, with its own separate rules. If you wrote a firewall script full of iptables -A INPUT ... lines and never wrote the matching ip6tables rules, your IPv6 firewall is empty, and an empty INPUT chain with a default ACCEPT policy allows everything:
sudo ip6tables -L INPUT -n
Chain INPUT (policy ACCEPT)
target prot opt source destination
That output is the whole trap on one screen. IPv4 is filtered, IPv6 accepts the world.
3. Docker publishing ports straight past your firewall. When you run docker run -p 8080:80, Docker inserts its own rules into the DOCKER chain, ahead of UFW's rules, so a published port is reachable even when ufw status says that port is denied. Docker does this on IPv4, and if you have turned on IPv6 in Docker it does the same on IPv6. That docker-proxy line on [::]:8080 above is exposed to the internet on a box whose UFW looks tight. See the basics of Docker Compose on a VPS for how these published ports get declared.
4. UFW with IPv6 switched off. UFW does handle IPv6, but only when it is told to. Check the switch:
grep IPV6 /etc/default/ufw
Modern Ubuntu ships IPV6=yes, so UFW applies each rule to both stacks. If you see IPV6=no, from an old image or an old guide, every UFW rule you have written is IPv4 only, and IPv6 is left unmanaged.
See exactly what you are exposing
Do not guess. Measure it from the outside. First list your listeners and note every one bound to :::
sudo ss -tlnp | grep '::'
Then, from a different machine, connect to the server's public IPv6 address and try a port you believe is closed:
curl -6 -v http://[2001:db8:2a::1]:8080/
If that returns a page or a banner, the port is open on IPv6. A closed port gives you Connection refused or a timeout. For a full picture, scan the IPv6 address with nmap from off the server:
nmap -6 2001:db8:2a::1
Every port nmap reports as open over IPv6 is a port the whole internet can reach, whatever your IPv4 scan showed. Comparing the IPv4 and IPv6 scans side by side is the fastest way to find the gap: anything open on -6 but closed on IPv4 is a service your firewall is missing.
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 what 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 was already active when you flipped IPV6=yes, the change does not take effect until you run sudo ufw reload.
ufw status lists each rule twice, once plain and once with a (v6) suffix. When you see the (v6) lines, UFW is filtering 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 to nftables, whose inet tables cover IPv4 and IPv6 in one place and remove this whole class of mistake. A single nftables inet filter table is the cleanest fix when you are writing rules yourself.
Bind services you do not want public to loopback. A database, an admin panel, or a metrics endpoint rarely needs a public address at all. Bind it to 127.0.0.1 and ::1 so it never listens on a routable address in the first place. For Postgres, set listen_addresses = 'localhost'. For an app server, bind it to 127.0.0.1 and put a reverse proxy in front. Closing the listener beats firewalling it, because then there is nothing to reach.
Do not trust UFW to guard Docker's published ports. Publish container ports to a specific address rather than every interface, for example -p 127.0.0.1:8080:80, so the port is reachable only from the host and whatever you deliberately proxy to it. When a container genuinely must be public, put it behind a Traefik reverse proxy and publish only the proxy, not each app.
Add IPv6 rules to your provider firewall, or accept that it is not your firewall for IPv6 and let UFW or nftables on the host do that job instead.
Verify you are actually closed
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 that answered before should now refuse or time out, and nmap should report it filtered or closed. If a port is still open, walk back through the four sources above: a service still bound to :: with no rule in front, a Docker rule sitting ahead of UFW, or a provider firewall that never saw IPv6 at all.
Keeping sensitive services off the public internet entirely is stronger still. Put SSH and admin panels behind a WireGuard VPN and firewall their ports so they answer only on the tunnel, and the IPv6 exposure question stops applying to them. To slow the brute-force scans that hit whatever stays public, layer Fail2ban in front of SSH on top of a default-deny firewall.
If ports themselves are new to you, what ports are and how services listen is the primer to read first.
FAQ
Does UFW block IPv6 by default?
On a modern Ubuntu 24.04 install, yes. UFW reads IPV6=yes from /etc/default/ufw and applies each rule to both IPv4 and IPv6, and ufw status shows the IPv6 rules with a (v6) suffix. The trap appears when IPV6=no (from an old image or an old tutorial), when you rely on a provider firewall that only filters IPv4, or when Docker publishes a port past UFW. Check the switch with grep IPV6 /etc/default/ufw.
How do I check what my VPS is exposing on IPv6?
Run sudo ss -tlnp and note every listener whose local address starts with [::], which means it answers on every IPv6 interface. Then, from a different machine, test the server's public IPv6 address directly with curl -6 -v http://[YOUR:IPV6::ADDR]:PORT/, or scan it with nmap -6 YOUR:IPV6::ADDR. Any port open on the IPv6 scan but closed on IPv4 is your gap.
Why can I reach my Docker container's port when UFW says it is blocked?
Docker inserts its own firewall rules ahead of UFW's when you publish a port with -p, so the published port is reachable even though ufw status lists it as denied. This happens on IPv4, and on IPv6 too when Docker's IPv6 support is on. Publish to a specific address such as -p 127.0.0.1:8080:80, or put the container behind a reverse proxy and publish only the proxy.
Do I still need an IPv6 firewall if my IPv4 firewall is solid?
Yes. IPv4 and IPv6 are separate network stacks with separate firewall rules. A perfect set of IPv4 rules does nothing for IPv6 traffic. If your VPS has a public IPv6 address, and almost all do, then any service listening on :: stays reachable over IPv6 until an IPv6 firewall rule or a loopback binding stops it.
How do I make a service listen on IPv4 only, or on localhost only?
Set the service's bind address in its own config. Bind to 127.0.0.1 for IPv4 loopback only, or 0.0.0.0 for all IPv4 addresses with no IPv6 listener. Postgres uses listen_addresses, SSH uses ListenAddress, and most app servers expose a host or bind flag. Confirm the result with sudo ss -tlnp and check that the Local Address no longer shows [::].