A port that was free yesterday refuses to bind today, a server that works on 127.0.0.1 is invisible on localhost from one tool, a Windows Security Alert pops up for an app that only you will ever call, and a container's database is reachable from the whole Wi-Fi. All four come from the same three mechanisms: the port ranges Windows reserves, the two addresses behind localhost, and the firewall's prompt. This part reads all three on this PC without changing any of them, runs a template web app on a dozen ports to see what happens, and crosses into WSL and Docker in both directions. Every output is quoted; the LAN address is written as 203.0.113.10.

Steps in this part
  1. Get-NetTCPConnection -State Listen joined to Get-Process: every listener with its program name
  2. netsh interface ipv4 show excludedportrange protocol=tcp and show dynamicport tcp
  3. dotnet new web -n PortLab3, then dotnet run --urls http://localhost:57250 (inside a listed range), :15930, and a second copy on 15930
  4. --urls http://127.0.0.1:15932, then curl.exe -v http://localhost:15932/ and http://[::1]:15932/
  5. Get-NetFirewallProfile with and without -PolicyStore ActiveStore; Get-NetFirewallRule for the rules a prompt left; the firewall event log, ids 2097 and 2099
  6. A throwaway WSL distribution running python3 -m http.server 15933 --bind 127.0.0.1, curled from Windows; then the reverse from inside WSL
  7. docker run -d --rm -p 127.0.0.1:15935:5432 postgres:18, and which process holds the port on Windows

Who listens, and on which address

netstat -ano shows a process id per socket; the PowerShell form joins the name, and on this PC it listed 58 listening sockets. The developer-relevant rows, with the third-party desktop tools left out:

PortAddressProcessWhat it means
54320.0.0.0 and ::postgresThe native PostgreSQL 18 service, on every interface. A Docker PostgreSQL must publish another host port.
11434127.0.0.1ollamaThe Windows Ollama app, loopback only.
53040127.0.0.1 and ::1sqlservrSQL Server Express on a dynamic port, loopback only. Nothing listens on 1433.
nonesqlservrLocalDB, the second sqlservr process: no TCP socket at all, a named pipe instead.
80, 445, 139all, and one per adapterSystemWindows itself: HTTP.sys, SMB, NetBIOS.
15840, 15850 to 158520.0.0.0 and ::com.docker.backendPublished ports of containers that had already stopped: Docker Desktop holds them on the host.

The address column is the one to read. 0.0.0.0 and :: mean every interface, including the Wi-Fi one; 127.0.0.1 and ::1 mean this PC only. Part 4 picks its container port from this table.

The ranges Windows keeps for itself

PS> netsh interface ipv4 show excludedportrange protocol=tcp

Protocol tcp Port Exclusion Ranges

Start Port    End Port
----------    --------
        80          80
     50000       50059     *
     57219       57318
     57424       57523
     57562       57661
     57819       57918
     57919       58018
     58019       58118
     58119       58218
     58231       58330

* - Administered port exclusions.

Eight ranges of exactly a hundred ports, all inside the dynamic range, which netsh interface ipv4 show dynamicport tcp gave as Start Port : 49152 and Number of Ports : 16384. An earlier day's notes on this PC listed a different set, so they move. Microsoft's pages do not say in words which component reserves them; after reading the container-networking, WSL networking and troubleshooting pages, the honest statement is that netsh shows them and nothing else does. What matters is what they do to a bind. The template web app on http://localhost:57250, inside the first range, with no other process anywhere near the port:

System.IO.IOException: Failed to bind to address http://localhost:57250.
System.Net.Sockets.SocketException (10013): An attempt was made to access a socket in a way forbidden by its access permissions.

The process exited with the unhandled-exception code, and the exception holds the 10013 twice, once for 127.0.0.1 and once for ::1. Compare the genuinely busy port, a second copy of the app on 15930 while the first was serving:

