Lesson 13 of 13 · Level 6 — Practical projects
Project: .NET front end + back end (Linux & Windows)
Containerise a two-tier .NET app: an ASP.NET Core front end and an ASP.NET Core API in separate containers, built with multi-stage Dockerfiles for Linux and for Windows Nano Server, with bash and PowerShell helpers that build, run and expose it.
Download the project (zip)Linux + Windows Dockerfiles, .NET code, compose files, app.sh + app.ps1
What you'll build
A Tasks app: add tasks, mark them done. Two ASP.NET Core (.NET 10, the current long-term-support release) apps, each in its own container:
| Front end | Back end | |
|---|---|---|
| What | ASP.NET Core app that renders HTML on the server and calls the API | ASP.NET Core minimal API |
| Linux image | aspnet:10.0 (Debian), non-root app user |
same |
| Windows image | aspnet:10.0-nanoserver-ltsc2022, ContainerUser |
same |
| Port inside | 8080 | 8080 |
| Published | Yes (WEB_PORT, default 8081) |
No |
| Data | – | tasks.json on the tasks-data volume |
The Linux variant was built and tested exactly as shown. The Windows files follow the same structure with Microsoft's Nano Server images; build and run them on a Windows host (details below).
dotnet-tasks/
├── app.sh / app.ps1 helpers for Linux (bash) and Windows (PowerShell)
├── compose.yaml Linux containers
├── compose.windows.yaml Windows containers
├── backend/ Program.cs TasksApi.csproj Dockerfile Dockerfile.windows
└── frontend/ Program.cs TasksWeb.csproj Dockerfile Dockerfile.windows
You don't need the .NET SDK installed on your machine: the build happens inside the SDK container.
Step 1: the back end API
dotnet-tasks/backend/Program.cs
// Tasks API: an ASP.NET Core minimal API that keeps tasks in a JSON file on a volume.
// GET /api/health -> {"status":"ok"}
// GET /api/tasks -> all tasks
// POST /api/tasks -> {"title":"..."}
// POST /api/tasks/{id}/done -> mark a task as done
// `dotnet TasksApi.dll --health` is used by the Docker HEALTHCHECK (the runtime image has no curl).
using System.Text.Json;
if (args.Contains("--health"))
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
try { (await http.GetAsync("http://127.0.0.1:8080/api/health")).EnsureSuccessStatusCode(); return 0; }
catch { return 1; }
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<TaskStore>();
var app = builder.Build();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/api/tasks", (TaskStore store) => store.All());
app.MapPost("/api/tasks", (NewTask input, TaskStore store) =>
{
var title = input.Title?.Trim() ?? "";
if (title.Length is 0 or > 200) return Results.BadRequest(new { error = "title is required (max 200 chars)" });
var task = store.Add(title);
return Results.Created($"/api/tasks/{task.Id}", task);
});
app.MapPost("/api/tasks/{id:int}/done", (int id, TaskStore store) =>
store.MarkDone(id) is { } task ? Results.Ok(task) : Results.NotFound());
app.Run();
return 0;
record NewTask(string? Title);
record TaskItem(int Id, string Title, bool Done);
class TaskStore
{
private readonly string _file;
private readonly object _lock = new();
private List<TaskItem> _tasks;
public TaskStore(IConfiguration config)
{
var dir = config["DATA_DIR"] ?? "/data";
Directory.CreateDirectory(dir);
_file = Path.Combine(dir, "tasks.json");
_tasks = File.Exists(_file)
? JsonSerializer.Deserialize<List<TaskItem>>(File.ReadAllText(_file)) ?? new()
: new() { new TaskItem(1, "Containerise the .NET app", true) };
}
public IReadOnlyList<TaskItem> All() { lock (_lock) return _tasks.OrderBy(t => t.Done).ThenByDescending(t => t.Id).ToList(); }
public TaskItem Add(string title)
{
lock (_lock)
{
var task = new TaskItem((_tasks.Count == 0 ? 0 : _tasks.Max(t => t.Id)) + 1, title, false);
_tasks.Add(task);
Save();
return task;
}
}
public TaskItem? MarkDone(int id)
{
lock (_lock)
{
var i = _tasks.FindIndex(t => t.Id == id);
if (i < 0) return null;
_tasks[i] = _tasks[i] with { Done = true };
Save();
return _tasks[i];
}
}
private void Save() => File.WriteAllText(_file, JsonSerializer.Serialize(_tasks));
}
dotnet-tasks/backend/TasksApi.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
Two .NET-specific details make the Dockerfile short and safe:
- .NET 8 and later images listen on port 8080 (
ASPNETCORE_HTTP_PORTS) and ship a non-root user, available as$APP_UID. - The runtime image has no
curl, so the app answers its own health check (--healthinProgram.cs).
dotnet-tasks/backend/Dockerfile
# Back end (Linux container): ASP.NET Core API on port 8080
# Stage 1: restore and publish with the full SDK
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY TasksApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out --no-restore
# Stage 2: only the ASP.NET Core runtime and the published app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV DATA_DIR=/data
# The volume is mounted at /data; the image's non-root "app" user must be able to write there
RUN mkdir -p /data && chown "$APP_UID" /data
COPY --from=build /out .
USER $APP_UID
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 CMD ["dotnet", "TasksApi.dll", "--health"]
ENTRYPOINT ["dotnet", "TasksApi.dll"]
$ docker build -t tasks-backend:1.0 ./backend
$ docker images tasks-backend:1.0 --format '{{.Size}}'
230MB
Step 2: the front end
The front end reads BACKEND_URL (default http://backend:8080), fetches the task list, and builds the page on the server. Titles go through WebUtility.HtmlEncode, so a task called <b>Ship it</b> shows as text, not bold.
dotnet-tasks/frontend/Program.cs
// Tasks web front end: renders HTML on the server and calls the back-end API over the container network.
// BACKEND_URL (default http://backend:8080) is the back-end container's name on the shared network.
using System.Net;
using System.Text;
if (args.Contains("--health"))
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
try { (await http.GetAsync("http://127.0.0.1:8080/healthz")).EnsureSuccessStatusCode(); return 0; }
catch { return 1; }
}
var builder = WebApplication.CreateBuilder(args);
var backendUrl = builder.Configuration["BACKEND_URL"] ?? "http://backend:8080";
builder.Services.AddHttpClient("api", c => { c.BaseAddress = new Uri(backendUrl); c.Timeout = TimeSpan.FromSeconds(5); });
var app = builder.Build();
app.MapGet("/healthz", () => "ok");
app.MapGet("/", async (IHttpClientFactory factory) =>
{
var api = factory.CreateClient("api");
string list, status;
try
{
var tasks = await api.GetFromJsonAsync<List<TaskItem>>("/api/tasks") ?? new();
var sb = new StringBuilder();
foreach (var t in tasks)
{
var title = WebUtility.HtmlEncode(t.Title); // never render user input as raw HTML
sb.Append(t.Done
? $"<li class=\"done\">{title}</li>"
: $"<li>{title}<form method=\"post\" action=\"/done/{t.Id}\"><button>Done</button></form></li>");
}
list = sb.ToString();
status = $"{tasks.Count} task(s) from the back end at {WebUtility.HtmlEncode(backendUrl)}";
}
catch (Exception ex)
{
list = "";
status = $"Back end not reachable: {WebUtility.HtmlEncode(ex.Message)}";
}
return Results.Content(Page(list, status), "text/html; charset=utf-8");
});
app.MapPost("/add", async (HttpContext ctx, IHttpClientFactory factory) =>
{
var form = await ctx.Request.ReadFormAsync();
await factory.CreateClient("api").PostAsJsonAsync("/api/tasks", new { title = form["title"].ToString() });
return Results.Redirect("/");
});
app.MapPost("/done/{id:int}", async (int id, IHttpClientFactory factory) =>
{
await factory.CreateClient("api").PostAsync($"/api/tasks/{id}/done", null);
return Results.Redirect("/");
});
app.Run();
return 0;
static string Page(string list, string status) => $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tasks</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1f2937; }
.muted { color: #6b7280; }
form.add { display: flex; gap: 8px; margin: 24px 0; }
input, button { font: inherit; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; }
input { flex: 1; } button { background: #512bd4; color: #fff; border: 0; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; margin-bottom: 8px; background: #f3f4f6; border-radius: 8px; }
li form { margin: 0; } li button { padding: 4px 10px; font-size: 14px; }
li.done { text-decoration: line-through; color: #9ca3af; }
</style>
</head>
<body>
<h1>Tasks</h1>
<p class="muted">Front end: ASP.NET Core container · Back end: ASP.NET Core API container · Data: JSON file on a volume</p>
<form class="add" method="post" action="/add">
<input name="title" maxlength="200" placeholder="What needs doing?" required>
<button>Add</button>
</form>
<ul>{{list}}</ul>
<p class="muted">{{status}}</p>
</body>
</html>
""";
record TaskItem(int Id, string Title, bool Done);
dotnet-tasks/frontend/Dockerfile
# Front end (Linux container): ASP.NET Core web app on port 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY TasksWeb.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV BACKEND_URL=http://backend:8080
COPY --from=build /out .
USER $APP_UID
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 CMD ["dotnet", "TasksWeb.dll", "--health"]
ENTRYPOINT ["dotnet", "TasksWeb.dll"]
Step 3: run it on Linux
With the helper (plain docker run underneath):
$ ./app.sh run
==> Building tasks-backend:1.0 and tasks-frontend:1.0 (the first build downloads the .NET SDK image, about 1 GB)
==> Starting the back end (no published port: only reachable on tasks-net)
==> tasks-backend is healthy
==> Starting the front end, published on 0.0.0.0:8081
==> tasks-frontend is healthy
Or with Compose:
dotnet-tasks/compose.yaml
# Tasks app (Linux containers): ASP.NET Core front end + ASP.NET Core API + a volume for the data.
# docker compose up -d --build --wait
# On Windows containers use: docker compose -f compose.windows.yaml up -d --build
name: tasks
services:
frontend:
build: ./frontend
image: tasks-frontend:1.0
ports:
- "${BIND_ADDR:-0.0.0.0}:${WEB_PORT:-8081}:8080"
environment:
BACKEND_URL: http://backend:8080
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
backend:
build: ./backend
image: tasks-backend:1.0
volumes:
- tasks-data:/data
restart: unless-stopped
# no ports: only the front end can reach it, over the project network
volumes:
tasks-data:
$ docker compose up -d --build --wait
$ docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
SERVICE STATUS PORTS
backend Up 11 seconds (healthy) 8080/tcp
frontend Up 5 seconds (healthy) 0.0.0.0:8081->8080/tcp
Open http://localhost:8081, add a task, then recreate the back end: the task survives on the volume.
Step 4: run it on Windows containers
Windows containers run Windows processes on a Windows kernel. You need Windows 10/11 Pro or Enterprise with Docker Desktop switched to "Windows containers", or Windows Server with Docker Engine (or Mirantis Container Runtime).
dotnet-tasks/backend/Dockerfile.windows
# escape=`
# Back end (Windows container): same app on Windows Nano Server.
# Build and run on a Windows host with Docker in Windows-containers mode.
# The ltsc2022 tag must be compatible with the host's Windows version (see the lesson).
FROM mcr.microsoft.com/dotnet/sdk:10.0-nanoserver-ltsc2022 AS build
WORKDIR C:\src
COPY TasksApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o C:\out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0-nanoserver-ltsc2022
WORKDIR C:\app
ENV DATA_DIR=C:\data
COPY --from=build C:\out .
# Nano Server images already run as the unprivileged ContainerUser
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=15s --retries=3 CMD ["dotnet", "TasksApi.dll", "--health"]
ENTRYPOINT ["dotnet", "TasksApi.dll"]
The differences from the Linux Dockerfile:
- The first line,
# escape=`, makes the backtick the escape character, so Windows paths likeC:\appwork. - Base images are the Nano Server variants, which already run as the unprivileged
ContainerUser. - Data lives in
C:\data; the volume is mounted there.
dotnet-tasks/compose.windows.yaml
# Tasks app on WINDOWS containers (Docker Desktop in "Windows containers" mode, or Docker on Windows Server).
# docker compose -f compose.windows.yaml up -d --build
# Same app, built from Dockerfile.windows on Nano Server. Network driver on Windows is "nat".
name: tasks-win
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.windows
image: tasks-frontend:1.0-nanoserver
ports:
- "${WEB_PORT:-8081}:8080"
environment:
BACKEND_URL: http://backend:8080
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile.windows
image: tasks-backend:1.0-nanoserver
volumes:
- tasks-data:C:\data
restart: unless-stopped
volumes:
tasks-data:
PS> docker info --format '{{.OSType}}'
windows
PS> .\app.ps1 up
PS> .\app.ps1 test
dotnet-tasks/app.ps1
<#
Helper for the Tasks app on WINDOWS containers (Docker Desktop in "Windows containers" mode,
or Docker Engine on Windows Server). Same commands as app.sh.
.\app.ps1 build build both images from Dockerfile.windows
.\app.ps1 run run with plain `docker run` (nat network + volume + 2 containers)
.\app.ps1 up run with Docker Compose (compose.windows.yaml)
.\app.ps1 expose [-Open] show the URLs for other machines; -Open adds a Windows Firewall rule (run as Administrator)
.\app.ps1 test call the app through the front end
.\app.ps1 status | logs what is running / follow logs
.\app.ps1 down stop and remove the containers (data volume kept)
.\app.ps1 clean also remove the volume, network and images
Setting: $env:WEB_PORT = 8081
If scripts are blocked: powershell -ExecutionPolicy Bypass -File .\app.ps1 up
#>
param(
[Parameter(Position = 0)] [string] $Command = "help",
[Parameter(Position = 1)] [string] $Target = "backend",
[switch] $Open
)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
$WebPort = if ($env:WEB_PORT) { $env:WEB_PORT } else { "8081" }
$Net = "tasks-net"; $Vol = "tasks-data"
$FeImg = "tasks-frontend:1.0-nanoserver"; $BeImg = "tasks-backend:1.0-nanoserver"
function Log($msg) { Write-Host "==> $msg" -ForegroundColor Magenta }
function Assert-WindowsEngine {
$os = docker info --format "{{.OSType}}"
if ($os -ne "windows") {
throw "Docker is in '$os' containers mode. Switch Docker Desktop to Windows containers, or use app.sh / compose.yaml for Linux containers."
}
}
function Build-Images {
Assert-WindowsEngine
Log "Building $BeImg and $FeImg (first build downloads the .NET SDK Nano Server image)"
docker build -f backend\Dockerfile.windows -t $BeImg backend
if ($LASTEXITCODE) { throw "backend build failed" }
docker build -f frontend\Dockerfile.windows -t $FeImg frontend
if ($LASTEXITCODE) { throw "frontend build failed" }
}
function Wait-Healthy($name, $seconds = 120) {
for ($i = 0; $i -lt $seconds; $i++) {
$s = docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" $name 2>$null
if ($s -eq "healthy") { Log "$name is healthy"; return }
Start-Sleep -Seconds 1
}
docker logs --tail 30 $name
throw "$name did not become healthy (last state: $s)"
}
function Start-Plain {
Build-Images
if (-not (docker network ls -q --filter "name=^$Net$")) { docker network create -d nat $Net | Out-Null }
if (-not (docker volume ls -q --filter "name=^$Vol$")) { docker volume create $Vol | Out-Null }
docker rm -f tasks-frontend tasks-backend 2>$null | Out-Null
Log "Starting the back end (no published port)"
docker run -d --name tasks-backend --network $Net --network-alias backend `
-v "${Vol}:C:\data" --restart unless-stopped $BeImg | Out-Null
Wait-Healthy tasks-backend
Log "Starting the front end, published on port $WebPort"
docker run -d --name tasks-frontend --network $Net -p "${WebPort}:8080" `
-e BACKEND_URL=http://backend:8080 --restart unless-stopped $FeImg | Out-Null
Wait-Healthy tasks-frontend
Show-Expose
}
function Start-Compose {
Assert-WindowsEngine
$env:WEB_PORT = $WebPort
docker compose -f compose.windows.yaml up -d --build --wait
Show-Expose
}
function Show-Expose {
Write-Host ""
Log "Open the app:"
Write-Host " on this machine: http://localhost:$WebPort"
Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)' } |
ForEach-Object { Write-Host " other machines: http://$($_.IPAddress):$WebPort" }
$rule = "Docker tasks app $WebPort"
if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
Write-Host " firewall: rule '$rule' exists"
} elseif ($Open) {
New-NetFirewallRule -DisplayName $rule -Direction Inbound -Protocol TCP -LocalPort $WebPort -Action Allow | Out-Null
Write-Host " firewall: inbound TCP $WebPort allowed ('$rule')"
} else {
Write-Host " firewall: to allow other machines, run as Administrator: .\app.ps1 expose -Open"
}
Write-Host " cloud VM? also allow TCP $WebPort in the security group / network firewall"
}
function Test-App {
$base = "http://localhost:$WebPort"
Log "GET $base/healthz"; (Invoke-WebRequest "$base/healthz" -UseBasicParsing).Content
Log "POST $base/add"; Invoke-WebRequest "$base/add" -Method Post -Body @{ title = "Written by app.ps1 test" } -UseBasicParsing | Out-Null
Log "GET $base/"; ((Invoke-WebRequest "$base/" -UseBasicParsing).Content -split "<li" | Select-Object -Skip 1 -First 5) | ForEach-Object { " <li" + ($_ -split "<form")[0] }
}
function Stop-App {
docker compose -f compose.windows.yaml down 2>$null | Out-Null
docker rm -f tasks-frontend tasks-backend 2>$null | Out-Null
Log "Stopped. Data volume kept."
}
function Remove-All {
Stop-App
docker compose -f compose.windows.yaml down -v 2>$null | Out-Null
docker volume rm $Vol 2>$null | Out-Null
docker network rm $Net 2>$null | Out-Null
docker rmi $FeImg $BeImg 2>$null | Out-Null
Log "Removed containers, volume, network and images."
}
switch ($Command) {
"build" { Build-Images }
"run" { Start-Plain }
"up" { Start-Compose }
"expose" { Show-Expose }
"test" { Test-App }
"status" { docker ps -a --filter name=tasks --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" }
"logs" { docker logs -f --tail 50 "tasks-$Target" }
"down" { Stop-App }
"clean" { Remove-All }
default { (Get-Content $PSCommandPath -TotalCount 17) -join "`n" }
}
Windows version compatibility
A Linux container runs on any Linux kernel, but a Windows container depends on the host's Windows build. With process isolation (the default on Windows Server), the image's base (ltsc2022 = Windows Server 2022) must be compatible with the host; a newer image won't start on an older host. Hyper-V isolation (--isolation=hyperv, the default on Windows 10/11 client) runs each container in a small VM and relaxes that rule. Use the tag family that matches your servers (ltsc2022, ltsc2025) and check Microsoft's "Windows container version compatibility" page for your host.
Other Windows differences worth knowing:
- The default network driver is nat; containers still find each other by name on a user-defined network.
- Windows images are larger (the Nano Server .NET SDK image is well over 1 GB), and the first pull takes a while.
- An engine runs either Linux or Windows containers. Docker Desktop switches the whole engine (tray menu, or
DockerCli.exe -SwitchDaemon).
Step 5: expose it
Same three parts as in the Python project: publish on a reachable address, allow it through the firewall, and make sure the other machine can route to the host.
On Linux:
$ ./app.sh expose
==> Open the app:
on this machine: http://localhost:8081
other machines: http://192.168.56.20:8081
listening socket: 0.0.0.0:8081
cloud VM? also allow TCP 8081 in the security group / network firewall
On Windows, other machines usually also need an inbound Windows Firewall rule for the published port (always check on Windows Server and managed laptops). From an Administrator PowerShell:
PS> .\app.ps1 expose -Open
==> Open the app:
on this machine: http://localhost:8081
other machines: http://192.168.1.40:8081
firewall: inbound TCP 8081 allowed ('Docker tasks app 8081')
(That runs New-NetFirewallRule -DisplayName "Docker tasks app 8081" -Direction Inbound -Protocol TCP -LocalPort 8081 -Action Allow.)
Step 6: test and troubleshoot
$ ./app.sh test
==> GET http://127.0.0.1:8081/healthz
ok
==> POST http://127.0.0.1:8081/add
302 (redirect back to /)
==> GET http://127.0.0.1:8081/
<li>Written by app.sh test
<li class="done">Containerise the .NET app
| Symptom | Check |
|---|---|
| Page says "Back end not reachable" | Is tasks-backend healthy? Is BACKEND_URL right and are both on the same network? |
permission denied on /data/tasks.json (Linux) |
The Dockerfile must create /data owned by $APP_UID before the volume is first used |
Build fails at dotnet restore |
The build needs to reach api.nuget.org (proxy settings for builds go in ~/.docker/config.json, lesson 8) |
Windows: no matching manifest for linux/amd64 |
Docker is in Linux mode: switch to Windows containers, or use the Linux files |
Windows: container operating system does not match |
Base-image tag vs host version: see the compatibility box above |
| Works locally, not from other machines | BIND_ADDR, Windows Firewall / ufw / firewalld, security group |
Extend it
- Add a
DELETE /api/tasks/{id}endpoint to the API and a delete button to the front end; rebuild only what changed. - Replace the JSON file with PostgreSQL: add a
dbservice tocompose.yaml(lesson 5's health check pattern) and useNpgsql. - Build multi-architecture Linux images (
docker buildx build --platform linux/amd64,linux/arm64) and push them to your registry from lesson 7. - On a Windows host, build the Nano Server images and compare their size with the Linux ones (
docker images).
Clean-up
$ ./app.sh clean # Linux
PS> .\app.ps1 clean # Windows
Recap
- Multi-stage: build with
sdk:10.0, ship onaspnet:10.0: about 230 MB per image, non-root via$APP_UID, port 8080. - The same app builds Windows Nano Server images from
Dockerfile.windows; Windows containers need a Windows host in Windows-containers mode and a compatible base-image version. - The front end is the only published service; it reaches the API by name over the container network.
- Exposing on Windows needs an inbound Windows Firewall rule as well as the published port.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.