A server’s bind address selects which local destination addresses its socket can accept. Binding an IPv4 listener to 127.0.0.1 differs from binding it to 0.0.0.0, the wildcard address. Neither setting alone describes the complete access policy. Routing, network namespaces, packet filtering, port publication, proxies, and application authentication determine how a request reaches the listener and what it may do afterward.

When it breaks

A wildcard listener is not proof that the internet can reach a service. It may run inside an isolated namespace without an external interface or have a restrictive firewall. Conversely, a loopback listener can be reached indirectly through a reverse proxy or tunnel that accepts remote requests. Investigate those forwarding paths before describing the service as accessible only to a particular group of people.

The IPv6 wildcard :: adds another choice. On Linux, IPV6_V6ONLY controls whether an IPv6 socket also accepts IPv4 connections as mapped addresses. Defaults depend on the environment and application. Seeing one IPv6 entry in a listener listing therefore does not establish that IPv4 is unavailable. Set the option deliberately when the service contract requires a particular family boundary, then test each family.

Binding to a specific address can also fail during startup. An address already occupied by a conflicting socket produces a different error from an address not assigned locally. Reuse options and nonlocal-binding facilities alter some cases, but they are not enabled here. Treating every bind failure as a busy port can send an operator toward killing an unrelated process when the actual fault is missing address configuration.

Check it quickly

Save the script as check-bind-address.py and run python3 check-bind-address.py with Docker. The pinned Python 3.11.16 image runs Linux sockets inside a disposable container with no published ports. The experiment compares two IPv4 loopback addresses, then explicitly changes IPV6_V6ONLY. It does not connect to a public host or change the host firewall:

import json,subprocess,uuid
image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84"
program=r"""
from contextlib import ExitStack
import errno,json,socket

def read_exact(stream,length):
    result=b''
    while len(result)<length:
        part=stream.recv(length-len(result));assert part,'unexpected end of stream'
        result+=part
    return result
def listener(stack,address,family=socket.AF_INET,v6only=None):
    stream=stack.enter_context(socket.socket(family,socket.SOCK_STREAM))
    if v6only is not None: stream.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,v6only)
    stream.settimeout(1);stream.bind((address,0));stream.listen(4)
    return stream
def exchange(server,address):
    with socket.create_connection((address,server.getsockname()[1]),timeout=1) as client:
        connection,peer=server.accept()
        with connection:
            connection.settimeout(1);client.sendall(b'ping');assert read_exact(connection,4)==b'ping'
            connection.sendall(b'pong');assert read_exact(client,4)==b'pong'
            return {'listener':server.getsockname(),'accepted_local':connection.getsockname(),'peer':peer}
def refused(server,address):
    try:
        with socket.create_connection((address,server.getsockname()[1]),timeout=1):pass
    except ConnectionRefusedError:return 'ECONNREFUSED'
    raise AssertionError('unexpectedly connected')
with ExitStack() as stack:
    narrow=listener(stack,'127.0.0.1')
    one=exchange(narrow,'127.0.0.1');other=refused(narrow,'127.0.0.2')
    duplicate=stack.enter_context(socket.socket())
    try:duplicate.bind(narrow.getsockname());raise AssertionError('duplicate bind succeeded')
    except OSError as error:assert error.errno==errno.EADDRINUSE
    unassigned=stack.enter_context(socket.socket())
    try:unassigned.bind(('192.0.2.99',0));raise AssertionError('unassigned bind succeeded')
    except OSError as error:assert error.errno==errno.EADDRNOTAVAIL
    wildcard=listener(stack,'0.0.0.0')
    both=[exchange(wildcard,a) for a in ['127.0.0.1','127.0.0.2']]
    narrow.close();wildcard.close()
    only6=listener(stack,'::',socket.AF_INET6,1)
    six=exchange(only6,'::1');four_rejected=refused(only6,'127.0.0.1')
    dual=listener(stack,'::',socket.AF_INET6,0)
    mapped=exchange(dual,'127.0.0.1');assert mapped['peer'][0].startswith('::ffff:')
    print(json.dumps({'narrow':one,'other_loopback':other,'duplicate':'EADDRINUSE','unassigned':'EADDRNOTAVAIL',
        'wildcard':both,'ipv6_only':six,'ipv6_only_v4':four_rejected,'dual_stack':mapped}))
"""
name='xbcatm-socket-'+uuid.uuid4().hex[:10]
try:
    result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none',
        '--memory','128m','--pids-limit','64',image,'python3','-'],input=program,
        capture_output=True,text=True,timeout=60)
    assert result.returncode==0,result.stderr
    print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True))
finally:
    subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)

The exact listener accepts a request to 127.0.0.1 and returns pong. Connecting to 127.0.0.2 on the same port is refused. Both are loopback destinations, so this comparison isolates address matching without claiming to test external filtering. Each listener binds port zero, allowing the kernel to choose an available port; the program reads that actual port before connecting.

The wildcard listener successfully serves both destinations. Its listening socket still reports 0.0.0.0, while each accepted connection reports the concrete local address used by that client. This difference matters when interpreting diagnostics: a wildcard describes the listener’s matching rule, whereas an accepted connection has a specific local endpoint. The example reads the complete four-byte response instead of assuming that stream reads preserve message boundaries.

Two deliberate setup errors are separately asserted. A second ordinary socket cannot bind the exact listener’s occupied address and port, yielding EADDRINUSE. Binding 192.0.2.99, which is not assigned in this namespace, yields EADDRNOTAVAIL. These negative controls verify the expected causes; an arbitrary exception or timeout would fail the exercise instead of being counted as equivalent evidence.

With IPV6_V6ONLY set to one, the IPv6 wildcard accepts ::1 and refuses IPv4 on the same port. With the option set to zero, a separate IPv6 listener accepts an IPv4 connection and reports ::ffff:127.0.0.1. The observed mapping follows the explicit socket option. It is not a prediction of another operating system’s default or a recommendation to disable IPv6.

Verify the listener and the permitted caller

Record address family, bind address, selected port, process, and network namespace together. A listener inside a container and one on its host can use identical address text without referring to the same socket. Check the application’s effective configuration after startup, because a deployment variable or library default may differ from the configuration file an operator first inspected.

Choose the smallest address scope compatible with legitimate callers, then enforce the required source and application policy. A proxy may need to reach a backend through a container interface rather than its own loopback. Binding the backend narrowly should follow that actual topology. Authentication still matters for local callers and for remote requests forwarded by an authorized intermediary.

Finally, test both permitted and rejected paths from the namespaces that represent real clients. This exercise establishes local binding semantics, duplicate-address errors, and explicitly configured address-family behavior. It does not prove a production firewall, tunnel, or load balancer is configured correctly. Those components must be checked along the real request path before publishing an access guarantee.

Further reading

Sources

  • Linux bind(2), binding errors and local socket addresses, checked 2026-09-12.
  • Linux ip(7), IPv4 wildcard and loopback binding, checked 2026-09-12.
  • Linux IPV6_V6ONLY, explicit IPv6-only and mapped IPv4 behavior, checked 2026-09-12.
  • Python 3.11 socket, socket creation, options and endpoint inspection, checked 2026-09-12.