System.IO.IOException: Failed to bind to address http://127.0.0.1:15930: address already in use.
System.Net.Sockets.SocketException (10048): Only one usage of each socket address (protocol/network address/port) is normally permitted.

10013 "forbidden by its access permissions" is a reserved range; 10048 "address already in use" is another process. Microsoft's page on error 10013 gives the workaround as a port outside the dynamic range, and the row marked with a star behaves differently: a bind on 50030, inside the administered range, served HTTP/1.1 200 OK, which matches the old knowledge base note that an administered exclusion only keeps a port out of random assignment. The rule for a dev PC needs no source: pick ports below 49152, and this series uses 15xxx. Docker's form of the same refusal, "ports are not available", has a Fixes post.

localhost is two addresses

--urls http://localhost:15931 made Kestrel open two sockets, 127.0.0.1:15931 and ::1:15931, and all three spellings of the URL answered 200. --urls http://127.0.0.1:15932 opened one, and http://[::1]:15932/ failed:

curl: (7) Failed to connect to ::1:15932 after 2030 ms: Could not connect to server

http://localhost:15932/ still worked, and curl -v shows the detour:

* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:15932...
*   Trying 127.0.0.1:15932...
* Established connection to localhost (127.0.0.1 port 15932) from 127.0.0.1 port 49678

curl tries ::1 first and falls back. A client that does not fall back, or a tool that resolves localhost to ::1 only, sees a refused connection; one that does fall back pays the wait, and on this PC every refused loopback connection took about two seconds to report, measured five times and documented nowhere this part found. The cheap fix is on the server side: write localhost in --urls and launchSettings.json, and both families are open.

The firewall as it really is

PS> Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, NotifyOnListen | Format-Table

Name    Enabled DefaultInboundAction DefaultOutboundAction NotifyOnListen
----    ------- -------------------- --------------------- --------------
Domain     True        NotConfigured         NotConfigured           True
Private    True        NotConfigured         NotConfigured           True
Public     True        NotConfigured         NotConfigured           True

"NotConfigured" reads as if nothing were enforced. It is the persistent store's way of saying "the default"; the same query with -PolicyStore ActiveStore showed the effective values, inbound Block and outbound Allow on all three profiles, which is what the documentation describes: "block all incoming traffic, unless solicited or matching a rule" and "allow all outgoing traffic, unless matching a rule". This PC's one network is on the Public profile, "the default profile for unidentified networks". NotifyOnListen True is the prompt. Two programs had met it earlier the same day:

DisplayName Direction Action Profile Enabled
----------- --------- ------ ------- -------
ollama.exe    Inbound  Allow  Public    True
ollama.exe    Inbound  Allow  Public    True
portlab.exe   Inbound  Allow  Public    True
portlab.exe   Inbound  Allow  Public    True

One TCP rule and one UDP rule each, named "TCP Query User" plus a GUID and the exe's full path, any local port, any remote address, Public profile only. The event log shows the prompt as a sequence: at 10:09:32 two 2097 events, "A rule has been added", both with Action: Block and the firewall service as the modifying application; three seconds later six 2099 events, "A rule has been modified", with Action: Allow and the signed-in user's account. That is what clicking Allow does: it flips two block rules that already existed into allow rules for every port of that exe, on the profile that was active, and the documentation adds that once the rules exist "they must be deleted to generate the prompt again." The other half of the story is the absence of events. This part ran the app on localhost on five ports, a WSL relay on 127.0.0.1 and a Docker publish on 127.0.0.1, all with NotifyOnListen True, and the log recorded nothing and no rule names PortLab3. No Microsoft page fetched says so in words; observed on this PC, a server bound to loopback never prompts, and the two programs that did prompt had listened beyond it.

WSL, in both directions

A throwaway distribution, installed as in Part 2, ran Python's built-in server on 127.0.0.1:15933. The first attempt to start it in the background from one wsl call left nothing behind:

ls: cannot access '/tmp/http15933.log': No such file or directory
no python3 process

