From cb34b2ae3225132e670849f4b0545c4924ab6db0 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 3 Aug 2026 14:44:49 +0000 Subject: [PATCH 1/4] Add lightweight Dockerized ASP.NET Core sample --- .../.dockerignore | 35 +++ .../ContainerizedApiMinimal.slnx | 9 + .../Dockerfile | 63 +++++ .../README.md | 267 ++++++++++++++++++ .../compose.yaml | 27 ++ .../ContainerHealthProbe.csproj | 10 + .../src/ContainerHealthProbe/Program.cs | 27 ++ .../ContainerHealthProbe/packages.lock.json | 6 + .../ContainerizedApiMinimal.csproj | 9 + .../src/ContainerizedApiMinimal/Program.cs | 180 ++++++++++++ .../ContainerizedApiMinimal/appsettings.json | 12 + .../packages.lock.json | 6 + .../ContainerizedApiMinimal.Tests.csproj | 49 ++++ .../ContainerizedApiTests.cs | 186 ++++++++++++ 14 files changed, 886 insertions(+) create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/.dockerignore create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/ContainerizedApiMinimal.slnx create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/Dockerfile create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/README.md create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/compose.yaml create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/ContainerHealthProbe.csproj create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/Program.cs create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerHealthProbe/packages.lock.json create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/ContainerizedApiMinimal.csproj create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/Program.cs create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/appsettings.json create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/src/ContainerizedApiMinimal/packages.lock.json create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiMinimal.Tests.csproj create mode 100644 cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiTests.cs 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..f54be54 --- /dev/null +++ b/cloud-native/dockerize-aspnet-core-clean-images/README.md @@ -0,0 +1,267 @@ +# 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. + +## 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..750277c --- /dev/null +++ b/cloud-native/dockerize-aspnet-core-clean-images/tests/ContainerizedApiMinimal.Tests/ContainerizedApiTests.cs @@ -0,0 +1,186 @@ +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; + + HttpResponseMessage live = + await client.GetAsync( + "/health/live", + ct); + + HttpResponseMessage ready = + await client.GetAsync( + "/health/ready", + ct); + + Assert.Equal( + HttpStatusCode.OK, + live.StatusCode); + + Assert.Equal( + HttpStatusCode.OK, + ready.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 From ab4d777e68f054ec398aec15a09242a7468dc79d Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 3 Aug 2026 14:44:49 +0000 Subject: [PATCH 2/4] Add Dockerized API sample to repository README --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee567f9..9c63cd6 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/) @@ -197,10 +198,31 @@ tutorials/ | | | |-- CartIsland.razor | | | |-- ProductCard.razor | | | `-- ReviewsSection.razor +`-- tests/ + `-- 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/ -| `-- BlazorCatalogIslands.Tests/ -| |-- BlazorCatalogIslands.Tests.csproj -| `-- CatalogIslandTests.cs +| `-- ContainerizedApiMinimal.Tests/ +| |-- ContainerizedApiMinimal.Tests.csproj +| `-- ContainerizedApiTests.cs |-- aspnet-core/ | |-- api-security-in-practice/ | | |-- ApiSecurityMinimal.slnx From 51e5989a86b84a9efe20adf74c575bc9fe5ae7cc Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 3 Aug 2026 14:44:50 +0000 Subject: [PATCH 3/4] Test Dockerized ASP.NET Core sample in GitHub Actions --- .github/workflows/build-samples.yml | 164 ++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) 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 From 6b5a7e651428708236d554e8de25e37e8bcae232 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 3 Aug 2026 14:52:47 +0000 Subject: [PATCH 4/4] Address final Dockerized API review feedback --- README.md | 8 +-- .../README.md | 46 ++++++++----- .../ContainerizedApiTests.cs | 65 +++++++++++++++++++ 3 files changed, 97 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9c63cd6..5ecec8b 100644 --- a/README.md +++ b/README.md @@ -198,10 +198,10 @@ tutorials/ | | | |-- CartIsland.razor | | | |-- ProductCard.razor | | | `-- ReviewsSection.razor -`-- tests/ - `-- BlazorCatalogIslands.Tests/ - |-- BlazorCatalogIslands.Tests.csproj - `-- CatalogIslandTests.cs +| `-- tests/ +| `-- BlazorCatalogIslands.Tests/ +| |-- BlazorCatalogIslands.Tests.csproj +| `-- CatalogIslandTests.cs |-- cloud-native/ | `-- dockerize-aspnet-core-clean-images/ | |-- ContainerizedApiMinimal.slnx diff --git a/cloud-native/dockerize-aspnet-core-clean-images/README.md b/cloud-native/dockerize-aspnet-core-clean-images/README.md index f54be54..f349b80 100644 --- a/cloud-native/dockerize-aspnet-core-clean-images/README.md +++ b/cloud-native/dockerize-aspnet-core-clean-images/README.md @@ -1,4 +1,4 @@ -# Dockerized ASP.NET Core API — Minimal Sample +# 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 @@ -58,11 +58,11 @@ controlled dependency process. ```text SDK restore stage - → SDK publish stage - → ASP.NET Core runtime stage - → numeric non-root user - → Kestrel on 8080 - → .NET Docker health probe + -> 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 @@ -161,6 +161,16 @@ docker exec ` 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 @@ -217,19 +227,19 @@ Dockerfile compose.yaml README.md src/ -└ ContainerizedApiMinimal/ - └ ContainerizedApiMinimal.csproj - └ Program.cs - └ appsettings.json - └ packages.lock.json -└ ContainerHealthProbe/ - └ ContainerHealthProbe.csproj - └ Program.cs - └ packages.lock.json +|-- 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 +`-- ContainerizedApiMinimal.Tests/ + |-- ContainerizedApiMinimal.Tests.csproj + `-- ContainerizedApiTests.cs ``` ## Deliberately omitted 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 index 750277c..a22a60d 100644 --- 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 @@ -116,6 +116,7 @@ public async Task TestContext.Current .CancellationToken; + // -- Normal configuration: all endpoints healthy HttpResponseMessage live = await client.GetAsync( "/health/live", @@ -126,6 +127,11 @@ await client.GetAsync( "/health/ready", ct); + HttpResponseMessage combined = + await client.GetAsync( + "/health", + ct); + Assert.Equal( HttpStatusCode.OK, live.StatusCode); @@ -133,6 +139,65 @@ await client.GetAsync( 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]