Testing Apps on IPv6-Only Networks: A Practical Guide

If your application has never been tested on an IPv6-only network, it is broken on an IPv6-only network. This is not a theoretical concern. Apple has required apps to work on IPv6-only networks since June 2016 — submit an app that fails the NAT64 check and it gets rejected. T-Mobile US runs its mobile network as IPv6-only with 464XLAT for legacy IPv4, serving over 100 million subscribers. India's Reliance Jio launched as IPv6-only from day one. As carriers and cloud providers continue shedding IPv4, applications that assume IPv4 connectivity will break in production — silently, in ways that are hard to diagnose after the fact.

This guide covers how to build a local IPv6-only test environment, the specific breakage patterns to look for, language-specific pitfalls, and how to integrate IPv6 testing into CI/CD pipelines. The goal is to make IPv6-only testing a routine part of your development workflow, not a fire drill triggered by a rejection email from Apple.

Why IPv6-Only Testing Is Non-Negotiable

The business case is straightforward. Apple's App Store review process tests every submitted app on an IPv6-only network using NAT64/DNS64. If your app makes a direct connection to an IPv4 literal or uses IPv4-only socket APIs, it will fail review. Google Play does not enforce this yet, but Android devices on T-Mobile, EE (UK), SK Telecom, and dozens of other carriers routinely operate on IPv6-only networks with NAT64 translation for IPv4 destinations. If your app fails, your users experience it as a mysterious "no connection" error.

Beyond mobile, the infrastructure trend is clear. AWS launched IPv6-only subnets in VPCs. GCP supports IPv6-only VMs. Hetzner, a major European hosting provider, charges extra for IPv4 addresses. The cost of IPv4 addresses on the transfer market exceeds $30 per address, pushing operators toward IPv6-only deployments with translation layers for backward compatibility.

The technical reasons applications break are well-understood and entirely preventable — but only if you test for them.

How NAT64/DNS64 Works (The Testing Foundation)

Before setting up a test environment, you need to understand the mechanism that makes IPv6-only networks talk to IPv4-only servers. The combination of NAT64 and DNS64 works as follows:

  1. Your IPv6-only client queries DNS for example.com.
  2. The DNS64 resolver checks for AAAA records. If none exist but an A record does (say 93.184.216.34), the resolver synthesizes an AAAA record by embedding the IPv4 address inside the well-known NAT64 prefix 64:ff9b::/96, producing 64:ff9b::5db8:d822.
  3. Your client connects to this synthesized IPv6 address.
  4. The NAT64 gateway receives the packet, extracts the embedded IPv4 address, and forwards the packet to the real IPv4 destination as an IPv4 packet.
  5. Return traffic follows the reverse path through the NAT64 gateway.

This works transparently for applications that use DNS to resolve hostnames and connect using whatever address family the system returns. It breaks catastrophically for applications that bypass DNS, hardcode IPv4 addresses, or force IPv4-only socket creation.

NAT64/DNS64 Translation Flow App (Client) IPv6-only DNS64 Resolver NAT64 Gateway 64:ff9b::/96 IPv4 Server 93.184.216.34 1. AAAA? 2. 64:ff9b::5db8:d822 3. IPv6 to 64:ff9b::5db8:d822 4. IPv4 to 93.184.216.34 IPv6-only network IPv4-only internet

Setting Up a Local NAT64/DNS64 Test Environment

macOS: Internet Sharing with NAT64 (Fastest Setup)

Apple provides the easiest way to create an IPv6-only test network, built directly into macOS. This is the exact environment Apple uses for App Store review.

  1. You need a Mac with two network interfaces — typically Wi-Fi and Ethernet, or Wi-Fi and a Thunderbolt/USB Ethernet adapter. The Mac shares its primary internet connection (e.g., Wi-Fi) over the secondary interface.
  2. Open System Settings > General > Sharing (or System Preferences > Sharing on older macOS).
  3. Select Internet Sharing. Choose the interface to share from (your internet-connected interface) and the interface to share to.
  4. Hold the Option key and click the Internet Sharing checkbox. This reveals the "Create NAT64 Network" option. Enable it.
  5. Turn on Internet Sharing.

