Loopback addresses let a process communicate through its local IP stack without sending the exchange over a physical network link. IPv4 reserves 127.0.0.0/8 for loopback; 127.0.0.1 is the familiar member. IPv6 uses ::1. On Linux, the relevant local networking context is a network namespace. Two processes on the same physical machine can therefore have different answers to what counts as their own loopback service.
When it breaks
A client inside a container often fails when configured with 127.0.0.1 for a database running on the host or in another container. The client has asked for its own namespace’s listener. The spelling is correct but the topology is wrong. A published host port, a container interface address, and another process’s loopback are different destinations that must be selected according to the deployment.
Loopback is not a guarantee that every probe succeeds. The target port still needs a listener, the intended address family must be available, and local networking policy can affect delivery. A successful IPv4 request does not prove that a service also listens on ::1. Conversely, a refused connection can simply mean there is no listener at that address and port in the caller’s namespace.
Local delivery also does not eliminate the IP stack. Loopback traffic still uses sockets, protocol processing, and local routing decisions; it merely avoids a physical trip through the external network. Timing a request to loopback cannot validate switch configuration, remote routing, an internet firewall, or the public service name’s certificate. Keep those paths separate when interpreting a health check.
Check it quickly
Save this as check-loopback.py and run python3 check-loopback.py with Docker. It creates two disposable containers using the same pinned Python 3.11.16 image, each with Docker networking disabled and no host port publication. Container A starts IPv4 and IPv6 listeners on port 19091. Container B initially runs without a service on that port:
import json,subprocess,time,uuid
image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
names=['xbcatm-loop-'+uuid.uuid4().hex[:10] for _ in range(2)]
server=r'''
import json,os,selectors,socket,sys
selector=selectors.DefaultSelector()
for family,address in [(socket.AF_INET,'127.0.0.1'),(socket.AF_INET6,'::1')]:
listener=socket.socket(family,socket.SOCK_STREAM)
if family==socket.AF_INET6:listener.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
listener.bind((address,19091));listener.listen(8);selector.register(listener,selectors.EVENT_READ)
print('ready',flush=True)
while True:
for key,events in selector.select():
connection,peer=key.fileobj.accept()
with connection:
connection.settimeout(1)
connection.sendall(json.dumps({'service':sys.argv[1],'netns':os.readlink('/proc/self/ns/net')}).encode())
'''
probe=r'''
import json,os,socket
answers={}
for address in ['127.0.0.1','::1']:
try:
with socket.create_connection((address,19091),timeout=1) as stream:
pieces=[]
while True:
piece=stream.recv(1024)
if not piece:break
pieces.append(piece)
answers[address]=json.loads(b''.join(pieces))
except ConnectionRefusedError:answers[address]={'error':'ECONNREFUSED'}
print(json.dumps({'netns':os.readlink('/proc/self/ns/net'),'interfaces':socket.if_nameindex(),'answers':answers}))
'''
def docker(*args):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=30)
assert result.returncode==0,result.stderr
return result.stdout
def inspect(name):return json.loads(docker('exec',name,'python3','-c',probe))
def wait_for(name,identity):
deadline=time.monotonic()+5
while time.monotonic()<deadline:
result=inspect(name)
if all(answer.get('service')==identity for answer in result['answers'].values()):return result
time.sleep(.05)
raise AssertionError('listener did not become ready')
try:
for name,command in [(names[0],['python3','-u','-c',server,'A']),
(names[1],['python3','-c','import time;time.sleep(120)'])]:
docker('run','-d','--rm','--name',name,'--network','none','--memory','128m','--pids-limit','64',image,*command)
a=wait_for(names[0],'A');b_before=inspect(names[1])
assert a['netns']!=b_before['netns']
assert all(v=={'error':'ECONNREFUSED'} for v in b_before['answers'].values())
docker('exec','-d',names[1],'python3','-u','-c',server,'B')
b_after=wait_for(names[1],'B');a_after=inspect(names[0])
assert all(v.get('service')=='A' for v in a_after['answers'].values())
for result in [a,b_after,a_after]:
assert all(answer['netns']==result['netns'] for answer in result['answers'].values())
assert all(any(interface[1]=='lo' for interface in result['interfaces']) for result in [a,b_before,b_after])
print(json.dumps({'A':a,'B_before_listener':b_before,'B_after_listener':b_after,'A_still_A':a_after},sort_keys=True))
finally:
for name in names:subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)
A probe executed inside A reaches both 127.0.0.1 and ::1 and reads the service identity A. Each response also carries the server’s network namespace identifier. The probe’s own identifier matches it. This links the successful application response to the networking context in which the client ran, rather than assuming that all local-looking addresses on the host share a listener.
The same probe inside B receives ECONNREFUSED for both addresses, even while A continues serving. B’s namespace identifier differs from A’s. The program uses literal addresses, so DNS search rules and a host’s interpretation of localhost cannot explain the difference. The failure comes from probing B’s local endpoints, where no matching service has yet been started.
The script next starts a second server inside B using the identical address strings and port. B now receives identity B through both families, while a new probe inside A still receives A. Both servers coexist without a port conflict. The test directly demonstrates namespace-specific loopback and socket port spaces, not merely two configuration files containing different container names.
The interface inventory is included for context, but the result does not assume that lo is the only interface name. This kernel also creates inactive tunnel devices in fresh namespaces. Their presence does not change which loopback listener answered. The assertions depend on distinct namespace identities and actual IPv4/IPv6 exchanges, rather than a fragile expected count of interface names.
Put probes in the context they claim to test
Run an application-local probe in the application’s namespace when the question is whether its listener responds. Run a separate probe from a representative client network when the question is whether users can reach the public endpoint. An internal success and an external failure can both be correct observations because the requests traverse different routing, forwarding, and authentication paths.
Processes sharing a network namespace also share loopback, even if they otherwise have separate process or filesystem environments. Host networking and intentionally shared container networking change the boundary. Before replacing localhost in a connection string, identify where the client executes and which namespace owns the server. Avoid treating container names or machine ownership as substitutes for that information.
A loopback listener can also be exposed indirectly by a proxy or tunnel that forwards requests to it. Local address scope is therefore different from user authorization. Authenticate the service as its contract requires, including requests originating from another local process. Use loopback to express the intended local transport path, and verify the complete forwarding chain when remote access is deliberately provided.
Sources
- RFC 1122 Internet host requirements, the reserved IPv4 loopback address block, checked 2026-09-12.
- RFC 4291 IPv6 addressing, the IPv6 loopback address and its scope, checked 2026-09-12.
- Linux network_namespaces(7), isolation of interfaces, routes and socket port numbers, checked 2026-09-12.
- Linux ip(7), local IPv4 delivery and socket addressing, checked 2026-09-12.