šŸ”„ Pragmatic .NET Code Rules Course is on Presale - 40% off!BUY NOW

How to Monitor .NET Applications in Production with Health Checks, Prometheus, and Grafana

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.

Why .NET Monitoring Is Not Optional in Production

A .NET app rarely ā€œdiesā€ in production. More often: • the API is running but can’t reach the database • requests start timing out because connection pool is exhausted • background jobs silently stop, but the process remains alive • latency increases after a deployment, and you notice it hours later That’s why production monitoring needs two layers:

  1. Health checks
    • ā€œIs the service alive? Is it ready?
  2. Metrics
    • ā€œHow is it behaving over time?ā€ This guide builds a complete .NET monitoring setup with: • ASP.NET Core health checks (liveness + readiness) • PostgreSQL dependency health • Prometheus /metrics endpoint • Grafana dashboards • Docker Compose to run the entire stack locally like production • custom metrics you can extend later

What Problem Health Checks and Metrics Actually Solve

Before writing code, let’s be clear about why this setup exists. In production, you need answers to questions like: • Is the service running or just stuck? • Is it ready to receive traffic? • Are dependencies healthy? • Are background jobs actually executing? • Is performance degrading over time?

Health checks and metrics solve different problems:

Health checks answer binary questions • Is the service alive? • Is it ready to serve requests? Metrics answer behavioral questions • How many requests per second? • How long do requests take? • How many jobs were processed? • How often do failures happen? You need both.

What Prometheus Is?

Prometheus is a time-series metrics system. It does three key things: • scrapes metrics from apps (pull model) • stores them over time • lets you query them using PromQL Important detail for juniors:

āœ… Your app exposes /metrics āœ… Prometheus periodically calls /metrics āœ… Prometheus stores the numeric values āŒ Prometheus is not a dashboard

What Grafana Is?

Grafana is a visualization layer. It: • connects to Prometheus as a data source (in our case) • turns metrics into dashboards • can trigger alerts Prometheus stores data. Grafana makes it visible.

Understanding Liveness and Readiness Health Checks in .NET

One of the most common production mistakes is exposing a single health endpoint.

That leads to broken deployments and unnecessary restarts.

Liveness checks

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 checks

Readiness answers a different question:

Is the service ready to handle traffic?

This must check: • databases • external APIs • message brokers

If readiness fails, traffic should be stopped, but the service should not be killed.

We will expose two endpoints: • /health/live • /health/ready

The Demo System We’ll Build (Two .NET Services + PostgreSQL)

We’ll build a small but realistic system consisting of:

  1. Orders API • ASP.NET Core Web API • Exposes health checks • Exposes /metrics for Prometheus
  2. Billing Worker • Background service • Periodically processes jobs • Exposes health checks • Includes custom metric: billing_jobs_processed_total
  3. PostgreSQL database • Used as a readiness dependency
  4. Prometheus • Scrapes metrics from both services
  5. Grafana • Visualizes metrics via dashboards All services will run locally using Docker Compose, exactly like a real environment.

Orders API: Implementing Health Checks and Metrics

Add required NuGet packages:

C#
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):

C#
var postgres = builder.Configuration.GetConnectionString("Postgres") ?? "Host=localhost;Port=5432;Database=orders;Username=postgres;Password=postgres";Ā // Health checksbuilder.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:

C#
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

C#
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 processed public 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:

C#
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS baseWORKDIR /appEXPOSE 8080EXPOSE 8081Ā FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildARG BUILD_CONFIGURATION=ReleaseWORKDIR /srcCOPY ["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 $BUILD_CONFIGURATION -o /app/buildĀ FROM build AS publishARG BUILD_CONFIGURATION=ReleaseRUN dotnet publish "./OrderManagement.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=falseĀ FROM base AS finalWORKDIR /appCOPY --from=publish /app/publish .ENTRYPOINT ["dotnet", "OrderManagement.Api.dll"]

Billing.Worker/Dockerfile:

C#
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS baseWORKDIR /appĀ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildARG BUILD_CONFIGURATION=ReleaseWORKDIR /srcCOPY ["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 $BUILD_CONFIGURATION -o /app/buildĀ FROM build AS publishARG BUILD_CONFIGURATION=ReleaseRUN dotnet publish "./OrderManagement.Billing.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=falseĀ FROM base AS finalWORKDIR /appCOPY --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:

C#
services: postgres: image: postgres:16 environment: POSTGRES_DB: orders POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - "5433:5433"Ā  orders-api: build: context: . dockerfile: OrderManagement.Api/Dockerfile environment: ConnectionStrings__Postgres: Host=postgres;Port=5433;Database=orders;Username=postgres;Password=postgres ports: - "8082:8080" depends_on: - postgresĀ  billing-worker: build: context: . dockerfile: OrderManagement.Billing.Worker/Dockerfile environment: ConnectionStrings__Postgres: Host=postgres;Port=5433;Database=orders;Username=postgres;Password=postgres ports: - "8081:8080" depends_on: - postgresĀ  prometheus: image: prom/prometheus:latest volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: - "9090:9090" depends_on: - orders-api - billing-workerĀ  grafana: image: grafana/grafana:latest volumes: - ./ops/grafana/provisioning:/etc/grafana/provisioning:ro - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:ro ports: - "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.

Docker Compose Up

How to Add Scraping Configuration with Prometheus - /metrics

Create ops/prometheus/prometheus.yml in your root folder of the solution:

C#
global: scrape_interval: 5sĀ scrape_configs: - job_name: "orders-api" metrics_path: /metrics static_configs: - targets: - "orders-api:8080"Ā  - job_name: "billing-worker" metrics_path: /metrics static_configs: - targets: - "billing-worker:8080"

Explanation • Prometheus scrapes every 5 seconds. • Each .NET service becomes its own job. • targets are Docker service names - Docker Compose provides DNS for them.

How to Add a Grafana Container: Provision the Prometheus datasource automatically

ops/grafana/provisioning/datasources/datasource.yml

C#
apiVersion: 1Ā datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true

Explanation • Grafana will start with Prometheus already connected. • This is how teams avoid the ā€œworks on my machineā€ dashboard setup.

Run everything and verify each layer

Start the stack:

C#
docker compose up --build

Now verify in this order:

1) Check health endpoints

• Orders API liveness: http://localhost:8082/health/live • Orders API readiness: http://localhost:8082/health/ready • Worker readiness: http://localhost:8081/health/ready

HealthCheck endpoints

2) Check /metrics

• Orders API metrics: http://localhost:8082/metrics • Worker metrics: http://localhost:8081/metrics Metrics

Look for: • http_requests_received_total • billing_jobs_processed_total

3) Check Prometheus targets

• Prometheus UI: http://localhost:9090 • Go to Status → Targets • Both targets should be UP Prometheus

4) Check the Grafana dashboard

• Grafana: 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.

  1. In the left-hand menu, click Connections → Data sources
  2. You should already see Prometheus in the list • If you used provisioning, it will be there automatically
  3. Click on Prometheus
  4. Verify the URL: http://prometheus:9090
  5. Click Save & Test

You should see a green confirmation message saying that the data source is working.

Now we can create a dashboard.

  1. In the left-hand menu, click Dashboards
  2. Click New
  3. Click New dashboard
  4. Click Add visualization

Grafana will now ask you to choose a data source.

  1. Select Prometheus

At this point, you are inside the panel editor. Grafana Dashboard

5) Create Your First Panel (HTTP Requests per Second)

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:

C#
rate(http_requests_received_total{job="orders-api"}[1m])

You should get something like this: Http Requests Grafana

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:

C#
rate(billing_jobs_processed_total[1m]) * 60

What this shows • approximate jobs per minute • easier for non-technical stakeholders to understand First Grafana Dashboard

For more observability tools, check out OpenTelemetry in .NET, Structured Logging with Serilog, and Health Checks.

Wrapping Up: Monitoring Is a Skill, Not a Tool

Monitoring is not something you ā€œadd laterā€.

It’s a skill. And like every skill, it’s built through: • understanding the concepts • wiring the system end to end • knowing what actually matters in production

In this article, you saw: • How liveness and readiness health checks should really be used • How to expose meaningful /metrics from .NET • How Prometheus scrapes those metrics • How Grafana turns them into answers • How to monitor background work, not just HTTP requests

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.

šŸŽ“ This Topic Goes Even Deeper in My Upcoming Course

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: • alerting strategies (what should wake you up at night) • PromQL basics for .NET developers • choosing the right metrics (and avoiding metric noise) • OpenTelemetry integration • structuring monitoring across multiple services • and common production mistakes teams repeat for years

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.

About the Author

Stefan Djokic is a Microsoft MVP and senior .NET engineer with extensive experience designing enterprise-grade systems and teaching architectural best practices.

There are 3 ways I can help you:

1

Pragmatic .NET Code Rules Course

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.

2

Design Patterns Ebooks

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.

3

Join 20,000+ subscribers

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
TheCodeMan.net

Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.