Connect your test device (iPhone, iPad, or another Mac) to the shared network. The device will receive only an IPv6 address. DNS queries go through Apple's built-in DNS64, which synthesizes AAAA records for IPv4-only destinations. All IPv4 traffic is translated through NAT64.

Verify the setup on the client device:

# On the test device — should show only IPv6 addresses
ifconfig en0 | grep inet6

# Verify NAT64 synthesis — should return a synthesized AAAA
dig AAAA ipv4only.arpa
# Expected: 64:ff9b::c000:aa (192.0.0.170 synthesized)

# Test connectivity to an IPv4-only host
curl -6 http://ipv4only.arpa

Linux: Jool NAT64 + DNS64 (Production-Grade Setup)

For a more controllable setup — especially for CI/CD or server-side testing — use Jool, the open-source SIIT/NAT64 implementation for Linux, combined with BIND or Unbound for DNS64.

Step 1: Install Jool

# Debian/Ubuntu
sudo apt install jool-dkms jool-tools

# Verify the kernel module
sudo modprobe jool
sudo jool instance add nat64 --iptables --pool6 64:ff9b::/96

Step 2: Configure the NAT64 instance

# Create a NAT64 instance with the well-known prefix
sudo jool instance add "default" --iptables --pool6 64:ff9b::/96

# Add ip6tables/iptables rules to steer traffic through Jool
sudo ip6tables -t mangle -A PREROUTING -d 64:ff9b::/96 -j JOOL --instance "default"
sudo iptables -t mangle -A PREROUTING -j JOOL --instance "default"

# Configure the IPv4 pool (the source addresses for translated packets)
sudo jool pool4 add --tcp 192.0.2.1 1024-65535
sudo jool pool4 add --udp 192.0.2.1 1024-65535
sudo jool pool4 add --icmp 192.0.2.1 0-65535

Step 3: Configure Unbound for DNS64

# /etc/unbound/unbound.conf
server:
    interface: ::0
    access-control: ::0/0 allow
    module-config: "dns64 iterator"
    dns64-prefix: 64:ff9b::/96
    do-ip4: yes
    do-ip6: yes
    prefer-ip6: yes
sudo systemctl restart unbound

Step 4: Create an IPv6-only test namespace

# Create an isolated network namespace for testing
sudo ip netns add ipv6only
sudo ip link add veth-host type veth peer name veth-test
sudo ip link set veth-test netns ipv6only

# Assign IPv6 addresses only — no IPv4
sudo ip addr add fd00::1/64 dev veth-host
sudo ip link set veth-host up

sudo ip netns exec ipv6only ip addr add fd00::2/64 dev veth-test
sudo ip netns exec ipv6only ip link set veth-test up
sudo ip netns exec ipv6only ip link set lo up

# Set default route via the host
sudo ip netns exec ipv6only ip -6 route add default via fd00::1

# Point DNS at the DNS64 resolver
sudo mkdir -p /etc/netns/ipv6only
echo "nameserver fd00::1" | sudo tee /etc/netns/ipv6only/resolv.conf

# Test from inside the namespace
sudo ip netns exec ipv6only curl -6 https://example.com

Docker: IPv6-Only Container Network

Docker supports IPv6-only networking, which is useful for testing server-side applications and container networking configurations.

# Create an IPv6-only network (no IPv4)
docker network create \
  --ipv6 \
  --subnet fd00:dead:beef::/48 \
  --gateway fd00:dead:beef::1 \
  -o com.docker.network.bridge.enable_ip_masquerade=true \
  -o com.docker.network.enable_ipv4=false \
  ipv6only-net

# Run a container on this network
docker run --rm -it --network ipv6only-net alpine sh

# Inside the container — verify no IPv4
ip addr show  # should show only IPv6 on eth0
ping -6 fd00:dead:beef::1  # gateway reachable
wget -q -O- http://ipv6.google.com  # IPv6 connectivity test

For complete NAT64 testing in Docker, you need to run a DNS64 resolver and Jool (or Tayga) inside or alongside the container network. A Docker Compose setup with a DNS64 sidecar is the most practical approach:

