IPv4 vs IPv6 on a VPS: What Does Your App Need?

Published on

Most apps on a VPS need IPv4, and IPv6 helps once it works end to end. Check your addresses, listen on both, open the firewall, test, then add the AAAA record.

Network speed graph from a server dashboard with two small upload spikes

Your VPS came with an IPv4 address and an IPv6 address, and you are not sure what the second one does. Maybe the site loads at home but times out for a friend on mobile data, certbot fails while your browser shows the page, or you wonder if you could drop IPv4 to save money. Having both addresses doesn't mean your app uses both. This guide covers who can reach which address, what your server does with each, and when to publish an AAAA record. The commands work on Ubuntu 24.04 and Debian 12.

Quick answer: Keep IPv4. On 29 October 2026 more than half of Google's users still reached it without IPv6, and an IPv6-only server can't reach IPv4-only hosts such as github.com without NAT64. Add IPv6 to a name only when it works end to end: the service listens on it (* or [::] in ss -ltn), the firewall lets it in, and curl -6 from another machine succeeds. Then publish the AAAA record. A wrong AAAA record is worse than none, because clients and Let's Encrypt try IPv6 first. An app behind nginx can stay on 127.0.0.1; only nginx has to listen on both.

Who Can Reach Which Address

A server with both is dual stack, and RFC 4213, which defines it, notes that whether a particular application is available on each stack "is explicitly not defined". The addresses exist, but every program decides for itself where it listens.

Clients decide the rest. Google's IPv6 statistics put the share of its users who reached it over IPv6 at 47.2% on 29 October 2026, with big gaps between countries: 76% of its users in Germany, 52% in the Netherlands and 19% in Poland. Everyone else arrives over IPv4, so a public service needs an IPv4 address. The other direction is easier: an IPv6-only network can reach IPv4 servers through NAT64, which RFC 6146 describes as allowing "IPv6-only clients to contact IPv4 servers".

When a client has both and your name has A and AAAA records, IPv6 goes first: RFC 6724 says applications "will use IPv6 in preference to IPv4 when the two are equally suitable". Browsers race the two with Happy Eyeballs (RFC 8305), giving IPv6 a head start of 300 ms in Firefox and Chrome according to curl's documentation. Clients that try one address after another wait for IPv6 to fail first, so a bad AAAA record shows up as slow or failing tools while the browser looks fine.

Outbound counts too. When we checked for this post, dig AAAA github.com returned no address, so an IPv6-only server can't clone from GitHub without NAT64.

Step 1: See Which Addresses Your VPS Has

ip -brief address show
ip -6 address show scope global
ip -6 route show default

-brief prints each interface on one line with its addresses. Read them like this:

  • 203.0.113.10/24: an IPv4 address, an inet line in the long format.
  • 2001:db8:1234::10/64, scope global in the long format: an IPv6 address that can be public. The exception is addresses starting with fd: unique local addresses, which RFC 4193 says "are not expected to be routable on the global Internet".
  • fe80:: with scope link: link-local, "valid only on this device" in the words of the ip-address man page. RFC 4291 requires one on every IPv6 interface, so it proves nothing about internet access.

The route command should print a line starting with default via; ip-route treats default as ::/0 for IPv6. No line means no way out over IPv6, even with a global address. Then test each protocol outbound against Google Public DNS, whose documented addresses include 8.8.8.8 and 2001:4860:4860::8888:

ping -4 -c 3 8.8.8.8
ping -6 -c 3 2001:4860:4860::8888

If you expected IPv6 but see only fe80::, ask your provider for the address, prefix length and gateway. Every Space-Node VPS includes both IPv4 and IPv6, so there the only question is what your software does with them.

Step 2: Make Each Public Service Listen on Both

sudo ss -ltunp

