BrandGhost
Serilog Best Practices for Production .NET Applications

Serilog Best Practices for Production .NET Applications

Configuring Serilog best practices in a local development environment is straightforward. Making Serilog behave well in production -- under load, across distributed systems, with sensitive data flying through -- requires deliberate choices. The defaults that work well for development often become problems at scale.

This guide covers production-ready Serilog configuration: async sinks to avoid blocking your application threads, minimum level tuning to control volume and cost, PII scrubbing to stay compliant, correlation IDs for distributed tracing, structured message templates for queryability, and performance patterns that matter in high-throughput services.

Before diving into production configuration, make sure you have the fundamentals in place with the complete Serilog guide and setting up Serilog in ASP.NET Core. For adding automatic context to every log entry, see Serilog enrichers.


Use Async Sinks in Production

Synchronous sinks -- the default for file and many third-party sinks -- write to the destination on the calling thread. In a high-traffic ASP.NET Core application, this means your request-handling thread blocks while waiting for disk I/O or a network write to complete.

Always wrap production sinks in Async:

dotnet add package Serilog.Sinks.Async
.WriteTo.Async(a =>
{
    a.File("logs/app.log", rollingInterval: RollingInterval.Day);
    a.Seq("http://your-seq-server:5341");
})

Serilog.Sinks.Async uses a background thread and an in-memory queue. The calling thread enqueues the event and returns immediately. The background thread drains the queue and writes to the wrapped sink at its own pace.

Configuring the queue:

.WriteTo.Async(a => a.File("logs/app.log"), bufferSize: 10000, blockWhenFull: false)
  • bufferSize: Default is 10,000 events. Increase for high-volume services.
  • blockWhenFull: false (default): Drop events rather than blocking the caller when the queue is full. For most production scenarios this is correct -- you don't want your application to slow down because the log server is overwhelmed.
  • blockWhenFull: true: Block the caller instead of dropping events. Use only for critical audit logs where dropping is unacceptable.

The Console sink is fast enough to omit from async wrapping in most cases, but for File and network sinks it's non-negotiable for production.


Tune Minimum Levels for Production

Shipping every Debug and Verbose event to production sinks is expensive: storage costs go up, Seq/Elasticsearch ingestion rates increase, and log search becomes noisier. Tune minimum levels to only ship what you need to diagnose production issues.

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System.Net.Http.HttpClient": "Warning"
      }
    }
  }
}

These overrides silence the framework noise that ASP.NET Core generates on every request, EF Core generates for every query, and HttpClientFactory generates for every handler lifecycle. Without these, a moderate-traffic service can generate tens of thousands of Debug/Information events per minute from framework internals alone.

For development, use a separate appsettings.Development.json with lower minimums:

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Debug",
      "Override": {
        "Microsoft.AspNetCore": "Information",
        "Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    }
  }
}

This way, you see SQL queries and request pipeline events locally without shipping them to production sinks.


Never Use String Interpolation in Log Messages

This is the single most common Serilog anti-pattern:

// WRONG -- string allocated even if filtered; no structured properties
_logger.LogInformation($"Order {order.Id} processed in {stopwatch.ElapsedMilliseconds}ms");

// CORRECT -- template parsed once, values are structured fields
_logger.LogInformation("Order {OrderId} processed in {ElapsedMs}ms", order.Id, stopwatch.ElapsedMilliseconds);

With string interpolation, the string is built in memory before the log level check runs. If Information is filtered out, you've wasted allocation for nothing. With a message template, Serilog checks the level first and only formats the message if it will be emitted. More importantly, OrderId and ElapsedMs become queryable structured fields in Seq and Elasticsearch.

For high-frequency paths, use source-generated logging:

public static partial class AppLoggers
{
    [LoggerMessage(EventId = 200, Level = LogLevel.Information,
        Message = "Order {OrderId} processed in {ElapsedMs}ms")]
    public static partial void OrderProcessed(
        this ILogger logger, string orderId, long elapsedMs);
}
_logger.OrderProcessed(order.Id, stopwatch.ElapsedMilliseconds);