# docker-compose.yml
services:
  dns64:
    image: mvance/unbound:latest
    networks:
      ipv6only:
        ipv6_address: fd00:dead:beef::53
    volumes:
      - ./unbound-dns64.conf:/opt/unbound/etc/unbound/unbound.conf

  app-under-test:
    build: .
    networks:
      ipv6only:
    dns:
      - fd00:dead:beef::53
    depends_on:
      - dns64

networks:
  ipv6only:
    enable_ipv6: true
    ipam:
      config:
        - subnet: fd00:dead:beef::/48
          gateway: fd00:dead:beef::1

Mobile Testing

iOS Simulator

The iOS Simulator on macOS uses the host Mac's network stack directly. It does not have its own network interface. To test IPv6-only behavior in the Simulator, set up the macOS NAT64 Internet Sharing described above and connect your Mac's secondary interface to that network — or, more practically, use the Mac itself as the NAT64 gateway and route the Simulator's traffic through it by adjusting the host's routing table to only use IPv6 paths.

The most reliable approach for Simulator testing: use Apple's networksetup command-line tool to configure a network service with only IPv6, or use the macOS NAT64 Internet Sharing with a second Mac/device and share back to the test Mac via a separate interface.

For definitive testing, use a physical device connected to the NAT64 network.

Android Emulator

The Android emulator can be configured to use an IPv6-only network. Start the emulator with a custom DNS pointing to your DNS64 resolver:

# Start the emulator with custom DNS
emulator -avd Pixel_7_API_34 -dns-server fd00::1

# Or modify the running emulator via adb
adb shell settings put global captive_portal_server ipv6.google.com
adb shell svc wifi disable && adb shell svc wifi enable

For a more thorough approach, run the emulator within a Linux network namespace that has only IPv6 connectivity (as described in the Jool setup above). This ensures the emulator genuinely cannot use IPv4 at the network layer.

Common Breakage Patterns

These are the specific ways applications fail on IPv6-only networks, ordered by frequency. Every one of these should be a checklist item in your code review process.

1. Hardcoded IPv4 Literals

The most common failure. Any code that connects to an IP address directly — http://10.0.0.1/api, a hardcoded DNS resolver like 8.8.8.8, or an analytics endpoint specified by IP — will fail outright on an IPv6-only network because the system has no IPv4 route.

# BROKEN: hardcoded IPv4 — unreachable on IPv6-only
requests.get("http://192.168.1.100:8080/health")

# BROKEN: hardcoded IPv4 DNS resolver
resolver = dns.resolver.Resolver()
resolver.nameservers = ['8.8.8.8']

# FIXED: use hostnames and let DNS resolution handle address family
requests.get("http://api.internal:8080/health")

# FIXED: use system DNS or IPv6-capable resolvers
resolver = dns.resolver.Resolver()
resolver.nameservers = ['2001:4860:4860::8888']  # Google's IPv6 DNS

2. AF_INET-Only Socket Calls

Code that explicitly creates IPv4-only sockets will fail. This is common in custom network code, health checks, and service discovery implementations.

// BROKEN: C/C++ — forces IPv4
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
inet_pton(AF_INET, "93.184.216.34", &addr.sin_addr);
connect(fd, (struct sockaddr*)&addr, sizeof(addr)); // fails on IPv6-only

// FIXED: use getaddrinfo and connect to whatever family it returns
struct addrinfo hints = {0}, *result;
hints.ai_family = AF_UNSPEC;      // accept both IPv4 and IPv6
hints.ai_socktype = SOCK_STREAM;
getaddrinfo("example.com", "443", &hints, &result);
// iterate result list and try each address (Happy Eyeballs)

3. IPv4-Only DNS Resolution

Some applications or libraries explicitly request only A records, ignoring AAAA records entirely. On an IPv6-only network with DNS64, querying for A records returns IPv4 addresses that the system cannot route to. The application needs to query for AAAA records (or both, via AF_UNSPEC) so that DNS64 can synthesize routable IPv6 addresses.

4. URL Parsing That Chokes on IPv6 Brackets

IPv6 addresses in URLs are enclosed in square brackets: http://[::1]:8080/. Many hand-rolled URL parsers, regex-based validators, and configuration file formats fail on this syntax. If your code parses URLs or network addresses with regex, it probably breaks on IPv6.