-l shows listening sockets, -t and -u TCP and UDP, -n numbers and -p the process. The Local Address column shows which protocol each socket takes:

  • 0.0.0.0:443: IPv4 only.
  • [::]:443: IPv6 only; fine next to a 0.0.0.0:443 line for the same program.
  • *:443: one socket for both. The kernel setting net.ipv6.bindv6only defaults to 0, so a program that binds :: without the IPV6_V6ONLY option also gets IPv4, and ss prints such a socket as *.
  • 127.0.0.1 or [::1]: reachable only from the server itself.

Defaults differ per program:

SoftwareDefaultFor IPv4 and IPv6
nginxlisten 443 is IPv4 only, listen [::]:443 is IPv6 onlyBoth lines in every server block
ApacheListen 80 takes both; Linux builds use IPv4-mapped addresses by defaultNothing to change
Node.jsserver.listen(port) without a host binds ::, which also takes IPv4Don't pass '0.0.0.0' as the host
JavaAn IPv6 socket that accepts bothLeave out -Djava.net.preferIPv4Stack=true
OpenSSHAddressFamily any on all local addressesNothing to change
Docker-p 8080:80 publishes on all host addresses, IPv4 and IPv6Nothing to change, but read step 3

The nginx row needs the most care. Its address parser turns a bare port into an IPv4 wildcard, and ipv6only "is turned on by default" (listen docs). nginx picks a server block by address and port first and by server_name second (request processing), so a block without the [::] line doesn't exist for IPv6 visitors: they get the default server of [::]:443, another site with another certificate, or a refused connection if no block listens there. Give each block both lines:

listen 443 ssl;
listen [::]:443 ssl;

Apply it with sudo nginx -t && sudo systemctl reload nginx. Our Cloudflare 521 guide shows a complete server block.

An app that only listens on IPv4 can stay that way behind a reverse proxy: nginx listens on both and passes requests to http://127.0.0.1:3000. Write the literal address there; our nginx 502 guide explains how localhost can end up meaning ::1.

Step 3: Open the Firewall for Both

Several of those programs listen on both by default, so a firewall that only filters IPv4 leaves them open over IPv6. With UFW, check:

grep IPV6 /etc/default/ufw
sudo ufw status

IPV6=yes is the default in ufw 0.36.2, the version in Ubuntu 24.04 and Debian 12. With no, the same file says, "only IPv6 on loopback" is accepted, so your public IPv6 address goes dark while UFW runs. After a change, run sudo ufw disable and then sudo ufw enable.

A rule without an address, such as sudo ufw allow 443/tcp, covers both protocols, and ufw status lists the IPv6 copy with (v6). A rule with a source address covers only that address's protocol, so allow your IPv6 admin address separately:

sudo ufw allow proto tcp from 2001:db8:5::7 to any port 22

Leave ICMPv6 alone. UFW's before6.rules already accepts the types RFC 4890 says must not be dropped, such as neighbor discovery and Packet Too Big. IPv6 routers don't fragment packets, so Packet Too Big is "vital to the correct functioning of Path MTU Discovery", and without it, in the RFC's words, "parts of the Internet will become inaccessible". Don't carry over the IPv4 habit of dropping all ICMP.

Without UFW, an nftables table of family inet covers both protocols, while ip and ip6 tables cover one each. With iptables, IPv6 rules go into ip6tables. Docker warns that traffic to published ports "gets diverted before it goes through the ufw firewall settings"; our Docker and UFW guide covers that, IPv6 included.

Step 4: Test From Outside, One Protocol at a Time

Run these on your own computer, not on the VPS. The IPv6 lines need IPv6 on your side too; test-ipv6.com tells you, and if it fails, test from a second server.

curl -4 -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' https://example.com/
curl -6 -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' https://example.com/

-4 and -6 make curl use only IPv4 or only IPv6 addresses, and remote_ip prints the address that answered; both lines should print the same status code. Before the AAAA record exists, aim curl at the new address with --resolve, which takes IPv6 addresses in brackets (since curl 7.57.0, so Ubuntu 24.04 and Debian 12 both have it). Keep the quotes, or the shell may treat the brackets as a pattern:

curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' \
  --resolve 'example.com:443:[2001:db8:1234::10]' https://example.com/

