Skip to main content
SEMastery
DevOpsbeginner

Service Discovery in .NET Microservices with HashiCorp Consul

A beginner-friendly guide to service discovery in .NET microservices using HashiCorp Consul, with registration, health checks, and lookups explained simply.

12 min readUpdated May 8, 2026

Think about a busy railway station in India. Trains arrive and leave all day. Platform numbers change. If you wrote on a piece of paper "the Chennai train is always on Platform 3," that note would be wrong tomorrow. So you do not trust the paper. You look at the big enquiry board, or you ask the enquiry counter. The board always shows the correct platform for right now.

Microservices have the same problem. Each service runs on some address and port. In the cloud, these addresses change all the time. Services start, stop, move, and multiply. If you write a fixed address in your code, that address becomes wrong the moment the service moves. You need an enquiry board for your services. HashiCorp Consul is that enquiry board.

In this post you will learn what service discovery is, why fixed addresses break, and exactly how to use Consul with .NET to register a service, check its health, and look it up by name. We will keep the language simple and the steps small.

What is service discovery?

Service discovery is a simple idea with a fancy name. It means: one service finds the address of another service by asking, instead of by remembering.

Let us say you have a shopping app made of small services:

  • An orders service that takes orders.
  • A payments service that charges money.
  • A shipping service that sends parcels.

The orders service needs to call payments. But where is payments today? On which machine? On which port? If there are three copies of payments running for speed, which copy should orders use?

A fixed URL cannot answer these questions, because the answers keep changing. Service discovery answers them at runtime.

The enquiry board idea: services ask by name and get the current address

There are two common styles of service discovery.

StyleWho looks up the addressSimple meaning
Client-sideThe calling service itselfOrders asks Consul, then calls payments directly.
Server-sideA load balancer or gatewayOrders calls a gateway, and the gateway asks Consul.

In this post we focus on the client-side style, because it is the easiest one to understand and to try on your own machine.

Meet HashiCorp Consul

Consul is a tool made by HashiCorp. It does a few useful jobs at once.

  • It keeps a registry: a live list of every service, its address, and its port.
  • It runs health checks: it keeps asking each service "are you okay?" on a schedule.
  • It answers lookups: it tells you the healthy addresses for a service name, over HTTP or DNS.
  • It also offers a key value store for small bits of shared settings.

The most important habit Consul gives you is this: Consul never returns an instance that is failing its health check. So when you ask "where is payments?", you only get copies that are actually working. That single rule prevents a lot of pain.

Consul runs as an agent on each machine. Your .NET service talks to the agent that sits next to it, usually at http://localhost:8500. The agents gossip with each other and keep a shared picture of the whole system.

The three jobs: register, check, look up

Everything we do with Consul fits into three small jobs. Keep these three words in your head and the rest is easy.

The Consul lifecycle for one service

Register
Health check
Look up
Deregister

Steps

1

Register

Tell Consul my name, address, port, and health URL

2

Health check

Consul keeps calling my health endpoint on a timer

3

Look up

Other services ask Consul for healthy copies of me

4

Deregister

On shutdown, remove myself from the registry

A service moves through these stages from start to stop

Let us walk through each job in order.

Job 1: Register the service

When your .NET service starts, it tells the local Consul agent who it is. It sends a small block of data: a name, an id, an address, a port, and the URL of a health endpoint. Consul stores this in the registry.

We will use the Consul.NET client library, which is a thin wrapper over the Consul HTTP API. First add the package.

dotnet add package Consul

Now register the service when the app starts. The code below registers a payments service and points Consul at a /health endpoint.

using Consul;
 
var builder = WebApplication.CreateBuilder(args);
 
// One Consul client for the whole app, pointing at the local agent.
builder.Services.AddSingleton<IConsulClient>(_ =>
    new ConsulClient(config =>
    {
        config.Address = new Uri("http://localhost:8500");
    }));
 
var app = builder.Build();
 
// Pick a stable id so we can deregister later.
var serviceId = $"payments-{Guid.NewGuid()}";
 
var registration = new AgentServiceRegistration
{
    ID = serviceId,
    Name = "payments",          // the friendly name others will ask for
    Address = "localhost",      // where this copy lives
    Port = 5001,                // the port this copy listens on
    Check = new AgentServiceCheck
    {
        HTTP = "http://localhost:5001/health",
        Interval = TimeSpan.FromSeconds(10),     // ask every 10 seconds
        Timeout = TimeSpan.FromSeconds(5),       // give up after 5 seconds
        DeregisterCriticalServiceAfter = TimeSpan.FromMinutes(1)
    }
};
 
// Register when the app has started.
var consul = app.Services.GetRequiredService<IConsulClient>();
await consul.Agent.ServiceRegister(registration);
 
app.MapGet("/health", () => Results.Ok("healthy"));
 
app.Run();

A few things to notice. The Name is "payments". Many copies can share this same name, and that is the point. The ID is unique per copy, so Consul can tell the copies apart. The Check tells Consul how to confirm the copy is alive.

Job 2: Let Consul check health

Once registered, Consul does not just trust your service forever. It keeps calling the health URL every ten seconds. If the answer is a 2xx status code, the copy is passing. If the call fails or times out, the copy starts to look sick.

Health check loop between Consul and a service

You can make the health endpoint smarter. ASP.NET Core has a built-in health checks system that can test the database, disk, or anything else before saying "healthy". A simple version looks like this.

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy());
 
var app = builder.Build();
 
// Now /health reports the result of all registered checks.
app.MapHealthChecks("/health");