# BROKEN: naive regex for host:port
match = re.match(r'^(\d+\.\d+\.\d+\.\d+):(\d+)$', endpoint)

# FIXED: use standard URL parser
from urllib.parse import urlparse
parsed = urlparse(f"http://{endpoint}")
# handles both 10.0.0.1:8080 and [::1]:8080

5. Health Checks Using IPv4 Literals

Kubernetes liveness/readiness probes, load balancer health checks, and monitoring systems that use IPv4 addresses will fail when the workload runs in an IPv6-only environment. Replace all health check targets with hostnames or IPv6-compatible addresses.

# BROKEN: Kubernetes health check with IPv4
livenessProbe:
  httpGet:
    host: 10.0.0.5
    path: /healthz
    port: 8080

# FIXED: use the pod's own address (default behavior — omit host)
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
Common IPv6-Only Breakage Patterns Hardcoded IPv4 Literals connect("10.0.0.1:443") No route to IPv4 destination Fix: use hostnames + DNS AF_INET-Only Sockets socket(AF_INET, ...) Cannot create IPv6 connection Fix: AF_UNSPEC + getaddrinfo IPv4-Only DNS Queries query A record only Bypasses DNS64 synthesis Fix: query AAAA or AF_UNSPEC URL Parsing Fails on [::] regex: (\d+\.\d+\.\d+\.\d+) No match for [::1]:8080 Fix: use URL parser library IPv4 Health Checks probe: http://10.0.0.5/hz Health check unreachable Fix: hostname or omit host Third-Party SDK Failures analytics.init("1.2.3.4") SDK uses hardcoded IPv4 Fix: update SDK or patch Testing Checklist Summary grep -rn 'AF_INET[^6]' src/ | grep -rn '\d+\.\d+\.\d+\.\d+' src/ | grep -rn 'gethostbyname' src/ Search your codebase for these patterns before testing

Language-Specific Pitfalls

Python

Python's socket.getaddrinfo() defaults depend on the system configuration, but many developers bypass it with explicit AF_INET usage.

import socket

# BROKEN: forces IPv4
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("example.com", 443))

# FIXED: use getaddrinfo to get the right address family
addrinfo = socket.getaddrinfo("example.com", 443, socket.AF_UNSPEC, socket.SOCK_STREAM)
for family, socktype, proto, canonname, sockaddr in addrinfo:
    try:
        sock = socket.socket(family, socktype, proto)
        sock.connect(sockaddr)
        break
    except OSError:
        sock.close()
        continue

# ALSO BROKEN: the popular requests library works fine, but urllib3
# connection pools with explicit IPv4 addresses will fail
# requests.get("http://[::1]:8080")  # this actually works in requests
# requests.get("http://10.0.0.1")    # fails on IPv6-only

The asyncio library's loop.create_connection() and aiohttp handle IPv6 correctly by default when given a hostname. The risk is in lower-level code, configuration loading, and third-party libraries that hardcode AF_INET.

Go

Go's standard library is generally IPv6-ready, but there are subtle traps.

// WORKS: net.Dial resolves and tries both IPv4 and IPv6
conn, err := net.Dial("tcp", "example.com:443")

// BROKEN: forces IPv4
conn, err := net.Dial("tcp4", "example.com:443")

// BROKEN: hardcoded IPv4 address
conn, err := net.Dial("tcp", "93.184.216.34:443")

// CAREFUL: net.DialContext with a custom resolver that only returns A records
resolver := &net.Resolver{
    PreferGo: true,
    Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
        d := net.Dialer{}
        return d.DialContext(ctx, "udp4", "8.8.8.8:53") // BROKEN: IPv4 DNS
    },
}

// FIXED: let the system resolver handle address family selection
resolver := &net.Resolver{
    PreferGo: false, // use the system resolver (CGo)
}

// For gRPC: the default resolver works, but custom name resolvers
// must return IPv6 addresses when available
grpc.Dial("dns:///my-service:50051", grpc.WithInsecure()) // works
grpc.Dial("93.184.216.34:50051", grpc.WithInsecure())     // broken on IPv6-only