bash returned at once and the session died before the child opened its log file; the same command with & sleep 1 at the end kept it alive, listed by ss -ltnp as python3 on 127.0.0.1:15933. From Windows, http://localhost:15933/ and http://127.0.0.1:15933/ answered HTTP/1.0 200 OK with Server: SimpleHTTP/0.6 Python/3.12.3, and [::1] did not. On the Windows side the port belongs to a relay:

Address   Port Process
-------   ---- -------
127.0.0.1 15933 wslrelay

The relay mirrors the Linux socket's family, so a Linux server on 127.0.0.1 is 127.0.0.1 on Windows too. The documentation calls this accessing Linux apps from Windows "using localhost (just like you normally would)". The reverse is not symmetric. With the web app on localhost:15934 on Windows, from inside the distribution:

curl: (7) Failed to connect to localhost port 15934 after 0 ms: Couldn't connect to server

Inside WSL, localhost is the Linux VM's own loopback. The documented route is the host's address, from ip route: default via 172.24.48.1 dev eth0, which is the Windows side of the vEthernet (WSL (Hyper-V firewall)) adapter. That address timed out after ten seconds rather than refusing, because the app listened on loopback only and nothing answered on the adapter. Making it work needs what the documentation says: "you may need to bind your application to 0.0.0.0 instead of 127.0.0.1", with its own warning, "Keep security in mind when making these changes as this will allow connections from your LAN." That is the bind that triggers the prompt above, and this series does not do it on the host; the mirrored networking mode, which the page recommends and which was not switched on here, lets both sides use 127.0.0.1.

Docker's share of the ports

A throwaway PostgreSQL container published with an address, -p 127.0.0.1:15935:5432, appeared on Windows as one socket:

Address   Port  Process
-------   ----  -------
127.0.0.1 15935 com.docker.backend

And that program already has its rules: two Docker Desktop Backend entries, Inbound, Allow, Public. Put the two facts together with the first table, where an unaddressed -p 5432:5432 sat on 0.0.0.0 and ::, and the conclusion is that a plain -p is reachable from the LAN with no prompt at all, because the prompt belongs to the backend and was answered when Docker Desktop was installed. This was not tested from a second machine; the listener address and the rule are the evidence. 127.0.0.1: in front of the host port keeps a development database on this PC, and the Docker series uses that form throughout.

Three habits: dev ports below 49152, localhost rather than 127.0.0.1 in --urls, and never 0.0.0.0 on the host or an unaddressed -p in Docker unless the LAN is meant to reach it. The Windows Security Alert then never appears, and the ports table stays readable.

Frequently asked

Why does Kestrel fail to bind with error 10013 on a port nothing is using?
The port is inside a range Windows has excluded; netsh interface ipv4 show excludedportrange protocol=tcp lists them. On this PC eight ranges of a hundred ports sat inside the dynamic range 49152 to 65535 and moved between days. A genuinely busy port gives 10048, address already in use, instead. Pick a port below 49152.
Why does my app answer on 127.0.0.1 but not on localhost?
Because localhost resolves to ::1 first on Windows and the app listens on IPv4 only. Binding --urls to http://localhost:port opens both 127.0.0.1 and ::1; a literal 127.0.0.1 opens one socket, and clients that try ::1 first either fall back after a delay, about two seconds here, or fail. Write localhost in --urls and launchSettings.json.
Why does the Windows Security Alert appear for my .NET app, and what does Allow do?
The firewall's NotifyOnListen setting prompts when a program listens beyond loopback on a profile with no rule for it. Allow turns the two block rules just created, TCP and UDP, into allow rules for every port of that exe on the active profile; they stay until deleted. A server bound to localhost produced no prompt and no event in this test.

Next: Part 4, databases on a dev PC: LocalDB, SQL Server Express and PostgreSQL in Docker, which one a connection string reaches, and the three errors that cost an afternoon.