This is zero-allocation -- the [LoggerMessage] source generator produces a method that does the level check inline, with no string construction or boxing. It works transparently through the MEL bridge into Serilog's pipeline.


Destructure Objects Thoughtfully

Serilog's @ destructuring operator captures object properties as structured fields:

_logger.LogInformation("Order created {@Order}", order);

This is powerful but has pitfalls in production:

Pitfall 1: Destructuring large or circular objects

Destructuring a 50-property entity with navigation properties attached can emit enormous log events and cause circular reference exceptions. Apply destructuring limits:

.Destructure.ToMaximumDepth(3)
.Destructure.ToMaximumStringLength(500)
.Destructure.ToMaximumCollectionCount(10)

Pitfall 2: Accidentally logging sensitive data (see PII section below)

Best practice: Create dedicated log DTOs for complex objects

public record OrderLogEntry(string Id, string CustomerId, decimal Amount, int ItemCount);

_logger.LogInformation("Order created {@OrderSummary}",
    new OrderLogEntry(order.Id, order.CustomerId, order.Total, order.Items.Count));

Log DTOs ensure you control exactly what fields are captured, avoiding both over-logging and PII exposure.


Protect PII and Sensitive Data

Logs frequently contain sensitive data by accident: user emails in authentication logs, credit card numbers in payment errors, health data in request bodies. In production, treat log output as a potential data breach vector.

Destructuring policies for PII masking:

using Serilog.Core;
using Serilog.Events;

public class PiiMaskingDestructuringPolicy : IDestructuringPolicy
{
    private static readonly HashSet<string> SensitiveFields = new(StringComparer.OrdinalIgnoreCase)
    {
        "Password", "CreditCardNumber", "Ssn", "Token", "Secret",
        "ApiKey", "Authorization", "Email"
    };

    public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result)
    {
        result = null!;
        return false; // Handled per-property below
    }
}

public class SensitivePropertyEnricher : ILogEventEnricher
{
    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        foreach (var property in logEvent.Properties.ToList())
        {
            if (IsSensitiveKey(property.Key))
            {
                logEvent.RemovePropertyIfPresent(property.Key);
                logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(property.Key, "***REDACTED***"));
            }
        }
    }

    private static bool IsSensitiveKey(string key) =>
        key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
        key.Contains("token", StringComparison.OrdinalIgnoreCase) ||
        key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
        key.Contains("email", StringComparison.OrdinalIgnoreCase);
}
.Enrich.With<SensitivePropertyEnricher>()

This acts as a Proxy pattern for your log events -- intercepting and scrubbing sensitive properties before they reach any sink.

For a library-based approach using Destructurama.Attributed:

dotnet add package Destructurama.Attributed
public class PaymentRequest
{
    public string OrderId { get; set; } = "";

    [NotLogged] // Excluded from destructuring
    public string CardNumber { get; set; } = "";

    [LogMasked(ShowFirst = 4)] // Shows first 4 chars: "4111***"
    public string CardHolder { get; set; } = "";
}
.Destructure.UsingAttributes()

Add Correlation IDs for Distributed Tracing

In microservices or any system where a request fans out to multiple services, correlation IDs link all log entries for a single user action across every service boundary.

Middleware-based correlation (reliable, works everywhere):

public class CorrelationMiddleware
{
    private readonly RequestDelegate _next;

    public CorrelationMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
            ?? context.TraceIdentifier;

        context.Response.Headers["X-Correlation-ID"] = correlationId;

        using (Serilog.Context.LogContext.PushProperty("CorrelationId", correlationId))
        {
            await _next(context);
        }
    }
}

Every log entry for the request -- from every class, every layer -- carries CorrelationId. When a downstream service receives the request, it reads X-Correlation-ID from the headers and does the same, linking the chain.

OpenTelemetry integration:

dotnet add package Serilog.Enrichers.Span
.Enrich.WithSpan() // Adds TraceId, SpanId from Activity

If you're using OpenTelemetry tracing, WithSpan() links Serilog log entries to the distributed trace ID automatically -- you can jump from a log entry in Seq to the trace in Jaeger/Zipkin.


