← Open in the full interactive course (progress tracking, search & more)

Kestrel listening on localhost works perfectly on your laptop — and silently refuses every request an orchestrator ever routes to it. Get this one binding right and most of the rest of containerizing ASP.NET Core falls into place.

Lesson 298 covered the general shape of a .NET Dockerfile — the multi-stage build, the small runtime base image, the layer caching trick. Build that image, run it, and hit it from your browser at localhost:8080, and it works. Deploy the exact same image behind an orchestrator's load balancer, and requests simply time out — nothing reaches the app at all, with no obvious error message pointing at why.

This is one of the single most common first-time container gotchas for ASP.NET Core specifically, and it comes down to one line of network configuration that behaves completely differently inside a container than it does on a developer's machine.

In this lesson, you'll learn exactly why Kestrel has to bind to 0.0.0.0 instead of localhost inside a container, how to configure that correctly, what a .dockerignore file is for, why running as a non-root user matters, and how to put all of it together into one complete, production-ready Dockerfile for an ASP.NET Core Web API.

What Is It?

The Simple Explanation

Containerizing ASP.NET Core well means more than just following the multi-stage Dockerfile pattern from Lesson 298. It means configuring Kestrel — the web server that actually handles HTTP requests for ASP.NET Core — so it's actually reachable from outside the container, keeping the image clean of files it doesn't need, and running the app as securely as the official images make easy.

The Technical Definition

Every container gets its own network namespace — its own private view of network interfaces, isolated from the host's. Inside that namespace, localhost (127.0.0.1) refers only to the container's own loopback interface — traffic that never leaves that one container. Binding a server there means it only accepts connections that originate from within the same container. To accept connections arriving from outside — from the host, from another container, or from an orchestrator's load balancer — Kestrel must bind to 0.0.0.0, meaning "listen on every available network interface," not just the loopback one.

Why Does It Exist?

The Problem — Traffic From Outside the Container Has to Enter Through a Real Interface

When an orchestrator routes a request to a container, that traffic arrives over the container's actual network interface — not over its internal loopback. If Kestrel is only listening on the loopback interface (127.0.0.1), that incoming traffic has nowhere to land: the connection is refused or simply times out, because nothing inside the container is listening on the interface the traffic is actually arriving on. Locally, on a laptop without containers, this distinction is invisible — localhost just means "this machine," and a browser on the same machine reaches it fine. Inside a container, "this machine" (the container's own network namespace) and "where traffic from outside actually enters" are two genuinely different things.

The Solution — Bind to All Interfaces

Telling Kestrel to bind to 0.0.0.0 makes it listen on every network interface available inside the container's namespace — including the one that traffic routed in from outside actually arrives on. This is configured with the ASPNETCORE_URLS environment variable (or the modern ASPNETCORE_HTTP_PORTS shorthand, which implicitly binds to all interfaces on the given port).

Binding to localhost inside a container

Binding to all interfaces

Big Picture

WHERE A REQUEST ACTUALLY TRAVELS
Orchestrator / Load Balancer
Routes an external request toward the container's real network interface
Container's Network Namespace
Has its own real interface, plus its own separate loopback (localhost) interface

The request lands on the container's real interface. Kestrel is only reachable there if it's listening on 0.0.0.0 — a Kestrel bound only to localhost is listening on the loopback interface, which this incoming request never touches.

How It Works — Building a Container-Ready ASP.NET Core Image

STEP BY STEP
1. SET THE BINDING VIA AN ENVIRONMENT VARIABLE
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
2. EXPOSE THE MATCHING PORT (DOCUMENTATION, NOT ENFORCEMENT)
EXPOSE 8080
3. EXCLUDE LOCAL-ONLY FILES FROM THE BUILD CONTEXT WITH .dockerignore
4. RUN AS THE BUILT-IN NON-ROOT USER
USER app

A Typical .dockerignore

bin/ obj/ .git/ .vs/ .vscode/ **/*.user **/appsettings.Development.json

This should look familiar — it directly mirrors the purpose of .gitignore: keep files that only make sense on a local dev machine out of the thing you're packaging. Every file sent to Docker as part of the "build context" gets uploaded to the Docker daemon before the build even starts, so excluding a bulky, irrelevant bin/ or obj/ folder keeps builds noticeably faster, not just tidier.

Simple Example — Diagnosing the Silent Timeout

