Mert Özen Further With Every Line
Computer Science Fundamentals How Networking and HTTP Work — #02

IP Addresses, Subnets, Ports and NAT: localhost, 0.0.0.0 and Connectivity in Containers

Mert Özen Nov 8, 2026 11 min 4 views
IP Addresses, Subnets, Ports and NAT: localhost, 0.0.0.0 and Connectivity in Containers

An IP address finds an interface, a port finds a socket on that machine; a server bound to 127.0.0.1 can only be reached from inside that machine. RFC 1918 private addresses, subnets with CIDR, NAT, ephemeral ports, address already in use, and why a Kestrel bound to localhost in a container returns an Empty reply, with real output.

Version note: The code and commands in this post were run on macOS with .NET 10 (ASP.NET Core 10 Kestrel), Docker and the mcr.microsoft.com/dotnet/aspnet:10.0 image; tests were run with xUnit v3 4.0.2.

Guess First

You put the Bookstore API (Application Programming Interface) into a Docker container. The container's log says everything is fine:

Now listening on: http://127.0.0.1:5491

You started the container with docker run -p 127.0.0.1:5594:5491 ..., forwarding port 5594 on the host to 5491 in the container. Now you run curl http://127.0.0.1:5594/api/books on the host. What happens? Do you get the book list, "connection refused", a timeout, or something else? The app runs inside the container, the port forwarding is right, the log is clean. Where could the problem be?

This post gathers enough about addresses and ports to answer that: IP (Internet Protocol) addresses, subnets, routing, NAT (Network Address Translation), ports and what it really means for a server to "bind" to an address.

IP Addresses: IPv4, IPv6 and Special Addresses

An IP address is a network interface's identity on the network. IPv4 addresses are 32 bits and written as four numbers like 192.168.8.9; that's about 4.3 billion addresses, a number that stopped being enough long ago. IPv6 addresses are 128 bits and written in hexadecimal groups like fe80::1. The Wi-Fi interface and the loopback interface of the machine I'm writing this on:

$ ifconfig en0 | grep "inet "
	inet 192.168.8.9 netmask 0xffffff00 broadcast 192.168.8.255
$ ifconfig lo0 | grep inet
	inet 127.0.0.1 netmask 0xff000000
	inet6 ::1 prefixlen 128
	inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1

Not all addresses are the same kind. Some blocks are reserved for special purposes, and code often needs to tell them apart: a rate limiter may want to exempt requests from the internal network, a webhook validation may want to reject requests going to private addresses. A small classifier with System.Net.IPNetwork, which arrived in .NET 8:

public static class AddressTools
{
    // RFC 1918 private IPv4 blocks
    private static readonly IPNetwork[] PrivateV4 =
    [
        IPNetwork.Parse("10.0.0.0/8"),
        IPNetwork.Parse("172.16.0.0/12"),
        IPNetwork.Parse("192.168.0.0/16"),
    ];

    public static string Classify(IPAddress address) => address switch
    {
        _ when IPAddress.IsLoopback(address) => "loopback",
        { AddressFamily: AddressFamily.InterNetwork } when PrivateV4.Any(n => n.Contains(address)) => "private (RFC 1918)",
        { AddressFamily: AddressFamily.InterNetwork } when IPNetwork.Parse("169.254.0.0/16").Contains(address) => "link-local",
        { AddressFamily: AddressFamily.InterNetworkV6, IsIPv6LinkLocal: true } => "link-local",
        { AddressFamily: AddressFamily.InterNetworkV6 } when IPNetwork.Parse("fc00::/7").Contains(address) => "unique local (IPv6)",
        _ when address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) => "unspecified (all interfaces)",
        _ => "public",
    };
}
$ dotnet Bookstore.Net.Lab.dll classify 127.0.0.1 ::1 192.168.8.23 172.17.0.2 10.0.0.5 0.0.0.0 203.0.113.10 fe80::1 169.254.10.1
127.0.0.1        loopback
::1              loopback
192.168.8.23     private (RFC 1918)
172.17.0.2       private (RFC 1918)
10.0.0.5         private (RFC 1918)
0.0.0.0          unspecified (all interfaces)
203.0.113.10     public
fe80::1          link-local
169.254.10.1     link-local