Configure Log Retention and Rolling

File sinks should always be configured with rolling and retention limits:

.WriteTo.Async(a => a.File(
    path: "logs/app-.log",
    rollingInterval: RollingInterval.Day,
    retainedFileCountLimit: 14,         // Keep 14 days
    fileSizeLimitBytes: 100_000_000,    // Max 100MB per file
    rollOnFileSizeLimit: true           // Roll when size limit hit
))

Without retainedFileCountLimit, Serilog accumulates log files indefinitely. On a busy service, log files can fill a disk in hours. 14 days is a reasonable default; production compliance requirements may specify longer retention via a centralized log store rather than local files.


Avoid Logging in Tight Loops

Even with async sinks and level filtering, generating millions of log events is expensive. Avoid logging inside tight loops:

// WRONG -- potentially millions of log entries
foreach (var item in largeCollection)
{
    _logger.LogDebug("Processing item {ItemId}", item.Id);
    Process(item);
}

// BETTER -- log summary, log errors only
var processed = 0;
var errors = new List<string>();

foreach (var item in largeCollection)
{
    try
    {
        Process(item);
        processed++;
    }
    catch (Exception ex)
    {
        errors.Add(item.Id);
        _logger.LogWarning(ex, "Failed to process item {ItemId}", item.Id);
    }
}

_logger.LogInformation("Batch complete: {Processed} processed, {ErrorCount} errors", processed, errors.Count);

The Chain of Responsibility pattern applied to log processing: don't generate events that will be dropped two steps downstream. Apply level discipline at the source.


Log Meaningful Boundaries, Not Implementation Details

Logs are most valuable when they capture business events and system boundaries, not internal implementation steps:

// NOISE -- internal implementation details nobody needs
_logger.LogDebug("Entering CalculateTotal method");
_logger.LogDebug("Retrieved {Count} line items from collection", items.Count);
_logger.LogDebug("Subtotal calculated: {Subtotal}", subtotal);
_logger.LogDebug("Tax rate: {TaxRate}", taxRate);
_logger.LogDebug("Exiting CalculateTotal");

// VALUE -- what happened at the boundary
_logger.LogInformation("Order {OrderId} total calculated: {Total} ({ItemCount} items, {TaxRate}% tax)",
    orderId, total, itemCount, taxRate);

In production, log:

  • Requests received and responses sent (use UseSerilogRequestLogging())
  • External service calls (start, end, duration, outcome)
  • Business events (order placed, payment processed, email sent)
  • Configuration changes
  • Errors and exceptions (always)
  • Performance warnings (operations exceeding thresholds)

Use Health Checks with Log-Based Alerting

Pair Serilog with health checks to surface diagnostic state in a structured, queryable way:

// Log health check results as structured events
builder.Services.AddHealthChecks()
    .AddCheck("database", () =>
    {
        // Your check logic
        return HealthCheckResult.Healthy();
    });

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = async (ctx, report) =>
    {
        var result = new
        {
            Status = report.Status.ToString(),
            Checks = report.Entries.Select(e => new
            {
                e.Key,
                Status = e.Value.Status.ToString(),
                e.Value.Description,
                e.Value.Duration
            })
        };

        _logger.LogInformation("Health check completed: {@HealthCheckResult}", result);
        await ctx.Response.WriteAsJsonAsync(result);
    }
});

Structured health check events in Seq allow you to alert on degraded status, track health check duration trends, and correlate health degradations with error spikes.


Complete Production Configuration Example

