Worker Services
Build a queue consumer on Host.CreateApplicationBuilder with no HTTP, give each message its own DI scope, and run it in a container.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<OrderPlacedConsumer>();
await builder.Build().RunAsync();A worker service is a .NET process with a host but no web server: a queue consumer, an outbox relay, a nightly export. It gets the same DI, configuration, logging and graceful shutdown as an API, without Kestrel, routing or an open port.
The template
dotnet new worker -n Orders.Worker creates a project on the Microsoft.NET.Sdk.Worker SDK with a package reference to Microsoft.Extensions.Hosting, a Worker class deriving from BackgroundService, and this Program.cs:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
Configuration works as in an API: appsettings.json, appsettings.{Environment}.json, environment variables, command line. The environment comes from DOTNET_ENVIRONMENT; a worker ignores ASPNETCORE_ENVIRONMENT, so a manifest copied from the API leaves the worker running as Production with the wrong settings file.
Anatomy of a consumer
- One loop in
ExecuteAsync: receive, process, acknowledge, repeat untilstoppingTokenis cancelled. - One DI scope per message, created with
IServiceScopeFactory.CreateAsyncScope(). The worker is a singleton; aDbContextinjected into its constructor would live for days and grow a change tracker without limit. - Acknowledge after success. Brokers redeliver unacknowledged messages, so a crash means a retry, not a loss. That makes delivery at-least-once, and handlers must be idempotent.
- Per-message error handling. Catch, log and dead-letter a poison message. An exception that escapes
ExecuteAsyncappliesBackgroundServiceExceptionBehavior.StopHost(the default): the host logs it and stops the whole process, which is better than a worker that silently stopped consuming while the pod looks healthy.
Running it in a container
| Question | API | Worker |
|---|---|---|
| Base image | aspnet:10.0 | runtime:10.0 (no ASP.NET Core) |
| Port and Service | 8080, a Kubernetes Service | None |
| Health | HTTP probes | Process exit plus restart, or a small health endpoint |
| Scaling | CPU or request rate | Queue depth; replicas compete for messages |
The decision teams face is health checking. A crashed worker exits and is restarted, but a stuck one (a deadlocked loop, a lost broker connection) looks alive. If that matters, host the worker in a WebApplication that only maps /health endpoints on a port, use aspnet:10.0, and keep everything else the same.
For systemd or Windows Services, the Microsoft.Extensions.Hosting.Systemd and Microsoft.Extensions.Hosting.WindowsServices packages add AddSystemd() and AddWindowsService(), which wire the host lifetime to the service manager.
What goes wrong in production
- A
catchwith no delay around a broker that is down: a hot loop at 100% CPU writing a log line per iteration. Back off. - Ignoring
stoppingTokenwhile waiting for work: the worker never stops and is SIGKILLed on every deploy. Passing it into the message's own work abandons that message instead; give it a bounded token. - A captive scoped service in the worker's constructor, caught only by
ValidateScopesin Development.
Example
When to use it
- Consume OrderPlaced messages from SQS and write invoices with a scoped repository per message, with no web server in the process.
- Deploy an outbox relay as a two-replica Deployment on the runtime:10.0 image with DOTNET_ENVIRONMENT and no Service.
- Add a liveness endpoint to a shipment consumer so Kubernetes restarts it when the polling loop stops making progress.
More examples
Dockerfile for a worker
There is no <code>EXPOSE</code> because nothing listens. The environment name is not baked in: the Deployment sets <code>DOTNET_ENVIRONMENT</code>, and when it is unset the host runs as Production. The <code>runtime:10.0</code> image is smaller than <code>aspnet:10.0</code> and has less to patch; switch to <code>aspnet</code> only if the worker hosts health endpoints with Kestrel. <code>USER $APP_UID</code> runs as the non-root <code>app</code> user (UID 1654) that the official images include.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Orders.Worker/Orders.Worker.csproj Orders.Worker/
RUN dotnet restore Orders.Worker/Orders.Worker.csproj
COPY . .
RUN dotnet publish Orders.Worker/Orders.Worker.csproj --no-restore -o /app
# runtime, not aspnet: a worker does not need the ASP.NET Core shared framework.
FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
# No ENV DOTNET_ENVIRONMENT here: the Deployment sets it (env: DOTNET_ENVIRONMENT),
# so one image runs as Staging in staging. Unset, the host defaults to Production.
USER $APP_UID
ENTRYPOINT ["dotnet", "Orders.Worker.dll"]Kubernetes Deployment with no Service
No ports, no Service, no HTTP probes: if the process exits, Kubernetes restarts it. The two replicas compete for messages on the same queue. The grace period is longer than the API's because a message in progress may take longer than a request, and the shutdown timeout is set below it so the host finishes before SIGKILL.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-worker
namespace: shop
spec:
replicas: 2
selector:
matchLabels:
app: orders-worker
template:
metadata:
labels:
app: orders-worker
spec:
terminationGracePeriodSeconds: 60
containers:
- name: worker
image: registry.example.com/shop/orders-worker:2.3.0
env:
- name: DOTNET_ENVIRONMENT
value: Production
- name: DOTNET_SHUTDOWNTIMEOUTSECONDS
value: "45"
- name: Broker__QueueName
value: orders-placed
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
memory: 384MiA worker that also answers health probes
A pure worker can only signal trouble by exiting. A consumer stuck on a dead connection keeps running and looks healthy. Hosting it in a slim <code>WebApplication</code> with one liveness endpoint, backed by a heartbeat the loop updates, lets Kubernetes restart a stuck instance. The trade-off is the <code>aspnet</code> base image and an open port.
using Microsoft.Extensions.Diagnostics.HealthChecks;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddSingleton<ConsumerHeartbeat>();
builder.Services.AddHostedService<ShipmentConsumer>();
builder.Services.AddHealthChecks()
.AddCheck<ConsumerHeartbeatCheck>("consumer", tags: ["live"]);
var app = builder.Build();
// The only HTTP surface: probes on the pod port, not exposed through a Service.
app.MapHealthChecks("/health/live");
app.Run();
sealed class ConsumerHeartbeat
{
private long _lastBeatTicks = DateTimeOffset.UtcNow.UtcTicks;
public void Beat() => Interlocked.Exchange(ref _lastBeatTicks, DateTimeOffset.UtcNow.UtcTicks);
public TimeSpan SinceLastBeat => DateTimeOffset.UtcNow - new DateTimeOffset(Interlocked.Read(ref _lastBeatTicks), TimeSpan.Zero);
}
sealed class ConsumerHeartbeatCheck(ConsumerHeartbeat heartbeat) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct) =>
Task.FromResult(heartbeat.SinceLastBeat < TimeSpan.FromMinutes(2)
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy($"No poll for {heartbeat.SinceLastBeat.TotalSeconds:F0}s"));
}
sealed class ShipmentConsumer(ConsumerHeartbeat heartbeat) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
heartbeat.Beat(); // a loop that hangs stops beating and fails the liveness probe
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
Discussion