IPv6 App Development Guide: Making Applications IPv6-Ready
Most production outages caused by IPv6 are not protocol failures — they are application bugs. A server that binds only to 0.0.0.0, a database column storing IP addresses as INT UNSIGNED, a regex that rejects colons, a rate limiter keyed on a /128 that changes every ten minutes. These are not edge cases. They are the default behavior of code written without considering that IPv6 exists, deployed onto networks where IPv6 is the primary — or only — protocol. As of 2025, over 45% of Google's traffic arrives over IPv6, T-Mobile US runs an IPv6-only network with NAT64 for legacy destinations, and Apple requires IPv6 compatibility for App Store approval. Writing IPv4-only code is writing code that is broken on a large and growing fraction of the internet.
This guide covers the concrete changes needed to make network applications work correctly on IPv6-only, dual-stack, and NAT64 networks — from socket creation to database schemas to load balancer configuration.
Dual-Stack Sockets: AF_INET6 with IPV6_V6ONLY
The most important socket-level concept for IPv6 application development is the dual-stack socket. On most operating systems, an AF_INET6 socket can accept both IPv4 and IPv6 connections when the IPV6_V6ONLY socket option is set to 0. The kernel maps incoming IPv4 connections to IPv4-mapped IPv6 addresses (e.g., ::ffff:192.0.2.1). This means a single socket can serve all clients regardless of protocol version.
# Python: dual-stack server socket
import socket
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
# IPV6_V6ONLY=0 enables dual-stack (accepts IPv4 and IPv6)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('::', 8080)) # :: = all interfaces, both protocols
sock.listen(128)
while True:
conn, addr = sock.accept()
# addr[0] will be "::ffff:192.0.2.1" for IPv4 clients
# addr[0] will be "2001:db8::1" for IPv6 clients
print(f"Connection from {addr[0]} port {addr[1]}")
The behavior of IPV6_V6ONLY defaults varies across operating systems, and this is a constant source of bugs:
- Linux: Defaults to 0 (dual-stack enabled). Controlled by
/proc/sys/net/ipv6/bindv6only. Some distributions change this default. - Windows: Defaults to 0 (dual-stack enabled) since Vista.
- FreeBSD, OpenBSD: Defaults to 1 (IPv6-only). You must explicitly set
IPV6_V6ONLY=0for dual-stack. - macOS: Defaults to 0, but the system-level setting can override this.
Never rely on the default. Always explicitly set IPV6_V6ONLY to the value you need. If you want dual-stack, set it to 0. If you need separate sockets for IPv4 and IPv6 (e.g., for different bind addresses or firewall rules), set it to 1 and create two sockets.
// Go: dual-stack listener using net.Listen
// Go's net package handles dual-stack automatically on Linux.
// On BSD systems, it creates two listeners internally.
ln, err := net.Listen("tcp", ":8080") // listens on all interfaces, both protocols
// If you need explicit control:
lc := net.ListenConfig{}
ln, err := lc.Listen(ctx, "tcp", "[::]:8080")
There is an important caveat on some BSD-derived systems: if you set IPV6_V6ONLY=0 and bind to ::, you cannot simultaneously bind a separate AF_INET socket to 0.0.0.0 on the same port — the dual-stack socket already claims the IPv4 port. On Linux, this conflict does not occur because the kernel handles the mapping differently. If your application must run on both Linux and BSD, test both paths.
Address Resolution: getaddrinfo Done Right
Hard-coding AF_INET or manually constructing sockaddr_in structures is the single most common cause of IPv6 incompatibility. The correct approach is getaddrinfo(), which resolves hostnames to socket addresses in a protocol-independent way. The key flags control how resolution interacts with the host's actual network connectivity:
AI_ADDRCONFIG— Only return IPv6 addresses if the host has at least one non-loopback IPv6 address configured, and likewise for IPv4. This prevents your application from attempting connections over a protocol the host cannot use. It is almost always what you want for outgoing connections.AI_V4MAPPED— If the caller requestsAF_INET6and no AAAA records exist, return IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) instead of failing. This allows an IPv6-only application to reach IPv4-only destinations through a dual-stack socket or NAT64.AI_ALL— Used withAI_V4MAPPED: return both real IPv6 addresses and IPv4-mapped addresses, giving the application the full set of reachable endpoints.
# Python: protocol-independent connection
import socket
def connect_to_host(hostname, port):
"""Connect to a host using the best available protocol."""
hints = socket.getaddrinfo(
hostname, port,
family=socket.AF_UNSPEC, # Accept any protocol
type=socket.SOCK_STREAM,
flags=socket.AI_ADDRCONFIG # Only return usable protocols
)
# Try each address in order (Happy Eyeballs would interleave)
last_err = None
for family, socktype, proto, canonname, sockaddr in hints:
try:
sock = socket.socket(family, socktype, proto)
sock.settimeout(5.0)
sock.connect(sockaddr)
return sock
except OSError as e:
last_err = e
sock.close()
raise last_err
// Node.js: dns.lookup uses getaddrinfo internally
// Since Node 17+, autoSelectFamily enables Happy Eyeballs (RFC 8305)
const net = require('net');
const socket = net.connect({
host: 'example.com',
port: 443,
autoSelectFamily: true, // try IPv6 and IPv4 in parallel
});
A critical pitfall with AI_ADDRCONFIG: on a host that has only a loopback IPv6 address (::1) but no global IPv6 address, AI_ADDRCONFIG will suppress AAAA results. This is correct behavior — the host cannot reach IPv6 destinations — but it surprises developers testing in containers or minimal VMs that lack IPv6 configuration. Inside Docker containers, IPv6 is often disabled by default, so AI_ADDRCONFIG correctly returns only IPv4 results even if the host outside the container is dual-stack.
Happy Eyeballs (RFC 8305)
Even with correct getaddrinfo() usage, naively trying addresses sequentially causes poor user experience when one protocol is broken. If a host has both A and AAAA records but IPv6 connectivity is flaky, a sequential approach waits for the IPv6 connection to time out (often 30+ seconds) before falling back to IPv4. TCP connection timeouts are painful.
RFC 8305 (Happy Eyeballs v2) specifies interleaving connection attempts: start an IPv6 connection, then after 250ms without success, start an IPv4 connection in parallel. Use whichever completes first. Most modern standard libraries and HTTP clients implement this:
- curl —
--happy-eyeballs-timeout-ms(default 200ms since curl 7.59) - Go net.Dialer — Implements Happy Eyeballs by default since Go 1.9
- Node.js —
autoSelectFamily: trueinnet.connect()since Node 18.13 - Python —
socket.create_connection()iterates sequentially (no Happy Eyeballs). Useasyncio.open_connection()with thehappy_eyeballs_delayparameter (Python 3.12+). - Apple platforms —
Network.framework(NWConnection) implements Happy Eyeballs natively and prefers it over raw sockets.
Storing IP Addresses: Never Use a 32-bit Integer
Storing IPv4 addresses as INT UNSIGNED or uint32 is a pattern that appears in countless codebases and works until the first IPv6 client connects. An IPv6 address is 128 bits — four times the size. You cannot fix this with a bigger integer column; you need a proper representation.
Database Schemas
PostgreSQL provides the best native support with its INET and CIDR types, which store both IPv4 and IPv6 addresses efficiently and support index-based lookups:
-- PostgreSQL: proper IP address storage
CREATE TABLE access_log (
id BIGSERIAL PRIMARY KEY,
client_ip INET NOT NULL, -- stores IPv4 or IPv6
subnet CIDR, -- network prefix with mask
created_at TIMESTAMPTZ DEFAULT now()
);
-- Index for fast lookups
CREATE INDEX idx_access_log_ip ON access_log USING gist (client_ip inet_ops);
-- Query: find all requests from a /48
SELECT * FROM access_log
WHERE client_ip << '2001:db8:abcd::/48'::cidr;
-- Query: check if IP is in a known range
SELECT * FROM access_log
WHERE client_ip <<= '192.0.2.0/24'::cidr
OR client_ip <<= '2001:db8::/32'::cidr;
MySQL/MariaDB lack native IP types. The best approach is VARBINARY(16) with INET6_ATON() / INET6_NTOA():
-- MySQL: use VARBINARY(16) for both IPv4 and IPv6
CREATE TABLE access_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
client_ip VARBINARY(16) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip (client_ip)
);
-- Insert: INET6_ATON handles both IPv4 and IPv6
INSERT INTO access_log (client_ip)
VALUES (INET6_ATON('2001:db8::1')),
(INET6_ATON('192.0.2.50'));
-- Query
SELECT INET6_NTOA(client_ip) AS ip FROM access_log;
Do not use VARCHAR(45) for IP addresses. While it technically fits the longest IPv6 representation, it wastes space (45 bytes + overhead vs. 16 bytes for VARBINARY), produces inconsistent results due to multiple valid text representations of the same IPv6 address (::1 vs. 0:0:0:0:0:0:0:1), and makes range queries impossible without conversion functions. If you must use a text column, always canonicalize addresses before storage using RFC 5952 rules.
In-Memory Representation
In application code, always use sockaddr_storage (C/C++) or your language's equivalent to hold addresses of either family:
// C: sockaddr_storage holds any address family
#include <sys/socket.h>
#include <netinet/in.h>
struct sockaddr_storage client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
// Determine family after accept
if (client_addr.ss_family == AF_INET6) {
struct sockaddr_in6 *addr6 = (struct sockaddr_in6 *)&client_addr;
// Check for IPv4-mapped address
if (IN6_IS_ADDR_V4MAPPED(&addr6->sin6_addr)) {
// This is actually an IPv4 client on a dual-stack socket
}
} else if (client_addr.ss_family == AF_INET) {
struct sockaddr_in *addr4 = (struct sockaddr_in *)&client_addr;
}
// Go: net.IP handles both transparently
import "net"
func handleConn(conn net.Conn) {
addr := conn.RemoteAddr().(*net.TCPAddr)
ip := addr.IP
// net.IP is a byte slice: 4 bytes for IPv4, 16 bytes for IPv6
if ip.To4() != nil {
// IPv4 (or IPv4-mapped IPv6)
} else {
// Native IPv6
}
}
URL Formatting: Brackets and RFC 5952
IPv6 addresses contain colons, which collide with the port separator in URLs and socket addresses. RFC 3986 specifies that IPv6 literals in URIs must be enclosed in brackets: http://[2001:db8::1]:8080/path. Forgetting the brackets is a common source of parsing failures.
Rules for IPv6 in URLs and configuration:
- URLs:
http://[::1]:8080/— brackets required around the address, port follows the closing bracket - Socket addresses in code: Go uses
"[::1]:8080"; Python'ssocket.getaddrinfo()does not want brackets (pass host and port separately) - Configuration files: Most web servers accept
[::]:80or:::80— check your server's documentation - SSH:
ssh user@2001:db8::1works without brackets, butscp user@[2001:db8::1]:/pathrequires them
RFC 5952 defines a canonical text representation for IPv6 addresses. When you log, store, or display IPv6 addresses, always canonicalize them to avoid comparing two representations of the same address and finding them "different":
- Leading zeros in each group must be suppressed:
2001:0db8:0000:0000:0000:0000:0000:0001becomes2001:db8::1 - The longest run of consecutive all-zero groups must be compressed with
::. If there are ties, compress the first run. - Hex digits must be lowercase:
2001:DB8::1is non-canonical
# Python: canonicalize IPv6 addresses
import ipaddress
# All of these are the same address:
addrs = [
"2001:0DB8:0000:0000:0000:0000:0000:0001",
"2001:db8:0:0:0:0:0:1",
"2001:db8::1",
"2001:0db8::0001",
]
for a in addrs:
canonical = str(ipaddress.ip_address(a))
print(canonical) # All print: 2001:db8::1
Regex Patterns for IP Addresses
Matching IPv6 addresses with regex is notoriously difficult because of the compression rules (the :: shorthand can appear anywhere and represents a variable number of zero groups). Do not write your own IPv6 regex. Use your language's standard library to parse and validate addresses, and use regex only for quick extraction from unstructured text where you accept some false positives:
# Python: validate with ipaddress module, not regex
import ipaddress
def is_valid_ip(s):
"""Returns True for any valid IPv4 or IPv6 address."""
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
# For extracting IPs from log lines (rough pattern, not a validator):
import re
# This catches most IPv6 addresses in text; not a strict validator
IPV6_ROUGH = r'[0-9a-fA-F:]{2,39}'
IPV4_PATTERN = r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'
IP_PATTERN = rf'(?:{IPV6_ROUGH}|{IPV4_PATTERN})'
# Better: extract candidates with regex, then validate with ipaddress
for match in re.finditer(IP_PATTERN, log_line):
candidate = match.group()
try:
addr = ipaddress.ip_address(candidate)
# addr is a valid IPv4Address or IPv6Address
except ValueError:
pass # Not a valid IP, skip
If you are parsing structured formats (JSON, HTTP headers, log formats you control), do not use regex at all. Parse the structure first, then validate the IP field with your language's IP address library.
Logging IP Addresses
Log IPv6 addresses in their full canonical (RFC 5952) form. Never truncate them. An IPv6 address can be up to 39 characters, and your log format, log parsing pipeline, and dashboards all need to handle this. Common mistakes:
- Fixed-width log columns that truncate at 15 characters (the max length of an IPv4 address)
- Log parsers that split on colons — an IPv6 address like
2001:db8::1contains colons that will be misinterpreted as delimiters - Structured logging libraries that serialize IP addresses inconsistently — some will log the same address as
::ffff:192.0.2.1in one place and192.0.2.1in another
For dual-stack sockets, when you receive a connection from an IPv4 client, the peer address will be an IPv4-mapped IPv6 address (::ffff:192.0.2.1). Decide on a consistent policy: either always log the raw address (including the ::ffff: prefix) or always strip the mapping and log the underlying IPv4 address. Do not mix both in the same log stream — it makes correlation impossible.
# Python: consistent IP logging for dual-stack sockets
import ipaddress
def normalize_peer_address(raw_addr: str) -> str:
"""Normalize a peer address for consistent logging.
Strips ::ffff: prefix from IPv4-mapped IPv6 addresses
so that 192.0.2.1 always appears the same in logs
regardless of whether the client connected over IPv4 or IPv6.
"""
try:
addr = ipaddress.ip_address(raw_addr)
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
return str(addr.ipv4_mapped) # Returns "192.0.2.1"
return str(addr) # Canonical RFC 5952 form
except ValueError:
return raw_addr # Return as-is if unparseable
Rate Limiting and Geolocation with IPv6
Rate limiting by IP address breaks immediately on IPv6 if you key on individual /128 addresses. IPv6 clients routinely use multiple addresses from the same prefix due to privacy extensions (RFC 8981), where the interface identifier (lower 64 bits) is randomized and rotated every few minutes. A single user can generate dozens of different source addresses in an hour. Additionally, IPv6 allocations are large: a typical residential user gets a /56 or /64, and an organization may have a /48.
Recommended rate limiting strategy:
# Python: IPv6-aware rate limiting
import ipaddress
from collections import defaultdict
class RateLimiter:
"""Rate limiter that aggregates IPv6 by /64 prefix."""
def __init__(self, limit: int, window_seconds: int):
self.limit = limit
self.window = window_seconds
self.counters = defaultdict(list) # key -> [timestamps]
def _get_key(self, ip_str: str) -> str:
addr = ipaddress.ip_address(ip_str)
if isinstance(addr, ipaddress.IPv6Address):
if addr.ipv4_mapped:
# IPv4-mapped: rate limit per IPv4 /32
return str(addr.ipv4_mapped)
# IPv6: aggregate to /64
network = ipaddress.ip_network(
f"{addr}/64", strict=False
)
return str(network.network_address)
else:
# IPv4: rate limit per individual address
return str(addr)
def is_allowed(self, ip_str: str) -> bool:
key = self._get_key(ip_str)
# ... standard sliding window logic on key ...
Geolocation databases face the same issue. MaxMind's GeoIP2 and similar databases include IPv6 ranges, but the granularity is often /48 or /32 for IPv6 versus /24 for IPv4. Accuracy for IPv6 geolocation is generally lower because allocation records are less mature and privacy extensions obscure the specific endpoint. Do not assume the same geolocation precision you get with IPv4.
IPv6 Privacy Extensions and Rotating Addresses
RFC 8981 (formerly RFC 4941) defines temporary addresses that are generated by randomizing the 64-bit interface identifier. Most modern operating systems enable this by default:
- Windows: Enabled by default since Vista. Temporary addresses rotate every 24 hours by default.
- macOS/iOS: Enabled by default. Temporary addresses are preferred for outgoing connections.
- Linux: Controlled by
/proc/sys/net/ipv6/conf/*/use_tempaddr. Value 2 means prefer temporary addresses for outgoing connections. - Android: Enabled by default since Android 4.0.
This means any system that tracks users by IP address — session binding, abuse detection, audit logging — must account for address rotation. A user's IPv6 address may change mid-session. Session management must use cookies or tokens, not source IP. Audit trails must be correlatable by /64 prefix or by authenticated identity, not by individual address.
Framework Default Bind Behavior
Modern web frameworks differ significantly in their default listening behavior. Knowing the default matters because it determines whether your application is reachable over IPv6 out of the box:
Node.js / Express.js
// Express defaults to all interfaces, dual-stack on Linux
const app = require('express')();
app.listen(3000); // Binds to 0.0.0.0 — IPv4 only!
// For dual-stack:
app.listen(3000, '::'); // Binds to [::] — IPv4 and IPv6 on Linux
// Or explicitly dual-stack in Node 18+:
const server = app.listen(3000, '::', () => {
console.log('Listening on [::]:3000 (dual-stack)');
});
Express defaults to 0.0.0.0 (IPv4 only). You must explicitly specify :: to get IPv6 support. This is probably the most common source of "works on my machine but not on IPv6-only networks" bugs in Node.js applications.
Python / Flask / Gunicorn
# Flask development server: IPv4-only by default
app.run(host='0.0.0.0', port=5000) # IPv4 only
app.run(host='::', port=5000) # IPv6 + IPv4 (dual-stack on Linux)
# Gunicorn: specify bind address
# gunicorn -b [::]:8000 app:app # dual-stack
# gunicorn -b 0.0.0.0:8000 -b [::]:8000 app:app # explicit both
Java / Spring Boot
# application.properties: Spring Boot binds to 0.0.0.0 by default
# For dual-stack, set the JVM flag:
# java -Djava.net.preferIPv6Addresses=true -jar app.jar
# Or in application.properties:
server.address=::
server.port=8080
Java's behavior depends on JVM flags. By default, Java prefers IPv4 on dual-stack systems. Set -Djava.net.preferIPv6Stack=true to use IPv6 sockets, or -Djava.net.preferIPv6Addresses=true to prefer IPv6 addresses in DNS resolution while keeping dual-stack sockets.
ASP.NET / Kestrel
// ASP.NET Kestrel: IPAddress.IPv6Any creates a dual-stack socket
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.Listen(IPAddress.IPv6Any, 5000); // [::]:5000 dual-stack
// or:
options.ListenAnyIP(5000); // Binds both IPv4 and IPv6
});
Kestrel's ListenAnyIP() creates both IPv4 and IPv6 listeners automatically, which is the safest default for cross-platform deployment.
Load Balancers and Reverse Proxies
If your application sits behind a reverse proxy (and most production applications do), the proxy must be configured for IPv6 on both the frontend (client-facing) and backend (upstream) sides. The most critical detail is preserving the client's real IP address in a header, since the proxy terminates the TCP connection.
Nginx
# nginx.conf: dual-stack frontend with IPv6 upstream
server {
listen 80;
listen [::]:80; # IPv6 — separate listen directive required
listen 443 ssl;
listen [::]:443 ssl;
# Pass real client IP to backend
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr; # may be IPv6
location / {
# Upstream can be IPv6 too
proxy_pass http://[::1]:8080;
}
}
HAProxy
# haproxy.cfg: dual-stack bind
frontend http_front
bind :80 # IPv4 + IPv6 on Linux (dual-stack)
bind :::80 v4v6 # Explicit: v4v6 keyword for dual-stack
option forwardfor # X-Forwarded-For header (IPv4 or IPv6)
backend app_servers
server app1 [::1]:8080 # IPv6 backend
server app2 192.0.2.10:8080 # IPv4 backend
When your application parses X-Forwarded-For or X-Real-IP, it must handle IPv6 addresses. These headers may contain bracketed addresses ([2001:db8::1]) or bare addresses (2001:db8::1) depending on the proxy. Parse defensively: strip brackets if present, then validate with your language's IP address library.
Kubernetes Dual-Stack Services
Kubernetes supports dual-stack networking since v1.23 (stable). A dual-stack Service gets both a ClusterIP (IPv4) and a ClusterIP (IPv6), and external LoadBalancer services can expose both protocols. Configuration requires that the cluster was deployed with dual-stack enabled (both an IPv4 and IPv6 pod CIDR and service CIDR).
# Kubernetes: dual-stack Service
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
type: LoadBalancer
ipFamilyPolicy: PreferDualStack # or RequireDualStack
ipFamilies:
- IPv6
- IPv4 # order = preference
selector:
app: my-app
ports:
- port: 80
targetPort: 8080
The ipFamilyPolicy field controls behavior:
SingleStack— Service gets one ClusterIP (default). Uses the cluster's default IP family.PreferDualStack— Gets both ClusterIPs if the cluster supports it; falls back to single-stack if not.RequireDualStack— Fails if the cluster does not support dual-stack.
The ipFamilies array determines which address family is primary. Pod-to-pod communication within a dual-stack cluster uses the pod's IP directly, and pods on dual-stack clusters receive both an IPv4 and IPv6 address. The CNI plugin (Calico, Cilium, etc.) must support dual-stack — not all do.
DNS Considerations
Publishing AAAA records for your service is the final piece of making it reachable over IPv6. Without AAAA records in DNS, no IPv6 client can connect — even if your server is fully dual-stack. Key considerations:
- Always publish both A and AAAA records if your server is dual-stack. Clients use Happy Eyeballs to pick the best protocol.
- Match TTLs between A and AAAA records. Mismatched TTLs cause one record to expire while the other is cached, producing intermittent connectivity issues.
- Do not publish AAAA records if your IPv6 path is broken. A broken AAAA record is worse than no AAAA record. Happy Eyeballs mitigates this with fallback, but the initial connection attempt still adds latency.
- Test AAAA resolution independently. Some DNS resolvers have bugs with AAAA queries. Test with
dig AAAA your.domainfrom multiple vantage points. - CDN integration: If you use Cloudflare, AWS CloudFront, or similar CDNs, they publish AAAA records automatically and handle IPv6 termination. Your origin can remain IPv4-only.
Health Checks and Monitoring
Health check endpoints must be reachable over both IPv4 and IPv6 if your service advertises both. A load balancer that health-checks only over IPv4 will continue routing IPv6 traffic to a backend whose IPv6 stack is broken. Configure separate health checks for each protocol, or ensure the health check uses the same path as client traffic.
Monitor IPv6 traffic ratios. If your service normally sees 30% IPv6 traffic and that suddenly drops to zero, something is broken — likely a DNS issue (AAAA records disappeared) or a network change that broke IPv6 routing. Alert on significant deviations in the IPv4/IPv6 traffic ratio, not just on absolute metrics.
# Prometheus: track connections by IP version
from prometheus_client import Counter
import ipaddress
ipv4_connections = Counter('connections_ipv4_total', 'IPv4 connections')
ipv6_connections = Counter('connections_ipv6_total', 'IPv6 connections')
def track_connection(remote_addr: str):
addr = ipaddress.ip_address(remote_addr)
if isinstance(addr, ipaddress.IPv6Address):
if addr.ipv4_mapped:
ipv4_connections.inc()
else:
ipv6_connections.inc()
else:
ipv4_connections.inc()
Configuration Files: Bind Addresses and ACLs
Configuration formats must accommodate IPv6 addresses without ambiguity. The colon problem reappears in configuration files that use colon as a key-value separator, and in ACL rules that need to specify both addresses and prefixes.
# Example: YAML configuration that handles both protocols
server:
bind:
- address: "::" # dual-stack
port: 8080
- address: "::1" # loopback only (IPv6)
port: 9090
acl:
allow:
- "10.0.0.0/8" # IPv4 private
- "172.16.0.0/12" # IPv4 private
- "192.168.0.0/16" # IPv4 private
- "fc00::/7" # IPv6 ULA (unique local)
- "2001:db8:abcd::/48" # specific IPv6 allocation
deny:
- "0.0.0.0/0" # deny all other IPv4
- "::/0" # deny all other IPv6
When implementing ACL matching, always use proper CIDR matching — never string prefix matching. The string 2001:db8:: is a prefix of 2001:db8::1 as text, but 2001:db8:1:: is also within the 2001:db8::/32 network despite not starting with the same string. Use ipaddress.ip_network and the in operator:
# Correct CIDR matching
import ipaddress
allowed_networks = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("2001:db8:abcd::/48"),
]
def is_allowed(ip_str: str) -> bool:
addr = ipaddress.ip_address(ip_str)
return any(addr in net for net in allowed_networks)
Testing on IPv6-Only Networks
The hardest bugs to catch are those that only appear on IPv6-only networks. On a dual-stack network, your application may silently fall back to IPv4 for every connection, masking IPv6 bugs entirely. Mobile carriers increasingly deploy IPv6-only with NAT64 and 464XLAT for IPv4 compatibility, so these environments are not hypothetical. Set up a test environment that forces IPv6:
- macOS: System Preferences > Sharing > Internet Sharing creates a local NAT64/DNS64 network. Connect your test device to this network to simulate an IPv6-only environment with NAT64 for IPv4 destinations.
- Linux: Create a network namespace with only IPv6 addresses and a NAT64 gateway (Jool or Tayga). Run your application inside the namespace.
- Docker: Run a container with IPv6-only networking (
--networkwith an IPv6-only bridge, or disable IPv4 with sysctl). - Cloud: AWS, GCP, and Azure all support IPv6-only VPCs/subnets. Spin up a test instance with no IPv4 address and run your integration tests against it.
Automated CI/CD pipelines should include IPv6-only test runs. GitHub Actions runners are dual-stack, so you can create an IPv6-only container within the runner to execute tests.
Common Pitfalls Reference
A summary of the most frequent IPv6 application bugs, each of which has been observed in production systems:
- Binding to
0.0.0.0instead of::— The application is unreachable over IPv6. On IPv6-only networks, no clients can connect. - Storing IPs as
INTorVARCHAR(15)— IPv6 addresses are 128 bits (39 characters in text). The column silently truncates or rejects them. - Splitting on colons in log parsing — IPv6 addresses contain colons. Log parsers break.
- Rate limiting per /128 — Privacy extensions let users rotate addresses, bypassing the limit entirely.
- Hardcoded
AF_INET/sockaddr_in— The code cannot create IPv6 connections. Usegetaddrinfo()withAF_UNSPEC. - Missing brackets in URLs —
http://::1:8080/is an invalid URL. Correct:http://[::1]:8080/ - Regex-only IP validation — IPv6 regex is complex enough that hand-written patterns miss valid forms or accept invalid ones. Use the standard library.
- No AAAA record in DNS — The server supports IPv6, but no client discovers it because the DNS record is missing.
- X-Forwarded-For parsing fails on colons — The proxy passes
2001:db8::1in the header, and the application chokes on the colons. - Health checks only over IPv4 — IPv6 routing breaks, but the load balancer keeps sending IPv6 traffic because it only checks IPv4.
- Mismatched A/AAAA TTLs — One record expires while the other is cached. Clients intermittently fail on one protocol.
- Java's default IPv4 preference — The JVM uses IPv4 unless
-Djava.net.preferIPv6Stack=trueis set.
Each of these is fixable with straightforward code changes. The difficulty is not technical — it is ensuring that IPv6 is tested as a first-class path, not an afterthought. Use the BGP looking glass to verify that your network's IPv6 prefixes are visible in the global routing table, and check your deployment against each item in this list. The transition to IPv6 is well underway, and applications that do not handle it correctly will fail for a growing fraction of their users.