Setting up Serilog in ASP.NET Core is straightforward once you understand the recommended pattern -- but there are a few gotchas that trip up developers the first time. The most common mistake is wiring Serilog in a way that misses startup exceptions, leaves the bootstrap logger running too long, or doesn't take advantage of appsettings.json-driven configuration.
This guide walks through the complete, production-ready Serilog setup for ASP.NET Core on .NET 9 and .NET 10: package installation, two-stage initialization, appsettings.json configuration, structured request logging middleware, and verification that everything is working.
Before following this guide, make sure you're familiar with the basics of Serilog in .NET and the broader context of logging in .NET.
Watch this video on Dev Leader
Step 1: Install the Required Packages
Start by installing the core Serilog packages for ASP.NET Core:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Settings.Configuration
Serilog.AspNetCore provides UseSerilog() and the request logging middleware. Serilog.Settings.Configuration enables appsettings.json configuration. Then add the sinks you need:
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
For development with Seq:
dotnet add package Serilog.Sinks.Seq
For enrichers:
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
Step 2: Configure Two-Stage Initialization
The recommended pattern for Serilog setup in ASP.NET Core uses two-stage initialization. This solves a critical problem: if your application fails to start (bad configuration, missing dependency, database connection issue), you need a logger running before the host is built.
// Program.cs
using Serilog;
// Stage 1: Bootstrap logger -- captures errors before the host is built
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting application");
var builder = WebApplication.CreateBuilder(args);
// Stage 2: Full logger -- reads from appsettings.json and DI services
builder.Host.UseSerilog((context, services, configuration) =>
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
builder.Services.AddControllers();
// other service registrations...
var app = builder.Build();
app.UseSerilogRequestLogging(); // Add before UseRouting/UseEndpoints
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
return 1;
}
finally
{
// IMPORTANT: flush all buffered log events before the process exits
await Log.CloseAndFlushAsync();
}
return 0;
Why Two-Stage?
| Concern | Bootstrap Logger | Full Logger |
|---|---|---|
| Catches startup failures | ✅ Yes | ❌ Too late |
| appsettings.json config | ❌ Not available yet | ✅ Full access |
| Injected services (e.g., enrichers) | ❌ Not registered yet | ✅ Via ReadFrom.Services() |
| Runs | Before builder.Build() |
After builder.Host.UseSerilog() |
The try/catch/finally block ensures startup exceptions are logged to the bootstrap logger, and Log.CloseAndFlushAsync() in finally ensures all buffered log events are written before the process exits -- critical for async sinks (file, Seq, cloud services).
Step 3: Configure appsettings.json
Move your Serilog configuration from code into appsettings.json for per-environment flexibility. The full logger reads this via .ReadFrom.Configuration(context.Configuration).
appsettings.json (base configuration)
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{NewLine} {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId"
]
}
}
appsettings.Development.json (override for local dev)
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"System": "Information"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341"
}
}
]
}
}
appsettings.Production.json (cloud/container configuration)
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
}
}
]
}
}
For production containers, writing JSON to stdout lets your log aggregator (Fluent Bit, Logstash, Vector, Azure Monitor) parse structured properties without regex extraction.
Step 4: Add Serilog Request Logging
ASP.NET Core's default request logging emits multiple log entries per request -- one for routing, one for the controller, one for the response. Replace these with a single, structured entry per request:
// In Program.cs, before UseRouting
app.UseSerilogRequestLogging(options =>
{
// Customize the message template
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
// Control log level based on response
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (ex != null || httpContext.Response.StatusCode >= 500)
return LogEventLevel.Error;
if (httpContext.Response.StatusCode >= 400 || elapsed > 2000)
return LogEventLevel.Warning;
return LogEventLevel.Information;
};
// Add extra properties per request
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
if (httpContext.User.Identity?.IsAuthenticated == true)
{
diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value ?? "unknown");
}
};
});
This produces one line per request like:
[14:23:01 INF] HTTP GET /api/orders responded 200 in 12.4 ms
With structured properties: RequestMethod=GET, RequestPath=/api/orders, StatusCode=200, Elapsed=12.4, RequestHost=localhost, UserId=user-123.
Step 5: Using ILogger in Your Application
With Serilog configured, inject ILogger<T> exactly as you normally would. Serilog transparently receives all log events routed through the ILogger bridge:
public class OrderController : ControllerBase
{
private readonly ILogger<OrderController> _logger;
private readonly IOrderService _orderService;
public OrderController(
ILogger<OrderController> logger,
IOrderService orderService)
{
_logger = logger;
_orderService = orderService;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
_logger.LogInformation(
"Creating order for customer {CustomerId} with {ItemCount} items",
request.CustomerId,
request.Items.Count);
try
{
var order = await _orderService.CreateAsync(request);
_logger.LogInformation(
"Order {OrderId} created successfully for customer {CustomerId}",
order.Id,
request.CustomerId);
return Created($"/api/orders/{order.Id}", order);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex,
"Order validation failed for customer {CustomerId}: {ValidationErrors}",
request.CustomerId,
string.Join(", ", ex.Errors));
return BadRequest(ex.Errors);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error creating order for customer {CustomerId}",
request.CustomerId);
return StatusCode(500);
}
}
}
Note the message templates: {CustomerId}, {ItemCount}, {OrderId} become structured properties that Seq and other log aggregators can filter and query.
Step 6: Verify the Setup
Run your application and check that Serilog is working:
dotnet run
You should see output like:
[14:23:00 INF] Starting application
[14:23:00 INF] Now listening on: https://localhost:7001
[14:23:01 INF] HTTP GET /api/health responded 200 in 5.2 ms
[14:23:01 INF] Creating order for customer cust-456 with 3 items
[14:23:01 INF] Order ord-789 created successfully for customer cust-456
If you're using Seq locally:
docker run -d --name seq -e ACCEPT_EULA=Y -p 5341:80 datalust/seq
Then navigate to http://localhost:5341 to query your structured log properties.
Common Setup Issues
Serilog not logging anything
The most common cause is missing Log.CloseAndFlushAsync() -- buffered sinks haven't flushed before the process exits (particularly in short-lived test runs). For tests, call Log.CloseAndFlush() explicitly.
Startup exceptions not appearing
If your application crashes before builder.Host.UseSerilog() runs, the bootstrap logger handles it. Verify the try/catch/finally block wraps all of Program.cs, not just app.Run().
appsettings.json overrides not applying
The Serilog:Using array in appsettings.json must list the assembly names (not NuGet package names) of the sinks you're configuring. For example, Serilog.Sinks.Seq in Using corresponds to the Serilog.Sinks.Seq NuGet package.
High log volume in development
Override minimum levels in appsettings.Development.json to Debug for your own namespaces but keep Microsoft.* and System.* at Warning to avoid noise from framework internals.
Patterns in Serilog Setup
The two-stage initialization approach mirrors the Template Method pattern -- the bootstrap phase defines the skeleton (simple console sink), and the full initialization fills in the details from configuration. The sink pipeline itself follows the Chain of Responsibility: each sink in the WriteTo array independently processes every log event that meets its level threshold.
Frequently Asked Questions
What is the correct order of middleware for Serilog request logging?
Place app.UseSerilogRequestLogging() before UseRouting(), UseAuthorization(), and endpoint mapping. This ensures the middleware captures timing for the full pipeline including authentication and routing overhead.
Should I call Log.CloseAndFlush() or Log.CloseAndFlushAsync()?
Prefer await Log.CloseAndFlushAsync() in top-level Program.cs using the await top-level statement pattern in .NET 6+. Use Log.CloseAndFlush() in contexts where async isn't available (legacy Main methods, test teardown).
Can I use Serilog without appsettings.json configuration?
Yes. Full code-based configuration with .WriteTo.Console(), .WriteTo.File(), etc. works fine. However, appsettings.json configuration is recommended for ASP.NET Core because it supports per-environment overrides without recompiling.
Do I need Serilog.Settings.Configuration?
Only if you want appsettings.json configuration. If you're configuring Serilog entirely in code, you can skip this package. For most ASP.NET Core apps, appsettings.json configuration is strongly recommended.
How do I suppress specific framework log messages?
Use MinimumLevel.Override() in your configuration. For example, "Microsoft.EntityFrameworkCore.Database.Command": "Warning" suppresses EF Core SQL query logs. The Override section accepts any namespace prefix and applies to all loggers in that namespace.
How do I add Serilog to a .NET Worker Service?
The same pattern applies. Call builder.Services.AddLogging() is implicit, and builder.Host.UseSerilog(...) configures Serilog as the provider. Worker services don't have HTTP request logging, so skip UseSerilogRequestLogging().
What is the Serilog.AspNetCore package?
Serilog.AspNetCore is the main integration package. It provides the UseSerilog() host extension, UseSerilogRequestLogging() middleware, CreateBootstrapLogger() for two-stage initialization, and integration with ASP.NET Core's IDiagnosticContext for per-request structured properties.
Summary
Setting up Serilog in ASP.NET Core comes down to five steps: install packages, configure two-stage initialization in Program.cs, set up appsettings.json with per-environment overrides, add UseSerilogRequestLogging() middleware, and inject ILogger<T> as normal.
With this foundation in place, your application captures structured log data that you can query in Seq, export to cloud logging services, and use to diagnose issues with precision.
Continue with the series to learn about Serilog sinks for writing to multiple output destinations.

