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?
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:
123
dotnet add OrderManagement.Api package AspNetCore.HealthChecks.NpgSql
dotnet 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):
12345678910
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:
123456789101112131415
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.
• 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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
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:
1234567891011121314151617181920212223
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 BUILD_CONFIGURATION=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 $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./OrderManagement.Api.csproj" -c $BUILD_CONFIGURATION -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:
123456789101112131415161718192021
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=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 $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./OrderManagement.Billing.Worker.csproj" -c $BUILD_CONFIGURATION -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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
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.

How to Add Scraping Configuration with Prometheus - /metrics
Create ops/prometheus/prometheus.yml in your root folder of the solution:
12345678910111213141516
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
123456789
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:
12
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
2) Check /metrics
• Orders API metrics: http://localhost:8082/metrics
• Worker metrics: http://localhost:8081/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
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.
5. Select Prometheus
At this point, you are inside the panel editor.
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:
12
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:
1
rate(billing_jobs_processed_total[1m]) * 60
What this shows
• approximate jobs per minute
• easier for non-technical stakeholders to understand

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.
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:👇
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.