Docker & Containers — Level by Level›13 · Project: .NET front end + back end (Linux & Windows)

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.

Practitioner

Download the project (zip)Linux + Windows Dockerfiles, .NET code, compose files, app.sh + app.ps1

Key wordsproject.NET 10ASP.NET Coreminimal APIWindows containersNano Serverltsc2022PowerShellmulti-stageAPP_UIDexposeWindows Firewall
Browser any machine :8081 Docker host · one user-defined network frontend ASP.NET Core server-rendered HTML backend .NET 10 minimal API Linux or Nano Server volume tasks-data tasks.json -p 8081 by name: backend only the front end publishes a port; the back end is unreachable from outside frontend/Dockerfile backend/Dockerfile
ASP.NET Core front end and API in two containers; the same code builds Linux images or Windows Nano Server images.

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 (--health in Program.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 like C:\app work.
  • 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

  1. Add a DELETE /api/tasks/{id} endpoint to the API and a delete button to the front end; rebuild only what changed.
  2. Replace the JSON file with PostgreSQL: add a db service to compose.yaml (lesson 5's health check pattern) and use Npgsql.
  3. Build multi-architecture Linux images (docker buildx build --platform linux/amd64,linux/arm64) and push them to your registry from lesson 7.
  4. 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 on aspnet: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.