Summary
When binding to an IPv6 address, uvicorn behaves differently depending on whether it runs in single-worker or multi-worker mode:
-
Multi-worker (bind_socket() in config.py): Creates the socket directly, sets SO_REUSEADDR but does not set IPV6_V6ONLY. On Linux (where /proc/sys/net/ipv6/bindv6only defaults to 0), this inherits dual-stack behavior — the socket accepts both IPv4 and IPv6 connections.
-
Single-worker (default): Delegates to asyncio.loop.create_server(host=..., port=...), which unconditionally sets IPV6_V6ONLY=True on IPv6 sockets (cpython source). This makes the socket IPv6-only.
This means a server started with --host '::' silently drops IPv4 connectivity when running with a single worker, but accepts IPv4 when running with multiple workers.
Steps to Reproduce
# Single worker (default) — IPv4 fails
uvicorn myapp:app --host '::' --port 8080
curl http://127.0.0.1:8080/ # connection refused
curl http://[::1]:8080/ # works
# Multiple workers — IPv4 works
uvicorn myapp:app --host '::' --port 8080 --workers 2
curl http://127.0.0.1:8080/ # works
curl http://[::1]:8080/ # works
Root Cause
In config.py, bind_socket() (lines 538+) creates the socket manually:
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# IPV6_V6ONLY is never set → inherits system default (0 on Linux = dual-stack)
In server.py, the single-worker path (lines 170-178) delegates to asyncio:
server = await loop.create_server(
create_protocol,
host=config.host,
port=config.port,
...
)
asyncio's create_server always does:
if af == socket.AF_INET6:
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, True)
Impact
This is particularly problematic in rootless container environments using pasta (Podman's default rootless networking). Pasta does L4 socket translation — it creates separate IPv4 and IPv6 connections inside the container namespace. If the server only listens on IPv6, pasta's IPv4 forwarding has nowhere to connect and the connection is refused.
Suggested Fix
Explicitly set IPV6_V6ONLY in bind_socket() to make the behavior consistent, and consider adding a configuration option (e.g., --ipv6-v6only / ipv6_v6only in Config) so users can control dual-stack behavior regardless of worker count.
Alternatively, when the user specifies an IPv6 host in single-worker mode, uvicorn could create and bind the socket itself (like bind_socket() does) and pass it via loop.create_server(sock=sock), bypassing asyncio's socket option override.
Workaround
Create a pre-bound socket with IPV6_V6ONLY=0 and pass it via Server.serve(sockets=[sock]):
import socket
import uvicorn
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
sock.bind(("::", 8080))
config = uvicorn.Config(app, lifespan="on")
server = uvicorn.Server(config)
await server.serve(sockets=[sock])
Environment
- uvicorn 0.34.3
- Python 3.13
- Linux (Fedora 43,
/proc/sys/net/ipv6/bindv6only = 0)
- Rootless Podman 5.5.2 with pasta networking
Summary
When binding to an IPv6 address, uvicorn behaves differently depending on whether it runs in single-worker or multi-worker mode:
Multi-worker (
bind_socket()inconfig.py): Creates the socket directly, setsSO_REUSEADDRbut does not setIPV6_V6ONLY. On Linux (where/proc/sys/net/ipv6/bindv6onlydefaults to0), this inherits dual-stack behavior — the socket accepts both IPv4 and IPv6 connections.Single-worker (default): Delegates to
asyncio.loop.create_server(host=..., port=...), which unconditionally setsIPV6_V6ONLY=Trueon IPv6 sockets (cpython source). This makes the socket IPv6-only.This means a server started with
--host '::'silently drops IPv4 connectivity when running with a single worker, but accepts IPv4 when running with multiple workers.Steps to Reproduce
Root Cause
In
config.py,bind_socket()(lines 538+) creates the socket manually:In
server.py, the single-worker path (lines 170-178) delegates to asyncio:asyncio's
create_serveralways does:Impact
This is particularly problematic in rootless container environments using pasta (Podman's default rootless networking). Pasta does L4 socket translation — it creates separate IPv4 and IPv6 connections inside the container namespace. If the server only listens on IPv6, pasta's IPv4 forwarding has nowhere to connect and the connection is refused.
Suggested Fix
Explicitly set
IPV6_V6ONLYinbind_socket()to make the behavior consistent, and consider adding a configuration option (e.g.,--ipv6-v6only/ipv6_v6onlyinConfig) so users can control dual-stack behavior regardless of worker count.Alternatively, when the user specifies an IPv6 host in single-worker mode, uvicorn could create and bind the socket itself (like
bind_socket()does) and pass it vialoop.create_server(sock=sock), bypassing asyncio's socket option override.Workaround
Create a pre-bound socket with
IPV6_V6ONLY=0and pass it viaServer.serve(sockets=[sock]):Environment
/proc/sys/net/ipv6/bindv6only= 0)