Here's the exact symptom this lesson is centered on, and the one-line fix:

// Works locally without a container. Inside a container, unreachable from outside. // (This is Kestrel's behavior if ASPNETCORE_URLS isn't explicitly set to bind broadly, // or if it's explicitly set to a localhost-only address.) ASPNETCORE_URLS=http://localhost:8080 // Reachable from the host, from other containers, and from an orchestrator's load balancer ASPNETCORE_URLS=http://0.0.0.0:8080 // Equivalent, modern shorthand for HTTP-only container scenarios — implicitly binds all interfaces ASPNETCORE_HTTP_PORTS=8080

Meaning: Nothing about the application's routing, controllers, or middleware pipeline changed at all — the entire fix is which network interface Kestrel is told to listen on. This is precisely why the bug is so easy to miss: the app runs, starts up cleanly, logs "Now listening on..." — and still never receives a single request.

Real-World Example

A team containerizes their notification service and deploys it behind an orchestrator's load balancer. Health checks (the ASP.NET Core side of which you already know from Lesson 268) immediately start failing — the orchestrator can't reach the liveness endpoint at all, and eventually kills and restarts the container, which then fails the exact same way again. The application logs show it started successfully and is "listening" — which is exactly what makes this bug so disorienting the first time. The fix is a one-line environment variable change (ASPNETCORE_URLS=http://0.0.0.0:8080, set in the Dockerfile or the orchestrator's container spec), after which the exact same probe that was timing out starts succeeding immediately, with no code change anywhere in the app.

Analogy

An Intercom That Only Hears the Room It's In

Imagine an office intercom that can either be set to "listen for calls from inside this one room only" or "listen for calls arriving at the building's main switchboard." Set it to "this room only" (localhost), and it works perfectly if you're standing in that exact room shouting into it — but a call routed in from reception downstairs (traffic from the host or a load balancer) never reaches it, no matter how loudly reception tries. Set it to "listen at the switchboard" (0.0.0.0), and it picks up calls arriving from anywhere in the building, including the one you're standing in. Kestrel's binding works exactly this way — localhost only ever hears requests that originate inside the very same container.

Under the Hood — Running as a Non-Root User

By default, a process inside a container that isn't otherwise configured typically runs as root — the same all-powerful account a root user has on a normal Linux machine, just scoped to that container's namespace. If an attacker ever manages to exploit a vulnerability in the running app, running as root inside the container hands them more capability within that container's namespace than running as an unprivileged account would.

This used to mean extra Dockerfile work — creating a dedicated user, setting file permissions correctly. Current official Microsoft .NET container images make this close to free: they already ship a built-in, non-root app user, ready to switch to with one instruction:

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app COPY --from=build /app/publish . # Switch to the built-in non-root user before running the app USER app ENTRYPOINT ["dotnet", "OrderApi.dll"]

This is a real, current, low-effort security improvement — not a theoretical best practice requiring you to build user management from scratch. One line, and the running process no longer has root privileges inside the container.

Common Confusion

1. "Binding to 0.0.0.0 exposes the app to the whole internet" — no, that's a separate concern

0.0.0.0 only controls which interfaces inside the container Kestrel listens on. Whether traffic from the public internet, or another network, can actually reach that container at all is governed entirely separately — by docker run -p port publishing, firewall rules, an orchestrator's network policies, and (if present) a reverse proxy or gateway in front of it. Binding broadly inside the container doesn't bypass any of those outer layers.

2. "This is an ASP.NET Core bug" — no, it's the correct, expected behavior of network namespaces

Kestrel is behaving exactly as configured in both cases — the surprise isn't a defect, it's that "localhost" quietly means something narrower inside a container's own network namespace than it does on an un-containerized machine. Understanding that namespace distinction is the actual fix, not a workaround for broken framework behavior.

Common Mistakes

Mistake 1 — Never setting ASPNETCORE_URLS explicitly and assuming the local-dev default is fine everywhere

Relying on whatever binding behavior worked during local development, without confirming it also binds broadly inside the container. Set ASPNETCORE_URLS (or ASPNETCORE_HTTP_PORTS) explicitly in the Dockerfile or the orchestrator's container spec, so the binding is intentional and documented, not inherited by accident.

Mistake 2 — Skipping .dockerignore entirely

Letting a stale local bin/ or obj/ folder ride along into the Docker build context — slower builds, and a real risk of stale, locally-built binaries silently getting copied in ahead of a fresh dotnet publish output. Add a .dockerignore excluding exactly the kind of local-only artifacts .gitignore already excludes.

Mistake 3 — Never switching away from the default root user

Leaving the final container running as root because it "just works" and nobody added the one extra line. Add USER app in the final stage — current official .NET images ship this user specifically so there's no excuse not to.

When Should I Use It?

Rule of thumb: If a containerized ASP.NET Core app starts cleanly, logs that it's listening, and yet every external request times out — check the binding address first. It's the single most common cause of that exact symptom.

Mental Model

localhost inside a container = only this one container's own loopback interface
0.0.0.0 = every interface inside the container, including the one outside traffic actually arrives on
.dockerignore = .gitignore, but for what gets sent to the Docker build
USER app = one line, built-in, non-root — no reason to skip it

Remember: if the app "runs fine but nothing can reach it," check the Kestrel binding before anything else.

Key Takeaway


Check Your Understanding

You've seen the single most common ASP.NET Core containerization gotcha, along with .dockerignore and non-root users. Let's confirm it clicked.

1. A containerized ASP.NET Core app starts up cleanly and logs "Now listening on http://localhost:8080" — but an orchestrator's load balancer can never reach it. What is the most likely cause?

Show answer

Correct: B

Why B is correct: This is the exact, precise gotcha the lesson covers — a localhost binding only accepts connections from inside the same container's network namespace, so externally routed traffic has nothing to land on.

Why A is incorrect: Missing attributes would cause routing/model-binding issues visible in responses, not a total, silent connection timeout at the network level.

Why C is incorrect: A missing .dockerignore affects build speed and image cleanliness, not network reachability.

Why D is incorrect: Running as a non-root user is a security improvement and doesn't affect which network interfaces Kestrel binds to.

Reinforcement: "Starts fine, logs fine, but unreachable" is the signature symptom of a localhost-only binding inside a container.

2. What does binding Kestrel to 0.0.0.0 actually change?

Show answer

Correct: B

Why B is correct: 0.0.0.0 means "all interfaces" — including whichever real interface traffic routed in from outside the container actually arrives on.

Why A is incorrect: Reachability from the actual public internet is a separate, outer concern — controlled by port publishing, firewalls, and orchestrator network policy, none of which this binding bypasses.

Why C is incorrect: The binding address and the protocol/scheme are independent settings.

Why D is incorrect: The port is set separately (e.g. the :8080 part of the URL) — 0.0.0.0 only changes which interfaces are listened on, not which port.

Reinforcement: Binding address and external exposure are two separate layers — don't conflate them.

3. What is the purpose of a .dockerignore file?

Show answer

Correct: B

Why B is correct: It directly mirrors .gitignore's purpose, just scoped to what gets sent into the Docker build context rather than what gets committed to source control.

Why A is incorrect: Package installation is handled by dotnet restore/NuGet inside the Dockerfile's RUN instructions, not by .dockerignore.

Why C is incorrect: That's the Dockerfile's EXPOSE instruction and the runtime's port publishing flags — unrelated to .dockerignore.

Why D is incorrect: NuGet package restrictions aren't something .dockerignore controls at all.

Reinforcement: .dockerignore is about what's excluded from the build context — nothing about runtime behavior.

4. Why can a team add USER app to their Dockerfile without first creating a dedicated user account themselves?

Show answer

Correct: B

Why B is correct: Microsoft's official aspnet/runtime images include this built-in app user specifically to make running non-root low-effort — no custom user setup required.

Why A is incorrect: This is unrelated to application-level authentication — it's about the OS-level privilege the container process runs with.

Why C is incorrect: Docker doesn't create this automatically for arbitrary images — it's specifically something the official .NET images ship, not a generic Docker behavior.

Why D is incorrect: The lesson's scope is the standard Linux-based official .NET images most cloud deployments use; the point stands regardless of speculation about other platforms.

Reinforcement: The official .NET images make non-root a one-line change, not a from-scratch project — there's little excuse to skip it.

You now know exactly why containerized ASP.NET Core apps go unreachable, and how to build a lean, secure image that avoids it. Next: cloud-specific environment variable patterns, building on what you already know from configuration in the Intermediate book.


dotnetmadeeasy.com — Learn C# and .NET, the right way.