One subtle Go issue: when PreferGo is true, Go's built-in DNS resolver sends queries over whatever network is available. On an IPv6-only system, it correctly uses IPv6 to reach DNS servers. But if the DNS server address itself is an IPv4 literal in /etc/resolv.conf, the resolver cannot reach it. Ensure your Go application either uses the CGo resolver (which delegates to the system) or that /etc/resolv.conf contains IPv6 DNS server addresses.

Java / JVM

Java has a long history of IPv6 issues, mostly resolved in modern JDKs but still triggering problems through configuration flags.

// Java prefers IPv4 by default (!) due to a system property
// This is the #1 cause of Java apps failing on IPv6-only networks
// Check: -Djava.net.preferIPv4Stack=true  (BREAKS IPv6)

// BROKEN: forces IPv4 resolution
InetAddress addr = Inet4Address.getByName("example.com"); // only A records

// FIXED: use InetAddress which returns both
InetAddress[] addrs = InetAddress.getAllByName("example.com");
// iterate and try IPv6 first, fall back to IPv4

// Ensure this JVM flag is NOT set (or set to false):
// -Djava.net.preferIPv4Stack=false
// -Djava.net.preferIPv6Addresses=true  (prefer IPv6 when available)

Check all JVM startup scripts, Docker entrypoints, and application configuration for -Djava.net.preferIPv4Stack=true. This single flag disables the IPv6 stack entirely in the JVM and is often set by default in legacy application templates.

Node.js

Node.js has a specific footgun: dns.lookup() defaults to returning IPv4 addresses first, even when IPv6 is available.

const dns = require('dns');
const http = require('http');

// BROKEN: dns.lookup() defaults to family=0 but tries IPv4 first
// On IPv6-only networks, it returns an IPv4 address that cannot be routed
dns.lookup('example.com', (err, address, family) => {
  console.log(address); // might return 93.184.216.34 — unreachable
});

// FIXED: explicitly prefer IPv6 or use dns.resolve6
dns.lookup('example.com', { family: 6 }, (err, address) => {
  console.log(address); // returns AAAA record (possibly DNS64-synthesized)
});

// BETTER: use the newer dns.promises API with verbatim option
const { Resolver } = require('dns').promises;
const resolver = new Resolver();
const addresses = await resolver.resolve6('example.com');

// Node.js 17+ has autoSelectFamily (Happy Eyeballs)
// Set globally or per-request:
const http = require('http');
http.request({
  hostname: 'example.com',
  port: 80,
  autoSelectFamily: true  // tries IPv6 and IPv4 in parallel
});

In Node.js 17+, the autoSelectFamily option implements Happy Eyeballs (RFC 8305) behavior. For earlier versions, you need to explicitly handle address family selection or use a library like happy-eyeballs.

Testing HTTP Clients and CLI Tools

Before testing your application, verify that the test environment itself works using standard tools.

# Force IPv6 with curl — should work through NAT64 for IPv4-only destinations
curl -6 -v https://example.com
# Look for: "Trying [64:ff9b::...]" in verbose output (NAT64 synthesis)

# wget with IPv6 only
wget --inet6-only https://example.com

# Test DNS64 synthesis explicitly
dig AAAA example.com @fd00::1
# If example.com has no native AAAA, DNS64 synthesizes one with 64:ff9b:: prefix

# Verify that IPv4 is genuinely unreachable
curl -4 https://example.com  # should fail with "network unreachable"
ping 8.8.8.8                 # should fail
ping6 2001:4860:4860::8888   # should succeed

Happy Eyeballs (RFC 8305) Behavior Testing

Happy Eyeballs is the algorithm that modern applications should use for connection establishment. It tries IPv6 first, and if no response arrives within a short timeout (typically 250ms), it starts a parallel IPv4 connection attempt. The first connection to succeed wins. This prevents IPv6 breakage from making the application feel slow — instead of waiting for a full TCP timeout on a broken IPv6 path, the IPv4 connection takes over quickly.

On an IPv6-only network with NAT64, Happy Eyeballs should work correctly: the DNS64-synthesized AAAA address is routable via NAT64, so the IPv6 connection should succeed. But testing this behavior requires verifying two things:

  1. The application actually implements Happy Eyeballs (many do not — they try addresses sequentially with full timeout).
  2. The DNS64-synthesized addresses are correctly prioritized.
