Skip to main content

Cleaning up old log files with Serilog

We got an alert from our ops team: the disk of one of our servers was filling up. The culprit was a log file that grew so fast it ate all the available space. The dev team didn't configured any cleanup, so the file just kept growing.

The application was already using Serilog. Let's have a look what options we have available to limit the amount of log data.

The default config

The simplest way to write a log file with Serilog looks like this:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/log.txt")
    .CreateLogger();

This works, but it writes everything to one single file. By default, no rolling happens and nothing gets deleted.

Remark: This isn’t entirely true. The file sink has a default fileSizeLimitBytes of 1 GB. When the file reaches that limit, Serilog silently stops writing log events. So you don’t fill your disk, but you lose your logs. That’s not much better.

Rolling files

The first step is to start a new file at regular intervals. This is done through the rollingInterval parameter:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
    .CreateLogger();

Remark: In older Serilog versions, a separate RollingFile sink existed. But now this functionally is all embedded directly into the File sink.

Serilog inserts the date in the file name, so you get files like log-20261010.txt.

The available intervals are:

  • Infinite: never roll (the default)
  • Year
  • Month
  • Day
  • Hour
  • Minute

Rolling on its own doesn’t clean up anything. You just get more files instead of one big file. For that we need the retention options.

Limit the number of files

retainedFileCountLimit controls how many log files are kept, including the current one:

.WriteTo.File(
    "logs/log-.txt",
    rollingInterval: RollingInterval.Day,
    retainedFileCountLimit: 14)

With a daily interval, this keeps roughly two weeks of logs. The default is 31. Pass null if you want to keep everything (you probably don’t).

Limit the age of files

Newer versions of Serilog.Sinks.File also have retainedFileTimeLimit. Instead of counting files, you define how old a file may get:

.WriteTo.File(
    "logs/log-.txt",
    rollingInterval: RollingInterval.Day,
    retainedFileTimeLimit: TimeSpan.FromDays(7))

If you set both retainedFileCountLimit and retainedFileTimeLimit, both rules apply.

Which one should you use? The count is predictable in terms of disk space, the time limit is predictable in terms of history. Often you want both.

Limit the size of a single file

Here is the catch that explains our incident. Daily rolling doesn’t help when a single day produces 40 GB of logs. We need to limit the size of each file as well:

.WriteTo.File(
    "logs/log-.txt",
    rollingInterval: RollingInterval.Day,
    fileSizeLimitBytes: 100 * 1024 * 1024,
    rollOnFileSizeLimit: true,
    retainedFileCountLimit: 14)
  • fileSizeLimitBytes: the maximum size of one file (here 100 MB)
  • rollOnFileSizeLimit: when true, Serilog starts a new file when the limit is reached instead of dropping events

When a file is rolled because of its size, Serilog adds a sequence number: log-20261010.txt, log-20261010_001.txt, log-20261010_002.txt, ...

Now you can calculate your worst-case disk usage: fileSizeLimitBytes × retainedFileCountLimit. In this example that is 100 MB × 14 = 1.4 GB.

Remark: retainedFileCountLimit counts files, not days. With size-based rolling, a busy day can produce many files and push the older days out sooner than you expect. Combine it with retainedFileTimeLimit if you want a guaranteed history.

Remark: Old files are removed when Serilog creates a new file. If your application never rolls over, nothing is cleaned up.

Putting it all together in appsettings.json

Hardcoding this in C# works, but you probably want to change these values without redeploying. Using Serilog.Settings.Configuration, the same setup looks like this:

{
  "Serilog": {
    "Using": [ "Serilog.Sinks.File" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "System": "Warning"
      }
    },
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.txt",
          "rollingInterval": "Day",
          "fileSizeLimitBytes": 104857600,
          "rollOnFileSizeLimit": true,
          "retainedFileCountLimit": 14,
          "retainedFileTimeLimit": "7.00:00:00"
        }
      }
    ]
  }
}

And in Program.cs:

builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration));

Some other options

Some other parameters of the file sink are worth knowing about:

  • shared: allows multiple processes to write to the same file. Useful in some hosting scenarios, but it comes with a performance cost.
  • buffered: buffers writes in memory instead of flushing every event. Faster, but you can lose events when the process crashes.
  • flushToDiskInterval: how often buffered data is flushed to disk.
  • restrictedToMinimumLevel: only write events at or above a given level to this file.
  • outputTemplate: control the format of each line. A shorter template means smaller files.
  • hooks: a FileLifecycleHooks implementation to run custom logic when a file is opened or closed, for example to compress rolled files with GZip.

Tip: Rolling and retention only protect your disk. They don’t fix the cause. In our case a chatty Microsoft.* namespace was responsible for most of the volume. Raising the minimum level for those namespaces (see the Override section above) reduced the log size more than any retention setting.

That’s it! A few lines of configuration, and the disk-full alert is no longer waiting for you on a Monday morning.

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

VS Code Planning mode

After the introduction of Plan mode in Visual Studio , it now also found its way into VS Code. Planning mode, or as I like to call it 'Hannibal mode', extends GitHub Copilot's Agent Mode capabilities to handle larger, multi-step coding tasks with a structured approach. Instead of jumping straight into code generation, Planning mode creates a detailed execution plan. If you want more details, have a look at my previous post . Putting plan mode into action VS Code takes a different approach compared to Visual Studio when using plan mode. Instead of a configuration setting that you can activate but have limited control over, planning is available as a separate chat mode/agent: I like this approach better than how Visual Studio does it as you have explicit control when plan mode is activated. Instead of immediately diving into execution, the plan agent creates a plan and asks some follow up questions: You can further edit the plan by clicking on ‘Open in Editor’: ...