RFC (Request for Comments) 1918 reserves three IPv4 blocks for private use: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. These addresses aren't routed on the internet; they're the internal addresses of your home router, your office network and Docker (172.17.0.2 is a typical container address from Docker's default bridge network). Loopback (127.0.0.0/8, and ::1 in IPv6) never lets packets leave the machine. Link-local addresses (169.254.0.0/16, fe80::/10) are only valid on the same physical segment. My classifier called 203.0.113.10 "public"; it's actually a block RFC 5737 reserves for documentation, which is why this series uses it instead of a real address. These cases and edge cases such as the 172.31.255.1 / 172.32.0.1 boundary are verified in an xUnit test.

Subnets and CIDR: What /24 Means

The netmask 0xffffff00 in the ifconfig output says the first 24 bits of the address identify the network and the last 8 bits identify the device on that network. In CIDR (Classless Inter-Domain Routing) notation we write this as 192.168.8.0/24: "the first 24 bits are fixed." An 8-bit host part means 256 addresses; take away the network address (.0) and the broadcast address (.255) and 254 usable addresses remain.

$ dotnet Bookstore.Net.Lab.dll subnet 192.168.8.0/24 192.168.8.23 192.168.9.23
network 192.168.8.0/24, usable hosts 254
  192.168.8.23    inside: True
  192.168.9.23    inside: False

Subnet information lets the machine decide where to send a packet. If the destination is on the same subnet, the packet goes straight to that device. If not, it's handed to the default gateway, the router:

$ route -n get default
    gateway: 192.168.8.1
  interface: en0

The router in turn checks its own routing table and hands the packet to the next router; the internet is a chain of routers that each know only the next step. You can see that chain with traceroute (tracert on Windows): it starts the packets' TTL (Time To Live) field at 1, increases it at every step and learns the next link of the path from each router's "time exceeded" reply. I'm not sharing the output here because it exposes my home network's and my provider's addresses; on your own machine traceroute -m 5 1.1.1.1 shows the first five hops.

In production you'll write CIDR most in cloud network design and security rules: 10.0.0.0/16 for a VNet or VPC (Virtual Private Cloud), 10.0.1.0/24 for the application subnet, "only traffic from 10.0.0.0/8" in a firewall rule. Picking subnets too small up front leads to address exhaustion quickly in environments like Kubernetes, which give every pod an IP.

NAT: Why Your Home Machine Can't Be Reached from Outside

My machine's address is 192.168.8.9, and that address isn't routed on the internet. So how do I get out? My router does NAT: it replaces the source address and port of every outgoing packet with its own public address and a port it picks, keeps that mapping in a table and reverses it when the reply comes back. The server outside sees the router's public address, not my machine.

That table has a consequence: mappings are only created for connections started from the inside. If someone outside tries to open a connection to the router's public address, the router doesn't know which inside machine the packet is for and drops it. That's why a Kestrel running on your home machine can't be reached from the internet. To get around it you either set up port forwarding on the router (binding a fixed outside port to an inside address and port) or use a tunnel service. Many mobile operators and providers also run a second NAT layer in their own network (CGNAT, Carrier-Grade NAT); in that case even port forwarding doesn't help.

Docker's -p option is a local version of the same idea. Containers live on their own private network; -p 127.0.0.1:5594:5491 adds a rule that forwards connections arriving at the host's port 5594 to the container's port 5491. That rule is the key to the guessing question.

Ports: Well-Known, Ephemeral and "Address Already in Use"

An IP address finds a machine; a port finds an application on that machine (more precisely, a socket). TCP (Transmission Control Protocol) and UDP (User Datagram Protocol) connections are identified by four values: source IP, source port, destination IP, destination port. IANA (Internet Assigned Numbers Authority) splits the port range in three: 0–1023 are well-known ports (80 HTTP (Hypertext Transfer Protocol), 443 HTTPS (HTTP Secure), 22 SSH (Secure Shell), 53 DNS (Domain Name System)), 1024–49151 registered ports and 49152–65535 dynamic, i.e. ephemeral, ports. When a client opens a connection, the operating system picks the source port from the ephemeral range. On macOS that range is:

$ sysctl net.inet.ip.portrange.first net.inet.ip.portrange.last
net.inet.ip.portrange.first: 49152
net.inet.ip.portrange.last: 65535

The from ::1 port 62043 you saw in the curl output of the previous post is exactly a source port picked from this range. On Linux the default range is 32768–60999 (/proc/sys/net/ipv4/ip_local_port_range); running out of it is the socket exhaustion that's the topic of the next post.

On the server side only one socket can listen on a given port at a given address at a time. Starting a second copy of Kestrel while the first is running:

$ lsof -nP -iTCP:5491 -sTCP:LISTEN
COMMAND   PID     USER   FD   TYPE            DEVICE SIZE/OFF NODE NAME
dotnet  93444 mertozen  195u  IPv4 0xb4b57746e21ed2e      0t0  TCP 127.0.0.1:5491 (LISTEN)

$ dotnet Bookstore.Net.Server.dll
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.IO.IOException: Failed to bind to address http://127.0.0.1:5491: address already in use.
       ---> Microsoft.AspNetCore.Connections.AddressInUseException: Address already in use
       ---> System.Net.Sockets.SocketException (48): Address already in use

When you see "Address already in use", the first job is to find who holds the port. lsof -nP -iTCP:5491 -sTCP:LISTEN on macOS and Linux, ss -tlnp on Linux too, netstat -ano | findstr :5491 on Windows, then find the process from the PID (process ID). Usually the culprit is an old debug session that never closed or a dotnet watch left running in the background.

localhost, 127.0.0.1, ::1 and 0.0.0.0

These four are often used interchangeably but they're not the same thing. localhost is a name; how it resolves depends on the operating system. 127.0.0.1 and ::1 are loopback addresses. 0.0.0.0 isn't an address at all but a special value that means "all interfaces" when binding (:: in IPv6). Let's see how .NET resolves localhost:

$ dotnet Bookstore.Net.Lab.dll resolve localhost
localhost -> ::1, 127.0.0.1

IPv6 comes first. With my server listening only on 127.0.0.1, I sent a request to localhost with HttpClient:

$ dotnet Bookstore.Net.Lab.dll connect http://localhost:5491/conn
OK in 20 ms: {"id":"0HNPTFV2DKJ2E","remotePort":62345,"protocol":"HTTP/1.1"}
$ dotnet Bookstore.Net.Lab.dll connect http://localhost:5599/conn
FAILED in 16 ms: Connection refused (localhost:5599) (ConnectionRefused)

The request succeeded. On loopback the ::1 attempt came back instantly with "connection refused", so the client moved straight on to the next address, 127.0.0.1; we saw that attempt line by line in the previous post's curl output. On a real network the same situation ends differently. If a domain has both A (IPv4 address) and AAAA (IPv6 address) records and the IPv6 path is silently dropped by a firewall, the first attempt isn't refused; the client waits until the operating system's connect timeout expires, which can take tens of seconds. Some clients, such as curl and browsers, try both address families in parallel with a short delay using Happy Eyeballs (RFC 8305); not every client does. Don't assume your client's behavior; measure it.

On the server side, the bind address decides which interfaces connections are accepted from. Binding Kestrel to 127.0.0.1 means "only from inside this machine"; binding to 0.0.0.0 means "from all of this machine's interfaces." On your development machine 127.0.0.1 is a safe default: the coffee shop's Wi-Fi network can't see your API. In a container it's the other way round.

"It Runs but I Can't Reach It" in Docker

I actually ran the scenario from the guessing section. I bound Kestrel in the container to 127.0.0.1 and sent a request from the host:

$ docker run -d --name network-kestrel-lo -p 127.0.0.1:5594:5491 -e BIND_ADDRESS=127.0.0.1 ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet /app/Bookstore.Net.Server.dll
$ docker logs network-kestrel-lo
      Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
      Now listening on: http://127.0.0.1:5491
$ curl -sS -m 5 http://127.0.0.1:5594/api/books
curl: (52) Empty reply from server

Neither "connection refused" nor a timeout: the connection is established and then closed right away. On the host side Docker's port forwarder listens on 5594 and accepts the connection; then it tries to pass it on to 5491 on the container's network interface. But 127.0.0.1 inside the container is the container's own loopback. Kestrel listens only there, and nobody accepts the connection arriving at the container's outward-facing interface. The container's socket table confirms it:

$ docker exec network-kestrel-lo sh -c 'cat /proc/net/tcp | head -3'
  sl  local_address rem_address   st ...
   0: 0100007F:1575 00000000:0000 0A ...
   1: 0100007F:1574 00000000:0000 0A ...

0100007F is 127.0.0.1 with its bytes reversed; 1574 and 1575 are 5492 and 5493 in hexadecimal; state 0A is LISTEN. The fix is to bind to 0.0.0.0 in the container. Running the same image with BIND_ADDRESS=0.0.0.0, every curl request in the previous post succeeded.

The first line of the log is an important hint too. Microsoft's .NET container images define the ASPNETCORE_HTTP_PORTS environment variable:

$ docker run --rm mcr.microsoft.com/dotnet/aspnet:10.0 env | grep -E "ASPNETCORE|DOTNET_RUNNING"
ASPNETCORE_HTTP_PORTS=8080
DOTNET_RUNNING_IN_CONTAINER=true

Since .NET 8, the images listen on 8080 on all interfaces (http://*:8080) by default; it used to be 80. Because I gave the endpoints in code with Listen, Kestrel overrode that setting and printed a warning. If you don't specify anything in code, your app listens on 8080 in the container, on the right interface, and -p 5594:8080 is enough. The most common mistake is carrying a localhost URL (Uniform Resource Locator) from code or launchSettings.json into the container.

In Production

Set the bind address per environment. 127.0.0.1 in development, 0.0.0.0 or http://+:8080 in a container. Reading it from ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS or configuration instead of hard-coding it lets the same image behave correctly everywhere. The full set of options is on Microsoft Learn's "Configure endpoints for the ASP.NET Core Kestrel web server" page.

Read the real client IP from the right place. Behind NAT, a load balancer or a reverse proxy, HttpContext.Connection.RemoteIpAddress shows the last hop, not the client; the real address arrives in the X-Forwarded-For header and in ASP.NET Core is read with the Forwarded Headers middleware, accepted only from proxies you trust. Believing an X-Forwarded-For from a source you don't trust is the classic way to bypass IP-based restrictions.

Be careful with requests to private addresses. Any feature that makes a server-side request to a URL supplied by the user (webhooks, "upload image from URL") can be pointed at internal addresses such as 10.x or 169.254.169.254; that's the SSRF (Server-Side Request Forgery) vulnerability. A classifier like the one above is the first step in checking the resolved address before sending the request; the details are in the Web Security series.

Common Pitfalls

Binding to localhost in a container. The log looks clean, the container stays up, and no request from outside ever arrives; curl says "Empty reply from server" or "connection reset".

Assuming localhost is IPv4 only. The name may resolve to ::1 first; if the server listens only on IPv4, every connection starts with a failed attempt, and in the bad case waits for a timeout.

Using 0.0.0.0 as a destination address. For binding, 0.0.0.0 means "all interfaces"; using it as a destination on the client side is undefined behavior.

Picking a subnet that's too small. A /24 means 254 addresses; in a cluster that gives every pod an address, that runs out in a few nodes.

Changing the port and moving on after "Address already in use". The old process holding the port is often a server that's still running and answering with the wrong version; find it and shut it down.

A Small Experiment

★☆☆ Find your address and netmask with ifconfig (ipconfig on Windows) and write them in CIDR notation. How many usable addresses does your network have?

★★☆ Add the 100.64.0.0/10 block RFC 6598 reserves for CGNAT and RFC 5737's documentation blocks to AddressTools.Classify; extend the xUnit tests.

★★★ Run an ASP.NET Core app in a container without specifying any endpoint and look at /proc/net/tcp6 with docker exec. Which address and port is Kestrel listening on? What changes when you pass ASPNETCORE_HTTP_PORTS=5000?

We found the address and the port. Next is the connection to that address itself. In the next post we look at TCP's 3-way handshake, congestion control and how ephemeral ports run out when you use HttpClient the wrong way.