Here is the rule worth repeating: a copy that fails its health check is hidden from lookups. So if payments copy 2 loses its database connection, Consul stops handing it out, and orders never talks to a broken copy.

Job 3: Look up a service by name

Now the fun part. The orders service wants to call payments, but it does not know any address. It asks Consul for the healthy copies of "payments". Consul replies with a list. Orders picks one and calls it.

using Consul;
 
public class PaymentsLookup
{
    private readonly IConsulClient _consul;
 
    public PaymentsLookup(IConsulClient consul) => _consul = consul;
 
    public async Task<Uri> GetHealthyAddressAsync()
    {
        // passingOnly: true means only healthy copies come back.
        var result = await _consul.Health.Service("payments", tag: null,
            passingOnly: true);
 
        var entries = result.Response;
        if (entries.Length == 0)
            throw new InvalidOperationException("No healthy payments copy found.");
 
        // Simple round-robin or random pick across the healthy copies.
        var chosen = entries[Random.Shared.Next(entries.Length)];
        var service = chosen.Service;
 
        return new Uri($"http://{service.Address}:{service.Port}");
    }
}

Notice passingOnly: true. That single flag is what keeps traffic away from sick copies. The orders service does not need to know how many copies exist or where they live. It just asks by name and gets a working address.

A call from orders to payments

Ask Consul
Get healthy list
Pick one
Call it

Steps

1

Ask Consul

Health.Service('payments', passingOnly: true)

2

Get healthy list

Consul returns only working copies

3

Pick one

Choose by random or round-robin

4

Call it

Send the HTTP request to that address

The lookup happens just before the real call

Cleaning up: deregister on shutdown

When a service stops, it should remove itself from the registry. If it does not, Consul will eventually notice the failing health checks and clean it up using DeregisterCriticalServiceAfter. But a polite shutdown is faster and cleaner.

// Run this when the app is stopping.
app.Lifetime.ApplicationStopping.Register(() =>
{
    var consul = app.Services.GetRequiredService<IConsulClient>();
    consul.Agent.ServiceDeregister(serviceId).GetAwaiter().GetResult();
});

This matters during deploys. When you roll out a new version, old copies shut down and new copies start. Clean deregistration means there is never a window where Consul points traffic at a copy that has already gone away.

Running Consul on your machine

You do not need a cloud account to try this. You can run a single Consul agent in development mode with Docker.

docker run -d --name consul -p 8500:8500 hashicorp/consul agent -dev -client=0.0.0.0

After it starts, open http://localhost:8500 in your browser. You will see the Consul UI. When your .NET service registers, it will appear in the Services list with a green tick if its health check is passing. This dashboard is the easiest way to learn, because you can watch services appear and disappear in real time.

How the whole picture fits together

Let us zoom out and see all the pieces at once. Two services register. Consul checks their health. Orders looks up payments and calls a healthy copy.

The full flow with two payments copies behind one name

Here is a quick comparison of life before and after service discovery. It shows why teams take the trouble to add Consul.

QuestionWithout service discoveryWith Consul
Where is the other service?Hardcoded in config or codeAsked by name at runtime
What if it moves?You edit and redeployNothing to change
What if a copy is broken?You might still call itIt is hidden automatically
How do you add more copies?Update every caller's configJust start them; they register
How do you know what is healthy?You guess or check by handConsul checks on a schedule

A few tips for real projects

These small habits will save you trouble later.

  • Always set a health check. A service without a health check looks healthy to Consul even when it is broken. The health check is your safety net.
  • Use stable, unique service ids. If every restart creates a brand new id and the old one is never removed, the registry fills with ghosts. Deregister on shutdown, and let DeregisterCriticalServiceAfter catch the rest.
  • Do not hardcode localhost in production. During local testing localhost is fine. In the cloud, register the real reachable address of the container or host.
  • Cache lookups for a short time. Asking Consul on every single request adds load. Many teams cache the healthy list for a few seconds and refresh it. The Consul .NET client also supports blocking queries that wait for changes.
  • Combine with retries. Even healthy copies sometimes fail a single call. Pair Consul lookups with a retry policy so one hiccup does not break a request.

How does this compare to .NET Aspire?

You may have seen that .NET Aspire also offers service discovery. The idea is the same: call a service by name, not by address. The difference is in scope. Aspire's built-in discovery is great for local development and simple deployments, and it can read addresses from configuration without any extra server. Consul is a separate, language-neutral system that also does health checking, a key value store, and service mesh features, and it works the same way whether the caller is .NET, Java, Go, or anything else. Many teams use Aspire while building locally and Consul (or a similar registry) in larger production setups. They are not enemies; they solve the same problem at different sizes.

One small note on licensing, since people often ask: the popular .NET messaging libraries MassTransit and MediatR moved to a commercial license for their newer versions. That change does not affect Consul or the Consul .NET client, which are separate projects. Just keep it in mind if you plan to combine service discovery with one of those messaging tools in the same app.

Quick recap

  • Service discovery lets one service find another by name, not by a fixed address, so moving and scaling services does not break callers.
  • Consul is an enquiry board for your services. It keeps a registry, runs health checks, and answers lookups over HTTP or DNS.
  • The whole job is three small steps: register on start, let Consul health check on a timer, and look up healthy copies by name before each call.
  • Use passingOnly: true so you only ever get healthy copies, and Consul hides sick ones for you.
  • Deregister on shutdown, set a real health endpoint, and cache lookups a little to keep things fast.
  • Consul and .NET Aspire solve the same problem; Aspire shines for local and simple setups, Consul shines for larger, mixed-language systems.

References and further reading

Related Posts