// Two-stage: bootstrap logger for startup errors
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((ctx, services, config) =>
        config
            .ReadFrom.Configuration(ctx.Configuration)
            .ReadFrom.Services(services)
            .Enrich.FromLogContext()
            .Enrich.WithMachineName()
            .Enrich.WithEnvironmentName()
            .Enrich.WithThreadId()
            .Enrich.WithProperty("Application", "OrderApi")
            .Enrich.WithProperty("Version",
                Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown")
            .Destructure.ToMaximumDepth(3)
            .Destructure.ToMaximumStringLength(500)
            .Destructure.ToMaximumCollectionCount(10)
            .WriteTo.Console(new ExpressionTemplate(
                "[{@t:HH:mm:ss} {@l:u3}] {#if SourceContext is not null}{Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1)}: {#end}{@m}
{@x}"),
                restrictedToMinimumLevel: LogEventLevel.Debug)
            .WriteTo.Async(a =>
            {
                a.File(
                    path: "logs/app-.log",
                    rollingInterval: RollingInterval.Day,
                    retainedFileCountLimit: 14,
                    fileSizeLimitBytes: 100_000_000,
                    rollOnFileSizeLimit: true,
                    restrictedToMinimumLevel: LogEventLevel.Warning);
                a.Seq(
                    ctx.Configuration["Seq:ServerUrl"] ?? "http://localhost:5341",
                    restrictedToMinimumLevel: LogEventLevel.Information);
            }));

    var app = builder.Build();

    app.UseMiddleware<CorrelationMiddleware>();
    app.UseSerilogRequestLogging(opts =>
    {
        opts.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
        {
            diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress);
            diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value ?? "anonymous");
        };
    });

    await app.RunAsync();
}
catch (Exception ex) when (ex is not HostAbortedException)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    await Log.CloseAndFlushAsync();
}

Frequently Asked Questions

Should I use async sinks in production?

Yes, always. Synchronous file and network sinks block the calling thread waiting for I/O. In a web application under load, this creates contention on request threads and degrades throughput. Serilog.Sinks.Async decouples logging I/O from request processing using a background thread and in-memory queue.

How do I prevent PII from appearing in logs?

Avoid logging raw objects that contain PII. Use log DTOs that include only the fields you need. For destructured objects, use Destructurama.Attributed with [NotLogged] attributes on sensitive properties. For defense-in-depth, add a scrubbing enricher that redacts known sensitive property names before events reach sinks.

What minimum log level should I use in production?

Start with Information as the default. Override noisy framework namespaces (Microsoft, Microsoft.AspNetCore, Microsoft.EntityFrameworkCore) to Warning. Tune based on observed log volume and storage cost. Never ship Debug or Verbose events to production sinks -- they're for local development only.

How do I configure different log levels for different environments?

Use appsettings.json for production defaults and override in appsettings.Development.json with lower minimum levels. Serilog's ReadFrom.Configuration() automatically merges environment-specific appsettings. The minimum level configuration is under "Serilog": { "MinimumLevel": { ... } }.

What happens when the async buffer is full?

By default (blockWhenFull: false), Serilog drops the oldest events in the queue to make room. A counter increments internally. For critical audit logs, set blockWhenFull: true to block the calling thread instead of dropping events. For general application logging, dropping is almost always preferable to slowing down the application.

How do I add correlation IDs across microservices?

Propagate a X-Correlation-ID header between services. Each service reads the header in middleware, pushes it to LogContext.PushProperty("CorrelationId", ...), and forwards it in all outgoing requests via HttpClient headers. Use Serilog.Enrichers.Span if you're on OpenTelemetry -- it links Serilog events to the distributed trace ID automatically.

Should I log exceptions with the exception object or as a string?

Always pass the exception object to Serilog: _logger.LogError(ex, "Operation {OperationId} failed", operationId). Serilog serializes the full exception including type, message, stack trace, and inner exceptions as structured fields. Logging ex.ToString() collapses everything to a single string that can't be queried or filtered effectively in Seq or Elasticsearch.

Serilog Sinks: Console, File, Seq, and More

Master Serilog sinks in .NET: console, file, Seq, async, and production sinks. Learn to configure multiple output destinations with filtering and formatting.

Serilog in .NET: Complete Guide to Structured Logging

Learn Serilog in .NET from scratch. Setup, sinks, enrichers, appsettings configuration, and best practices for ASP.NET Core with .NET 9 and .NET 10.

How to Set Up Serilog in ASP.NET Core: Step-by-Step Guide

Learn how to set up Serilog in ASP.NET Core with two-stage initialization, appsettings.json configuration, and structured request logging. .NET 9 and .NET 10.

An error has occurred. This application may no longer respond until reloaded. Reload