diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml
index 00139a8..97ab896 100644
--- a/.github/workflows/build-samples.yml
+++ b/.github/workflows/build-samples.yml
@@ -17,6 +17,7 @@ on:
- "blazor/create-interactive-ui-csharp-12/**"
- "blazor/forms-validation-masterclass/**"
- "blazor/ssr-interactive-islands/**"
+ - "cloud-native/dockerize-aspnet-core-clean-images/**"
- ".github/workflows/build-samples.yml"
pull_request:
@@ -34,6 +35,7 @@ on:
- "blazor/create-interactive-ui-csharp-12/**"
- "blazor/forms-validation-masterclass/**"
- "blazor/ssr-interactive-islands/**"
+ - "cloud-native/dockerize-aspnet-core-clean-images/**"
- ".github/workflows/build-samples.yml"
workflow_dispatch:
@@ -455,3 +457,165 @@ jobs:
blazor/ssr-interactive-islands/BlazorCatalogIslands.slnx
--configuration Release
--no-build
+
+ test-dockerized-aspnet-core:
+ name: Test Dockerized ASP.NET Core sample
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ run: >
+ dotnet restore
+ cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx
+
+ - name: Build
+ run: >
+ dotnet build
+ cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx
+ --configuration Release
+ --no-restore
+
+ - name: Test
+ run: >
+ dotnet test
+ cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx
+ --configuration Release
+ --no-build
+
+ - name: Validate Compose configuration
+ run: >
+ docker compose
+ --file cloud-native/dockerize-aspnet-core-clean-images/compose.yaml
+ config
+ --quiet
+
+ - name: Build container image
+ run: >
+ docker build
+ --pull
+ --tag dotnet-guide/containerized-api-minimal:ci
+ cloud-native/dockerize-aspnet-core-clean-images
+
+ - name: Run hardened container
+ run: |
+ docker run \
+ --detach \
+ --name containerized-api-ci \
+ --publish 127.0.0.1:5152:8080 \
+ --env ASPNETCORE_ENVIRONMENT=Production \
+ --env Sample__Message="Configured by GitHub Actions" \
+ --read-only \
+ --tmpfs /tmp \
+ --cap-drop ALL \
+ --security-opt no-new-privileges:true \
+ dotnet-guide/containerized-api-minimal:ci
+
+ - name: Wait for Docker health
+ run: |
+ status=""
+
+ for attempt in $(seq 1 30); do
+ status="$(
+ docker inspect \
+ --format='{{.State.Health.Status}}' \
+ containerized-api-ci
+ )"
+
+ echo "Attempt ${attempt}: ${status}"
+
+ if [ "$status" = "healthy" ]; then
+ break
+ fi
+
+ if [ "$status" = "unhealthy" ]; then
+ docker logs containerized-api-ci
+ exit 1
+ fi
+
+ sleep 2
+ done
+
+ test "$status" = "healthy"
+
+ - name: Verify container HTTP and runtime properties
+ run: |
+ python3 - <<'PY'
+ import json
+ import urllib.request
+
+ base = "http://127.0.0.1:5152"
+
+ for path in (
+ "/health/live",
+ "/health/ready",
+ "/health",
+ ):
+ with urllib.request.urlopen(base + path) as response:
+ assert response.status == 200, (path, response.status)
+
+ with urllib.request.urlopen(base + "/api/todos") as response:
+ todos = json.load(response)
+
+ assert len(todos) == 3
+ assert any(
+ item["title"] == "Run the container as non-root"
+ for item in todos
+ )
+
+ with urllib.request.urlopen(base + "/info") as response:
+ info = json.load(response)
+
+ assert info["environment"] == "Production"
+ assert info["message"] == "Configured by GitHub Actions"
+ assert info["runningInContainer"] is True
+ assert info["user"].lower() != "root"
+ PY
+
+ - name: Verify non-root image and runtime-only contents
+ run: |
+ image_user="$(
+ docker image inspect \
+ dotnet-guide/containerized-api-minimal:ci \
+ --format='{{.Config.User}}'
+ )"
+
+ echo "Image user: ${image_user}"
+
+ test -n "$image_user"
+ test "$image_user" != "0"
+ test "$image_user" != "root"
+
+ sdk_output="$(
+ docker exec \
+ containerized-api-ci \
+ dotnet --list-sdks
+ )"
+
+ test -z "$sdk_output"
+
+ - name: Show container diagnostics
+ if: always()
+ run: |
+ docker ps --all
+ docker inspect containerized-api-ci || true
+ docker logs containerized-api-ci || true
+
+ - name: Remove test container
+ if: always()
+ run: |
+ docker rm \
+ --force \
+ containerized-api-ci \
+ 2>/dev/null \
+ || true
diff --git a/README.md b/README.md
index ee567f9..5ecec8b 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The
| [`blazor/create-interactive-ui-csharp-12`](blazor/create-interactive-ui-csharp-12/) | Interactive .NET 10 Todo Dashboard demonstrating Razor components, form binding, DataAnnotations validation, scoped state, EventCallback communication, filtering, and bUnit component tests | [Blazor Web Development: Create Interactive UIs with C# 12](https://www.dotnet-guide.com/tutorials/blazor/create-interactive-ui-csharp-12/) |
| [`blazor/forms-validation-masterclass`](blazor/forms-validation-masterclass/) | Focused .NET 10 Profile Settings form demonstrating manual EditContext management, DataAnnotations, FluentValidation, backend field-error mapping, accessible inputs, dirty state, and bUnit testing | [Blazor .NET 8 Forms & Validation: EditForm, FluentValidation & Server Error Handling](https://www.dotnet-guide.com/tutorials/blazor/forms-validation-masterclass/) |
| [`blazor/ssr-interactive-islands`](blazor/ssr-interactive-islands/) | Focused .NET 10 catalog demonstrating static SSR, streaming review updates, a serializable render-mode boundary, an Interactive Server cart island, and component/integration testing | [Blazor SSR & Interactive Islands: Streaming Rendering, Auto Render Mode & Progressive Enhancement](https://www.dotnet-guide.com/tutorials/blazor/ssr-interactive-islands/) |
+| [`cloud-native/dockerize-aspnet-core-clean-images`](cloud-native/dockerize-aspnet-core-clean-images/) | Focused .NET 10 container sample demonstrating a multi-stage Dockerfile, locked restore, runtime-only image, non-root execution, port 8080, runtime configuration, health checks, hardened Compose settings, and CI smoke testing | [Dockerizing ASP.NET Core: Multi-Stage Builds, Clean Images & a Production-Ready Ship Workflow](https://www.dotnet-guide.com/tutorials/cloud-native/dockerize-aspnet-core-clean-images/) |
## Companion articles
- [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/)
@@ -201,6 +202,27 @@ tutorials/
| `-- BlazorCatalogIslands.Tests/
| |-- BlazorCatalogIslands.Tests.csproj
| `-- CatalogIslandTests.cs
+|-- cloud-native/
+| `-- dockerize-aspnet-core-clean-images/
+| |-- ContainerizedApiMinimal.slnx
+| |-- Dockerfile
+| |-- .dockerignore
+| |-- compose.yaml
+| |-- README.md
+| |-- src/
+| | |-- ContainerizedApiMinimal/
+| | | |-- ContainerizedApiMinimal.csproj
+| | | |-- Program.cs
+| | | |-- appsettings.json
+| | | `-- packages.lock.json
+| | `-- ContainerHealthProbe/
+| | |-- ContainerHealthProbe.csproj
+| | |-- Program.cs
+| | `-- packages.lock.json
+| `-- tests/
+| `-- ContainerizedApiMinimal.Tests/
+| |-- ContainerizedApiMinimal.Tests.csproj
+| `-- ContainerizedApiTests.cs
|-- aspnet-core/
| |-- api-security-in-practice/
| | |-- ApiSecurityMinimal.slnx
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/.dockerignore b/cloud-native/dockerize-aspnet-core-clean-images/.dockerignore
new file mode 100644
index 0000000..c574d00
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/.dockerignore
@@ -0,0 +1,35 @@
+# Git and repository metadata
+.git/
+.github/
+.gitattributes
+.gitignore
+
+# .NET build output
+**/bin/
+**/obj/
+**/TestResults/
+
+# IDE and user files
+.vs/
+.vscode/
+*.user
+*.suo
+
+# Tests are validated before image build and aren't copied into the runtime image
+tests/
+
+# Local configuration and secrets
+.env
+.env.*
+**/secrets.json
+**/appsettings.Local.json
+**/*.pfx
+**/*.key
+
+# Repository documentation and local orchestration
+README.md
+compose.yaml
+
+# Miscellaneous local artifacts
+*.log
+*.tmp
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx b/cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx
new file mode 100644
index 0000000..5a4e11b
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/Dockerfile b/cloud-native/dockerize-aspnet-core-clean-images/Dockerfile
new file mode 100644
index 0000000..8135578
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/Dockerfile
@@ -0,0 +1,63 @@
+# syntax=docker/dockerfile:1
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS restore
+WORKDIR /src
+
+COPY ["src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj", "src/ContainerizedApiMinimal/"]
+COPY ["src/ContainerizedApiMinimal/packages.lock.json", "src/ContainerizedApiMinimal/"]
+
+COPY ["src/ContainerHealthProbe/ContainerHealthProbe.csproj", "src/ContainerHealthProbe/"]
+COPY ["src/ContainerHealthProbe/packages.lock.json", "src/ContainerHealthProbe/"]
+
+RUN dotnet restore \
+ "src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj" \
+ --locked-mode \
+ && dotnet restore \
+ "src/ContainerHealthProbe/ContainerHealthProbe.csproj" \
+ --locked-mode
+
+FROM restore AS publish
+
+COPY ["src/ContainerizedApiMinimal/", "src/ContainerizedApiMinimal/"]
+COPY ["src/ContainerHealthProbe/", "src/ContainerHealthProbe/"]
+
+RUN dotnet publish \
+ "src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj" \
+ --configuration Release \
+ --no-restore \
+ --output /out/app \
+ /p:UseAppHost=false \
+ && dotnet publish \
+ "src/ContainerHealthProbe/ContainerHealthProbe.csproj" \
+ --configuration Release \
+ --no-restore \
+ --output /out/probe \
+ /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
+WORKDIR /app
+
+ENV ASPNETCORE_HTTP_PORTS=8080
+
+COPY --from=publish \
+ --chown=app:app \
+ /out/app/ \
+ ./
+
+COPY --from=publish \
+ --chown=app:app \
+ /out/probe/ \
+ ./
+
+USER $APP_UID
+
+EXPOSE 8080
+
+HEALTHCHECK \
+ --interval=30s \
+ --timeout=5s \
+ --start-period=10s \
+ --retries=3 \
+ CMD ["dotnet", "ContainerHealthProbe.dll"]
+
+ENTRYPOINT ["dotnet", "ContainerizedApiMinimal.dll"]
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/README.md b/cloud-native/dockerize-aspnet-core-clean-images/README.md
new file mode 100644
index 0000000..f349b80
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/README.md
@@ -0,0 +1,277 @@
+# Dockerized ASP.NET Core API — Minimal Sample
+
+A focused .NET 10 companion demonstrating a multi-stage Dockerfile, locked
+restore, a runtime-only final image, non-root execution, port 8080, runtime
+configuration, liveness/readiness endpoints, a .NET-based Docker health probe,
+a hardened Compose service, integration tests, and CI container smoke checks.
+
+## Full tutorial
+
+[Dockerizing ASP.NET Core: Multi-Stage Builds, Clean Images & a Production-Ready Ship Workflow](https://www.dotnet-guide.com/tutorials/cloud-native/dockerize-aspnet-core-clean-images/)
+
+## Framework note
+
+The tutorial explains ASP.NET Core containers on .NET 8.
+
+This companion targets .NET 10 so it can use the DOTNET GUIDE repository's
+current SDK and CI toolchain. The multi-stage-build, locked-restore, non-root,
+port-8080, runtime-configuration, health-check, Docker Compose, and
+container-verification practices are the same core container concepts.
+
+## Image note
+
+The Dockerfile uses:
+
+```text
+mcr.microsoft.com/dotnet/sdk:10.0-noble
+mcr.microsoft.com/dotnet/aspnet:10.0-noble
+```
+
+These are maintained channel tags, not immutable image references.
+
+Rebuilding can pull newer patched image content. Teams requiring byte-for-byte
+base-image reproducibility can pin reviewed digests and update them through a
+controlled dependency process.
+
+## What this sample demonstrates
+
+- restore, publish, and final Docker stages;
+- committed package lock files;
+- `dotnet restore --locked-mode`;
+- project-file-first Docker cache ordering;
+- a runtime-only final image;
+- the built-in .NET `app` user via `$APP_UID`;
+- port 8080;
+- no SDK in the final image;
+- no installed `curl` or `wget`;
+- a small .NET health-probe executable;
+- `/health/live`;
+- `/health/ready`;
+- `/health`;
+- configuration through `Sample__Message`;
+- a read-only container filesystem;
+- dropped Linux capabilities;
+- `no-new-privileges`;
+- integration and container smoke tests.
+
+## Container flow
+
+```text
+SDK restore stage
+ -> SDK publish stage
+ -> ASP.NET Core runtime stage
+ -> numeric non-root user
+ -> Kestrel on 8080
+ -> .NET Docker health probe
+```
+
+## Why the health check is another .NET project
+
+The final image doesn't install an operating-system HTTP tool solely for
+`HEALTHCHECK`.
+
+`ContainerHealthProbe` sends a short HTTP request to the local liveness endpoint
+and returns exit code 0 or 1.
+
+Docker remains responsible for scheduling, timeouts, and retry counts.
+
+## Prerequisites
+
+- .NET 10 SDK
+- Docker Engine or Docker Desktop
+- Docker Compose v2
+- `curl` on the host only for manual HTTP checks
+
+## Restore, build, and test
+
+```powershell
+dotnet restore `
+ .\ContainerizedApiMinimal.slnx
+
+dotnet build `
+ .\ContainerizedApiMinimal.slnx `
+ --configuration Release `
+ --no-restore
+
+dotnet test `
+ .\ContainerizedApiMinimal.slnx `
+ --configuration Release `
+ --no-build
+```
+
+## Build the image
+
+```powershell
+docker build `
+ --pull `
+ --tag dotnet-guide/containerized-api-minimal:local `
+ .
+```
+
+## Run the hardened container
+
+```powershell
+docker run `
+ --detach `
+ --name containerized-api-minimal `
+ --publish 127.0.0.1:5152:8080 `
+ --env ASPNETCORE_ENVIRONMENT=Production `
+ --env Sample__Message="Configured at docker run time" `
+ --read-only `
+ --tmpfs /tmp `
+ --cap-drop ALL `
+ --security-opt no-new-privileges:true `
+ dotnet-guide/containerized-api-minimal:local
+```
+
+Open:
+
+```text
+http://127.0.0.1:5152/api/todos
+http://127.0.0.1:5152/info
+http://127.0.0.1:5152/health/live
+http://127.0.0.1:5152/health/ready
+```
+
+## Check Docker health
+
+```powershell
+docker inspect `
+ --format="{{.State.Health.Status}}" `
+ containerized-api-minimal
+```
+
+## Confirm the default user
+
+```powershell
+docker image inspect `
+ dotnet-guide/containerized-api-minimal:local `
+ --format="{{.Config.User}}"
+```
+
+The result must not be empty, `0`, or `root`.
+
+## Confirm the SDK is absent
+
+```powershell
+docker exec `
+ containerized-api-minimal `
+ dotnet --list-sdks
+```
+
+No SDK version should be returned.
+
+## Stop the manual container
+
+```powershell
+docker rm --force containerized-api-minimal
+```
+
+Remove the manual container before starting the Docker Compose service. Both
+workflows bind to `127.0.0.1:5152` and leaving a running container causes a
+port conflict.
+
+## Docker Compose
+
+```powershell
+docker compose config --quiet
+docker compose up --build --detach
+docker compose ps
+docker compose down --remove-orphans
+```
+
+## Configuration boundary
+
+`Sample__Message` is intentionally non-sensitive.
+
+Environment variables can be inspected through container tooling. Use a
+platform secret mechanism for real credentials.
+
+Never place a credential in:
+
+- the Dockerfile;
+- `ARG`;
+- `ENV`;
+- a committed Compose file;
+- `appsettings.json`;
+- an image label.
+
+## Health boundary
+
+The liveness endpoint tests whether the process can execute.
+
+The readiness endpoint verifies that required sample configuration is present.
+
+Neither endpoint checks a database because this lightweight sample has no
+database.
+
+## Security boundary
+
+The sample applies useful container defaults, but these controls don't replace:
+
+- application authorization;
+- vulnerability management;
+- image signing;
+- registry policy;
+- network policy;
+- orchestrator security context;
+- secret management;
+- operating-system patching.
+
+## Project structure
+
+```text
+ContainerizedApiMinimal.slnx
+Dockerfile
+.dockerignore
+compose.yaml
+README.md
+src/
+|-- ContainerizedApiMinimal/
+| |-- ContainerizedApiMinimal.csproj
+| |-- Program.cs
+| |-- appsettings.json
+| `-- packages.lock.json
+|-- ContainerHealthProbe/
+| |-- ContainerHealthProbe.csproj
+| |-- Program.cs
+| `-- packages.lock.json
+tests/
+`-- ContainerizedApiMinimal.Tests/
+ |-- ContainerizedApiMinimal.Tests.csproj
+ `-- ContainerizedApiTests.cs
+```
+
+## Deliberately omitted
+
+- database health checks;
+- private package feeds;
+- build secrets;
+- image publishing;
+- cloud deployment;
+- Kubernetes;
+- multi-architecture publication;
+- image signing;
+- fixed image-size targets;
+- production restart policy.
+
+These remain in the full tutorial or a future deployment sample.
+
+## Verification
+
+- Companion target framework: .NET 10
+- Tutorial framework: .NET 8
+- Build image: .NET 10 SDK on Ubuntu Noble
+- Final image: ASP.NET Core 10 runtime on Ubuntu Noble
+- Container port: 8080
+- Runtime user: non-root `$APP_UID`
+- Application packages: none
+- External services required: none
+- Database required: none
+- API keys required: none
+- Expected integration tests: 5
+- Container smoke test: required
+- Last reviewed: 2026-08-03
+
+This sample is educational and must be reviewed against the target registry,
+host, orchestrator, and security requirements before production use.
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/compose.yaml b/cloud-native/dockerize-aspnet-core-clean-images/compose.yaml
new file mode 100644
index 0000000..edd451f
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/compose.yaml
@@ -0,0 +1,27 @@
+name: dotnet-guide-containerized-api
+
+services:
+ api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+
+ image: dotnet-guide/containerized-api-minimal:local
+
+ ports:
+ - "127.0.0.1:5152:8080"
+
+ environment:
+ ASPNETCORE_ENVIRONMENT: Production
+ Sample__Message: Configured by Docker Compose
+
+ read_only: true
+
+ tmpfs:
+ - /tmp
+
+ cap_drop:
+ - ALL
+
+ security_opt:
+ - no-new-privileges:true
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/ContainerHealthProbe.csproj b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/ContainerHealthProbe.csproj
new file mode 100644
index 0000000..b2e09d4
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/ContainerHealthProbe.csproj
@@ -0,0 +1,10 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/Program.cs b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/Program.cs
new file mode 100644
index 0000000..d287c4a
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/Program.cs
@@ -0,0 +1,27 @@
+string url =
+ Environment.GetEnvironmentVariable(
+ "HEALTHCHECK_URL")
+ ?? "http://127.0.0.1:8080/health/live";
+
+using var client =
+ new HttpClient
+ {
+ Timeout =
+ TimeSpan.FromSeconds(
+ 3)
+ };
+
+try
+{
+ using HttpResponseMessage response =
+ await client.GetAsync(
+ url);
+
+ return response.IsSuccessStatusCode
+ ? 0
+ : 1;
+}
+catch
+{
+ return 1;
+}
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/packages.lock.json b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/packages.lock.json
new file mode 100644
index 0000000..4a91a8c
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/packages.lock.json
@@ -0,0 +1,6 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {}
+ }
+}
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj
new file mode 100644
index 0000000..84d2766
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/Program.cs b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/Program.cs
new file mode 100644
index 0000000..940b4f0
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/Program.cs
@@ -0,0 +1,180 @@
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+var builder =
+ WebApplication.CreateBuilder(args);
+
+builder.Services
+ .AddHealthChecks()
+ .AddCheck(
+ "self",
+ () =>
+ HealthCheckResult.Healthy(
+ "Process is running."),
+ tags:
+ [
+ "live"
+ ])
+ .AddCheck<
+ ConfigurationHealthCheck>(
+ "configuration",
+ tags:
+ [
+ "ready"
+ ]);
+
+var app =
+ builder.Build();
+
+TodoItem[] todos =
+[
+ new TodoItem(
+ 1,
+ "Build the multi-stage image",
+ false),
+
+ new TodoItem(
+ 2,
+ "Run the container as non-root",
+ false),
+
+ new TodoItem(
+ 3,
+ "Verify the health probe",
+ true)
+];
+
+app.MapGet(
+ "/",
+ () =>
+ TypedResults.Ok(
+ new ServiceDescription(
+ "ContainerizedApiMinimal",
+ [
+ "GET /api/todos",
+ "GET /api/todos/{id}",
+ "GET /info",
+ "GET /health/live",
+ "GET /health/ready",
+ "GET /health"
+ ])));
+
+app.MapGet(
+ "/api/todos",
+ () =>
+ TypedResults.Ok(
+ todos));
+
+app.MapGet(
+ "/api/todos/{id:int:min(1)}",
+ (int id) =>
+ {
+ TodoItem? todo =
+ todos.FirstOrDefault(
+ item =>
+ item.Id == id);
+
+ return todo is null
+ ? Results.NotFound()
+ : Results.Ok(todo);
+ });
+
+app.MapGet(
+ "/info",
+ (
+ IConfiguration configuration,
+ IHostEnvironment environment) =>
+ {
+ bool runningInContainer =
+ bool.TryParse(
+ Environment.GetEnvironmentVariable(
+ "DOTNET_RUNNING_IN_CONTAINER"),
+ out bool parsed)
+ && parsed;
+
+ return TypedResults.Ok(
+ new RuntimeInfo(
+ "ContainerizedApiMinimal",
+ environment.EnvironmentName,
+ configuration[
+ "Sample:Message"]
+ ?? string.Empty,
+ runningInContainer,
+ Environment.UserName));
+ });
+
+app.MapHealthChecks(
+ "/health/live",
+ new HealthCheckOptions
+ {
+ Predicate =
+ registration =>
+ registration.Tags.Contains(
+ "live")
+ });
+
+app.MapHealthChecks(
+ "/health/ready",
+ new HealthCheckOptions
+ {
+ Predicate =
+ registration =>
+ registration.Tags.Contains(
+ "ready")
+ });
+
+app.MapHealthChecks(
+ "/health");
+
+app.Run();
+
+public sealed record TodoItem(
+ int Id,
+ string Title,
+ bool IsComplete);
+
+public sealed record ServiceDescription(
+ string Name,
+ string[] Endpoints);
+
+public sealed record RuntimeInfo(
+ string Application,
+ string Environment,
+ string Message,
+ bool RunningInContainer,
+ string User);
+
+public sealed class
+ ConfigurationHealthCheck(
+ IConfiguration configuration) :
+ IHealthCheck
+{
+ public Task
+ CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken =
+ default)
+ {
+ cancellationToken
+ .ThrowIfCancellationRequested();
+
+ string? message =
+ configuration[
+ "Sample:Message"];
+
+ HealthCheckResult result =
+ string.IsNullOrWhiteSpace(
+ message)
+ ? HealthCheckResult.Unhealthy(
+ "Sample:Message is not configured.")
+ : HealthCheckResult.Healthy(
+ "Required runtime configuration is present.");
+
+ return Task.FromResult(
+ result);
+ }
+}
+
+public partial class Program
+{
+}
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/appsettings.json b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/appsettings.json
new file mode 100644
index 0000000..7ff5677
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Sample": {
+ "Message": "Configured by appsettings.json"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/packages.lock.json b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/packages.lock.json
new file mode 100644
index 0000000..4a91a8c
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/packages.lock.json
@@ -0,0 +1,6 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {}
+ }
+}
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiMinimal.Tests.csproj b/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiMinimal.Tests.csproj
new file mode 100644
index 0000000..9afec98
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiMinimal.Tests.csproj
@@ -0,0 +1,49 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+ all
+
+ runtime;
+ build;
+ native;
+ contentfiles;
+ analyzers;
+ buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiTests.cs b/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiTests.cs
new file mode 100644
index 0000000..a22a60d
--- /dev/null
+++ b/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiTests.cs
@@ -0,0 +1,251 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+
+namespace ContainerizedApiMinimal.Tests;
+
+public sealed class ContainerizedApiTests
+{
+ [Fact]
+ public async Task
+ Root_describes_the_sample_endpoints()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ ServiceDescription? description =
+ await client.GetFromJsonAsync<
+ ServiceDescription>(
+ "/",
+ ct);
+
+ Assert.NotNull(
+ description);
+
+ Assert.Equal(
+ "ContainerizedApiMinimal",
+ description.Name);
+
+ Assert.Contains(
+ "GET /health/ready",
+ description.Endpoints);
+ }
+
+ [Fact]
+ public async Task
+ Todos_returns_the_seeded_items()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ TodoItem[]? todos =
+ await client.GetFromJsonAsync<
+ TodoItem[]>(
+ "/api/todos",
+ ct);
+
+ Assert.NotNull(
+ todos);
+
+ Assert.Equal(
+ 3,
+ todos.Length);
+
+ Assert.Contains(
+ todos,
+ todo =>
+ todo.Title ==
+ "Run the container as non-root");
+ }
+
+ [Fact]
+ public async Task
+ Missing_todo_returns_not_found()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage response =
+ await client.GetAsync(
+ "/api/todos/999",
+ ct);
+
+ Assert.Equal(
+ HttpStatusCode.NotFound,
+ response.StatusCode);
+ }
+
+ [Fact]
+ public async Task
+ Live_and_ready_health_endpoints_are_healthy()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ // -- Normal configuration: all endpoints healthy
+ HttpResponseMessage live =
+ await client.GetAsync(
+ "/health/live",
+ ct);
+
+ HttpResponseMessage ready =
+ await client.GetAsync(
+ "/health/ready",
+ ct);
+
+ HttpResponseMessage combined =
+ await client.GetAsync(
+ "/health",
+ ct);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ live.StatusCode);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ ready.StatusCode);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ combined.StatusCode);
+
+ // -- Blank configuration: liveness stays 200, readiness degrades to 503
+ using WebApplicationFactory<
+ Program> blankFactory =
+ factory.WithWebHostBuilder(
+ builder =>
+ builder.ConfigureAppConfiguration(
+ (
+ context,
+ configuration) =>
+ {
+ configuration
+ .AddInMemoryCollection(
+ new Dictionary<
+ string,
+ string?>
+ {
+ ["Sample:Message"] =
+ string.Empty
+ });
+ }));
+
+ using HttpClient blankClient =
+ blankFactory.CreateClient();
+
+ CancellationToken blankCt =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage blankLive =
+ await blankClient.GetAsync(
+ "/health/live",
+ blankCt);
+
+ HttpResponseMessage blankReady =
+ await blankClient.GetAsync(
+ "/health/ready",
+ blankCt);
+
+ HttpResponseMessage blankCombined =
+ await blankClient.GetAsync(
+ "/health",
+ blankCt);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ blankLive.StatusCode);
+
+ Assert.Equal(
+ HttpStatusCode.ServiceUnavailable,
+ blankReady.StatusCode);
+
+ Assert.Equal(
+ HttpStatusCode.ServiceUnavailable,
+ blankCombined.StatusCode);
+ }
+
+ [Fact]
+ public async Task
+ Runtime_configuration_can_override_appsettings()
+ {
+ using var baseFactory =
+ new WebApplicationFactory<
+ Program>();
+
+ using WebApplicationFactory<
+ Program> factory =
+ baseFactory.WithWebHostBuilder(
+ builder =>
+ builder.ConfigureAppConfiguration(
+ (
+ context,
+ configuration) =>
+ {
+ configuration
+ .AddInMemoryCollection(
+ new Dictionary<
+ string,
+ string?>
+ {
+ ["Sample:Message"] =
+ "Overridden by integration test"
+ });
+ }));
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ RuntimeInfo? info =
+ await client.GetFromJsonAsync<
+ RuntimeInfo>(
+ "/info",
+ ct);
+
+ Assert.NotNull(
+ info);
+
+ Assert.Equal(
+ "Overridden by integration test",
+ info.Message);
+ }
+}
\ No newline at end of file