# Test connection timing to see if Happy Eyeballs is in effect
# On a dual-stack network:
curl -w "time_connect: %{time_connect}s\ntime_namelookup: %{time_namelookup}s\n" \
  -o /dev/null -s https://example.com

# Compare IPv4-only vs IPv6-only connection times
curl -4 -w "%{time_connect}" -o /dev/null -s https://example.com
curl -6 -w "%{time_connect}" -o /dev/null -s https://example.com

# If IPv6 connect time is significantly higher than IPv4,
# your NAT64 path may be adding latency — this is expected but
# should not exceed the Happy Eyeballs threshold

Note that curl 7.73+ implements Happy Eyeballs by default. Older versions try addresses sequentially. Python's asyncio has Happy Eyeballs since Python 3.12. Go's net.Dialer implements it when DualStack is true (deprecated — it's now always on). Java's HttpClient (JDK 11+) does not implement Happy Eyeballs.

Verifying IPv6 Traffic with Packet Capture

When debugging IPv6-only issues, packet captures tell you exactly what is happening on the wire.

# Capture only IPv6 traffic on the test interface
sudo tcpdump -i eth0 ip6 -n -v

# Capture DNS queries to see DNS64 synthesis
sudo tcpdump -i eth0 port 53 -n -v
# Look for: AAAA query, response containing 64:ff9b:: addresses

# Filter for NAT64 prefix traffic
sudo tcpdump -i eth0 'ip6 dst net 64:ff9b::/96' -n

# Using tshark (Wireshark CLI) for more detailed analysis
tshark -i eth0 -f "ip6" -Y "dns.qr == 1 && dns.resp.type == AAAA" \
  -T fields -e dns.qry.name -e dns.aaaa

In Wireshark's GUI, use the display filter ipv6.dst == 64:ff9b::/96 to see all traffic going through NAT64. This is the fastest way to identify which connections are using the translation path and which have native IPv6 connectivity.

CI/CD Pipeline Integration

Testing IPv6-only in CI is harder than testing locally because most CI environments do not provide IPv6 connectivity. Here are practical approaches for the major CI platforms.

GitHub Actions

GitHub Actions runners have IPv6 addresses but the network is dual-stack, not IPv6-only. To create an IPv6-only test environment, use a Linux network namespace inside the runner.

# .github/workflows/ipv6-test.yml
name: IPv6-Only Tests
on: [push, pull_request]

jobs:
  ipv6-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up IPv6-only test namespace
        run: |
          # Create an IPv6-only network namespace
          sudo ip netns add ipv6test
          sudo ip link add veth-ci type veth peer name veth-test
          sudo ip link set veth-test netns ipv6test

          # Configure IPv6 only — no IPv4 at all
          sudo ip addr add fd00:ci::1/64 dev veth-ci
          sudo ip link set veth-ci up

          sudo ip netns exec ipv6test ip addr add fd00:ci::2/64 dev veth-test
          sudo ip netns exec ipv6test ip link set veth-test up
          sudo ip netns exec ipv6test ip link set lo up
          sudo ip netns exec ipv6test ip -6 route add default via fd00:ci::1

          # Enable IPv6 forwarding
          sudo sysctl -w net.ipv6.conf.all.forwarding=1

      - name: Run tests in IPv6-only namespace
        run: |
          # Run your test suite inside the IPv6-only namespace
          sudo ip netns exec ipv6test \
            su $(whoami) -c "cd $GITHUB_WORKSPACE && make test-ipv6"

      - name: Verify no IPv4 leakage
        run: |
          # Confirm that IPv4 is truly unreachable
          sudo ip netns exec ipv6test ping -c 1 -W 1 8.8.8.8 && exit 1 || echo "IPv4 correctly unreachable"

Running Jool in CI

For full NAT64 testing in CI, you need kernel module support, which most CI runners do not offer. Alternatives:

# CI-friendly approach: Docker IPv6-only network for application-level testing
- name: Test with Docker IPv6-only network
  run: |
    docker network create --ipv6 --subnet fd00:ci::/48 \
      -o com.docker.network.enable_ipv4=false ipv6-test

    docker build -t app-test .
    docker run --network ipv6-test --rm app-test /app/run-tests.sh

Cloud Provider IPv6-Only VPCs

AWS

AWS supports IPv6-only subnets in VPCs. EC2 instances launched in these subnets receive only IPv6 addresses. AWS provides a managed NAT64 service through the DNS64-enabled Route 53 Resolver and a NAT64 gateway for reaching IPv4-only endpoints.

# Create an IPv6-only subnet in an existing VPC
aws ec2 create-subnet \
  --vpc-id vpc-12345 \
  --ipv6-native \
  --ipv6-cidr-block 2600:1f18:abc:def::/64 \
  --availability-zone us-east-1a

# Enable DNS64 on the subnet
aws ec2 modify-subnet-attribute \
  --subnet-id subnet-67890 \
  --enable-dns64

# Create a NAT gateway with NAT64 support
# (requires a public subnet with an Elastic IP for the NAT gateway itself)
aws ec2 create-nat-gateway \
  --subnet-id subnet-public \
  --allocation-id eipalloc-xxx

# Route NAT64 prefix through the NAT gateway
aws ec2 create-route \
  --route-table-id rtb-xxx \
  --destination-ipv6-cidr-block 64:ff9b::/96 \
  --nat-gateway-id nat-xxx

The key benefit of AWS IPv6-only VPCs: you get a real production-like IPv6-only environment for testing. Spin up test instances in IPv6-only subnets as part of your staging pipeline. The DNS64 resolver and NAT64 gateway handle connectivity to IPv4-only dependencies, exactly as a real IPv6 transition deployment would.

GCP

GCP supports IPv6-only VMs using dual-stack subnets where you simply do not assign an IPv4 address. GCP's internal DNS resolves to IPv6 addresses for Google services. For reaching external IPv4-only services from IPv6-only VMs, you need to configure a NAT64 gateway manually or use Private Google Access which provides IPv6 routes to Google APIs.

# Create a subnet with IPv6 enabled
gcloud compute networks subnets create ipv6-test-subnet \
  --network=test-vpc \
  --region=us-central1 \
  --range=10.0.0.0/24 \
  --stack-type=IPV6_ONLY \
  --ipv6-access-type=EXTERNAL

# Launch a VM with only IPv6
gcloud compute instances create ipv6-test-vm \
  --zone=us-central1-a \
  --subnet=ipv6-test-subnet \
  --stack-type=IPV6_ONLY \
  --image-family=debian-12 \
  --image-project=debian-cloud

Systematic Testing Strategy

A thorough IPv6-only testing strategy covers three layers: static analysis (finding problems in code), runtime testing (verifying behavior), and production monitoring (catching regressions).

Static Analysis: Find IPv4 Assumptions in Code

#!/bin/bash
# ipv6-audit.sh — scan codebase for common IPv4-only patterns

echo "=== Hardcoded IPv4 addresses ==="
grep -rn --include='*.py' --include='*.go' --include='*.java' --include='*.js' \
  --include='*.ts' --include='*.rs' --include='*.c' --include='*.cpp' \
  -E '\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b' src/ \
  | grep -v '0\.0\.0\.0' | grep -v '127\.0\.0\.1' | grep -v 'test' | grep -v '\.md'

echo "=== AF_INET without AF_INET6 ==="
grep -rn 'AF_INET[^6]' src/ --include='*.py' --include='*.c' --include='*.go'

echo "=== Deprecated gethostbyname (does not support IPv6) ==="
grep -rn 'gethostbyname' src/

echo "=== Java IPv4 preference flag ==="
grep -rn 'preferIPv4Stack' . --include='*.java' --include='*.properties' \
  --include='*.xml' --include='*.yml' --include='*.sh'

echo "=== Node.js dns.lookup without family ==="
grep -rn 'dns\.lookup(' src/ --include='*.js' --include='*.ts' | grep -v 'family'

echo "=== Go tcp4/udp4 forced ==="
grep -rn '"tcp4"\|"udp4"' src/ --include='*.go'

Runtime Testing Checklist

Run these tests on your IPv6-only test environment (macOS NAT64, Linux namespace, or cloud IPv6-only VPC):

  1. Application startup: Does the app start without errors? Many apps fail at startup when they try to bind to an IPv4 address or resolve a configuration endpoint.
  2. DNS resolution: Does the app resolve hostnames correctly? Verify that DNS64-synthesized addresses are used for IPv4-only backends.
  3. Outbound connections: Can the app reach all external dependencies (APIs, databases, caches, message queues)?
  4. Inbound connections: Can clients reach the app? Test from an IPv6-only client.
  5. WebSocket/long-lived connections: Do persistent connections survive and reconnect correctly?
  6. Certificate validation: Does TLS certificate validation work? Some apps embed IPv4 addresses in certificate SANs.
  7. Third-party SDKs: Do analytics, crash reporting, and feature flagging SDKs work? These are a common source of silent failures.
  8. File downloads/uploads: Do presigned URLs with IPv4 endpoints work through NAT64?

Production Monitoring

Once deployed, monitor for IPv6-specific issues:

# Track connection failures by address family in your app metrics
# Example: Prometheus metric
ipv6_connection_errors_total{service="api", destination="payment-gateway"}

# Monitor DNS resolution latency (DNS64 synthesis adds latency)
dns_resolution_duration_seconds{type="AAAA"}

Troubleshooting Common Issues

Problem: DNS64 returns the original A record instead of synthesizing a AAAA.
Cause: The DNS64 resolver is not configured correctly, or the target already has a native AAAA record (DNS64 only synthesizes when no AAAA exists).
Debug: dig AAAA example.com @your-dns64-server — verify the response contains 64:ff9b:: prefix addresses.

Problem: Connections to NAT64-translated addresses time out.
Cause: The NAT64 gateway is not routing correctly, or firewall rules block the 64:ff9b::/96 prefix.
Debug: traceroute6 64:ff9b::808:808 (equivalent to tracing to 8.8.8.8 via NAT64). Check ip6tables rules on the NAT64 host.

Problem: TLS handshake fails for NAT64-translated connections.
Cause: SNI (Server Name Indication) is not set because the app connected using an IP address literal. Or: the TLS library does not support IPv6 address literals in the host field.
Debug: openssl s_client -connect [64:ff9b::5db8:d822]:443 -servername example.com

Problem: Application works in local NAT64 test but fails on T-Mobile network.
Cause: T-Mobile uses 464XLAT (CLAT on the device + PLAT on the network), not pure NAT64. Some apps detect the CLAT IPv4 address and incorrectly assume they have real IPv4 connectivity.
Debug: Check if the app is using the CLAT v4- interface instead of the native IPv6 interface.

Key Takeaways

IPv6-only testing is not optional. The two most impactful actions you can take today:

  1. Run the static analysis script above against your codebase. Every hardcoded IPv4 literal, every AF_INET without AF_INET6, and every gethostbyname call is a potential failure on IPv6-only networks.
  2. Set up a macOS NAT64 network (takes 2 minutes) and test your app. If it fails, you have found real bugs that affect real users today on T-Mobile, Jio, and other IPv6-only carriers.

The underlying principle is simple: never assume IPv4 connectivity. Use hostnames instead of IP addresses. Use AF_UNSPEC instead of AF_INET. Use getaddrinfo instead of gethostbyname. Let the operating system and DNS choose the right address family. These are not just IPv6 best practices — they are correct network programming practices that have been documented for over two decades.

For a deeper understanding of the translation mechanisms your test environment relies on, see How NAT64 Works, How 464XLAT Works, and the comprehensive IPv6 Transition Technologies overview. For fundamentals, see IPv4 vs IPv6 and What Is an IP Address.

You can look up the IPv6 prefixes and BGP routes for any network using the BGP Looking Glass tool — enter an IPv6 address or prefix to see its origin AS, upstream providers, and routing details.

See BGP routing data in real time

Open Looking Glass
More Articles
What is DNS? The Internet's Phone Book
What is an IP Address?
IPv4 vs IPv6: What's the Difference?
What is a Network Prefix (CIDR)?
How Does Traceroute Work?
What is a CDN? Content Delivery Networks Explained