For other services, test each port over IPv6:

ssh -6 user@example.com
nc -6 -zv -w 5 2001:db8:1234::10 25565

-6 limits ssh and nc to IPv6, and -z makes nc only check that something listens. For UDP and nmap, see the Docker guide's test section. Check the DNS records with dig +short A example.com and dig +short AAAA example.com; if dig is missing, it is in the bind9-dnsutils package.

Step 5: Publish the AAAA Record Last

Add the AAAA record once every port that the name serves passes step 4 over IPv6, and delete AAAA records you don't use. A wrong record hurts more than a missing one, and certificates show it first: Let's Encrypt "will always prefer the IPv6 addresses for the initial connection" and retries over IPv4 only after a connection timeout, not after other errors. If something answers on the IPv6 address, like an nginx default server, validation fails without a retry, and redirects never get one. Its docs add: "There is not a way to request Let's Encrypt prefer IPv4, you must fix the misconfiguration."

Behind Cloudflare's proxy, your server doesn't need IPv6 for web traffic. Cloudflare's IPv6 compatibility is on by default and generates AAAA records for proxied names, so IPv6 visitors reach Cloudflare, which "will prefer the IPv4 address when connecting to your origin server" if a record has both. If your software only understands IPv4-formatted visitor addresses, the same page points to Pseudo IPv4.

Which One Fits

SetupRecordsWho reaches itFits
IPv4 onlyAEveryone; IPv6-only networks through NAT64Software that can't listen on IPv6, or until your IPv6 test passes
Dual stackA and AAAAEveryone, IPv6 clients directlyPublic sites, APIs and game servers that listen on both
IPv6 onlyAAAAClients with IPv6, 47.2% of Google's usersInternal services between machines that all have IPv6

Aim for dual stack on anything public, and keep a name IPv4 only until its software passes the IPv6 test.

FAQ

Can I save money with an IPv6-only VPS? Some providers bill IPv4 separately, as our VPS price checklist shows, but an IPv6-only server can't be reached by clients without IPv6 and can't fetch from IPv4-only hosts such as github.com. For anything public, keep IPv4.

Should I turn IPv6 off on my VPS? Usually not. Without an AAAA record, clients use IPv4 for that name, and with IPV6=yes, UFW's default incoming policy covers the IPv6 address too. If IPv6 is half working, see the next question.

Why do apt and curl hang on my VPS? Probably a global IPv6 address without a working route, the case RFC 6724 calls broken IPv6: programs try IPv6 first and run into timeouts. Check step 1 and ask your provider to fix the route. Meanwhile, sudo apt-get -o Acquire::ForceIPv4=true update makes apt use IPv4 only (apt.conf), and this line, which the comments in /etc/gai.conf (glibc's copy) suggest for sites that prefer IPv4, makes programs started afterwards do so:

echo 'precedence ::ffff:0:0/96  100' | sudo tee -a /etc/gai.conf

Remove it again once IPv6 works.

Why does certbot fail when the site loads in my browser? Your browser fell back to IPv4 and Let's Encrypt didn't. Run dig +short AAAA example.com; if an address comes back, test it with the --resolve command from step 4. Then fix the listen lines or the firewall, or delete the record, and run certbot again.

Why does Gmail bounce my server's mail with an IPv6 error? The bounce reads "550-5.7.1: Message does not meet IPv6 sending guidelines regarding PTR records and authentication." Google's sender guidelines require a PTR record for the sending address, pointing to a hostname whose A or AAAA record resolves back to that address. Ask your provider for reverse DNS on the IPv6 address as well, or set inet_protocols = ipv4 in Postfix's main.cf; Postfix says you "MUST stop and start" it after that change.

Why do my logs show addresses like ::ffff:203.0.113.5? That is an IPv4 client on a dual-stack socket. The ipv6 man page explains that an IPv4 connection to an IPv6 socket gets its source address "mapped to v6". The part after ::ffff: is the client's IPv4 address.

Sources

All opened on 31 October 2026: