This issue is self-sponsored. By supporting my work and purchasing my products, you directly help me keep this newsletter free and continue creating high-quality, practical .NET content for the community.
Thank you for the support 🙌
P.S. I’m currently building a new course, Pragmatic .NET Code Rules, focused on creating a predictable, consistent, and self-maintaining .NET codebase using .editorconfig, analyzers, Visual Studio code cleanup, and CI enforcement.
The course is available for pre-sale until the official release, with early-bird pricing for early adopters. You can find all the details here.
A .NET app rarely “dies” in production. More often:
Before writing code, let’s be clear about why this setup exists. In production, you need answers to questions like:
Health checks answer binary questions
Prometheus is a time-series metrics system. It does three key things:
lets you query them using PromQL Important detail for juniors:
/metrics/metricsGrafana is a visualization layer. It:
One of the most common production mistakes is exposing a single health endpoint.
That leads to broken deployments and unnecessary restarts.
Liveness answers one question:
Is the process running?
It must not check databases, HTTP calls, or external dependencies.
If this fails, the service is considered dead.
Readiness answers a different question:
Is the service ready to handle traffic?
This must check:
If readiness fails, traffic should be stopped, but the service should not be killed.
We will expose two endpoints:
/health/live/health/readyWe’ll build a small but realistic system consisting of:
billing_jobs_processed_totalAdd required NuGet packages:
dotnet add OrderManagement.Api package AspNetCore.HealthChecks.NpgSqldotnet add OrderManagement.Api package prometheus-net.AspNetCore
Explanation
AspNetCore.HealthChecks.NpgSql gives us a ready-made PostgreSQL health probe.prometheus-net.AspNetCore exposes /metrics and HTTP request metrics.
Health check configuration (Program.cs):
```csharp
var postgres = builder.Configuration.GetConnectionString("Postgres")
?? "Host=localhost;Port=5432;Database=orders;Username=postgres;Password=postgres";// Health checks builder.Services.AddHealthChecks()
// Liveness: “process is alive”.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })// Readiness: “dependencies are reachable”.AddNpgSql(postgres, name: "postgres", tags: new[] { "ready" });
- self is the liveness probe- PostgreSQL is checked only for readiness Health endpoints:
csharp var app = builder.Build();
app.UseHttpMetrics(); app.MapMetrics("/metrics");
app.MapHealthChecks("/health/live", new HealthCheckOptions {
Predicate = r => r.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions {
Predicate = r => r.Tags.Contains("ready")
});
app.MapGet("/api/ping", () => Results.Ok (new { ok = true, at = DateTimeOffset.UtcNow }));
Explanation- `/health/live` checks only the app itself.- `/health/ready` checks PostgreSQL connectivity.- `/metrics` exposes Prometheus-format metrics.- `UseHttpMetrics()` auto-collects request metrics. ## Billing Worker: Add an HTTP host + Health + Custom Metrics You need the same packages as for the Order API.A worker template doesn’t expose HTTP endpoints by default. But we want:- `/health/*`- `/metrics` So we host a minimal web server **inside the worker**. Billing.Worker/Program.cs
csharp using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks; using Prometheus;
namespace OrderManagement.Billing.Worker;
public partial class Program {
private static void Main(string[] args){ var builder = WebApplication.CreateBuilder(args); var postgres = builder.Configuration.GetConnectionString("Postgres") ?? "Host=localhost;Port=5432;Database=appdb;Username=app;Password=app"; // Health checks builder.Services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" }) .AddNpgSql(postgres, name: "postgres", tags: ["ready"]); // Background job runner builder.Services.AddHostedService<BillingJobRunner>(); var app = builder.Build(); // Metrics app.UseHttpMetrics(); app.MapMetrics("/metrics"); // Health endpoints app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = r => r.Tags.Contains("ready") }); app.Run();} // Custom business metric: how many jobs were processedpublic static readonly Counter JobsProcessed = Metrics.CreateCounter( "billing_jobs_processed_total", "Total number of billing jobs processed."); internal sealed class BillingJobRunner : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { // Simulate “job processed” JobsProcessed.Inc(); await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); } }}
}
Explanation- This worker now exposes `/metrics` and health endpoints.- `billing_jobs_processed_total` is a **custom metric** you’ll graph in Grafana.- This is how you monitor background processing in real systems.. ## Docker: Containerize both .NET services correctly This is where my previous version was too hand-wavy. Here’s the correct, production-style approach: **multi-stage Dockerfiles**. OrderManagement.Api/Dockerfile:
csharp FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8080 EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILDCONFIGURATION=Release WORKDIR /src COPY ["OrderManagement.Api/OrderManagement.Api.csproj", "OrderManagement.Api/"] RUN dotnet restore "./OrderManagement.Api/OrderManagement.Api.csproj" COPY . . WORKDIR "/src/OrderManagement.Api" RUN dotnet build "./OrderManagement.Api.csproj" -c $BUILDCONFIGURATION -o /app/build
FROM build AS publish ARG BUILDCONFIGURATION=Release RUN dotnet publish "./OrderManagement.Api.csproj" -c $BUILDCONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "OrderManagement.Api.dll"]
Billing.Worker/Dockerfile:
csharp FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILDCONFIGURATION=Release WORKDIR /src COPY ["OrderManagement.Billing.Worker/OrderManagement.Billing.Worker.csproj", "OrderManagement.Billing.Worker/"] RUN dotnet restore "./OrderManagement.Billing.Worker/OrderManagement.Billing.Worker.csproj" COPY . . WORKDIR "/src/OrderManagement.Billing.Worker" RUN dotnet build "./OrderManagement.Billing.Worker.csproj" -c $BUILDCONFIGURATION -o /app/build
FROM build AS publish ARG BUILDCONFIGURATION=Release RUN dotnet publish "./OrderManagement.Billing.Worker.csproj" -c $BUILDCONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "OrderManagement.Billing.Worker.dll"]
Explanation- The SDK image builds the app.- The runtime image runs the published output.- Both listen on port 8080 inside their containers. ## Docker Compose: Start in the correct order (DB → apps → Prometheus → Grafana) Create `docker-compose.yml` in the solution root:
csharp services: postgres:
image: postgres:16environment: POSTGRES_DB: orders POSTGRES_USER: postgres POSTGRES_PASSWORD: postgresports: - "5433:5433"
orders-api:
build: context: . dockerfile: OrderManagement.Api/Dockerfileenvironment: ConnectionStrings__Postgres: Host=postgres;Port=5433;Database=orders;Username=postgres;Password=postgresports: - "8082:8080"depends_on: - postgres
billing-worker:
build: context: . dockerfile: OrderManagement.Billing.Worker/Dockerfileenvironment: ConnectionStrings__Postgres: Host=postgres;Port=5433;Database=orders;Username=postgres;Password=postgresports: - "8081:8080"depends_on: - postgres
prometheus:
image: prom/prometheus:latestvolumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:roports: - "9090:9090"depends_on: - orders-api - billing-worker
grafana:
image: grafana/grafana:latestvolumes: - ./ops/grafana/provisioning:/etc/grafana/provisioning:ro - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:roports: - "3003:3000"depends_on: - prometheus
Explanation- Postgres must exist before readiness checks can pass.- Both .NET apps start after Postgres.- Prometheus starts after apps because it needs targets to scrape.- Grafana starts after Prometheus because it needs a data source.  ## How to Add Scraping Configuration with Prometheus - /metrics Create `ops/prometheus/prometheus.yml` in your root folder of the solution:
csharp global: scrape_interval: 5s
scrape_configs:
jobname: "orders-api" metricspath: /metrics static_configs:
jobname: "billing-worker" metricspath: /metrics static_configs:
Explanation
ops/grafana/provisioning/datasources/datasource.yml
apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true
Explanation
Start the stack:
docker compose up --build
Now verify in this order:
http://localhost:8082/health/livehttp://localhost:8082/health/readyhttp://localhost:8081/health/ready
http://localhost:8082/metricshttp://localhost:8081/metrics

Look for:
http_requests_received_totalbilling_jobs_processed_total
http://localhost:9090
http://localhost:3000 (default login admin/admin)At this point, Prometheus is already running and successfully scraping metrics from both .NET services.
Grafana is also running, but it doesn’t show anything yet, because dashboards do not exist by default.
So, let's create the first dashboard.
Before creating a dashboard, Grafana needs to know where the metrics are coming from.
http://prometheus:9090 You should see a green confirmation message saying that the data source is working.
Now we can create a dashboard.
Grafana will now ask you to choose a data source.
At this point, you are inside the panel editor.

Let’s start with a very common and useful metric: HTTP requests per second for the Orders API.
Write the PromQL query
In the Query section, enter:
rate(http_requests_received_total{job="orders-api"}[1m])
You should get something like this:

Give it a name and save Dashboard.
Okay, what about the custom metric we have added: billing_jobs_processed_total
Let's use the same approach and in the Query section write:
rate(billing_jobs_processed_total[1m]) * 60
What this shows

For more observability tools, check out OpenTelemetry in .NET, Structured Logging with Serilog, and Health Checks.
Monitoring is not something you “add later”.
It’s a skill. And like every skill, it’s built through:
In this article, you saw:
This is the baseline I expect in real .NET systems, not an advanced setup, not over-engineered, just correct.
📦 Want the Full Source Code?
All source code from this article (projects, Docker setup, Prometheus config, Grafana dashboards) is available for free inside my private .NET community.
I’m currently building a course focused on production-grade .NET practices, not theory, not “hello world”.
Monitoring is a core chapter in that course.
We’ll go deeper into:
The course is currently available for $59.89.
Here is the HINT:👇 Community members get an even bigger discount. (shhh, I didn't say that)
So if you’re thinking: “I want the source code anyway…”
Joining the group first is simply the smarter move.
P.S. Follow me on YouTube.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.