Serilog is a popular logging library for ASP.NET Core applications. It integrates easily with the built-in logging infrastructure and offers flexible configuration options.
When running an application in Docker—and later, potentially, in Kubernetes—logging to standard output (stdout) is generally the preferred approach. It allows you to access application logs using standard Docker commands, such as:
docker logs <container_name>
Fortunately, Serilog's Console sink makes this straightforward.
However, things become more complicated when an application also uses the File sink and we need to persist log files on the host machine. Without persistent storage, files written inside a container can be lost when the container is removed and recreated.
The problem is particularly relevant when following security best practices and running application processes as a non-root user.
In this article, we'll configure Serilog in an ASP.NET Core MVC application, run it in Docker under a dedicated non-root user, and investigate how to persist log files on the host. We'll also look at two approaches to managing directory permissions, including an initialization container.
1. Configuring Serilog in ASP.NET Core
First, install the Serilog.AspNetCore NuGet package:
Install-Package Serilog.AspNetCore
Next, configure Serilog in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Logging.ClearProviders();
builder.Host.UseSerilog((hostingContext, loggerConfiguration) =>
{
loggerConfiguration
.ReadFrom.Configuration(hostingContext.Configuration)
.Enrich.FromLogContext();
});This configuration removes the default logging providers and registers Serilog as the application's logging provider. Logger settings are loaded from the application configuration, and FromLogContext() enables contextual properties to be included in log events.
Now, let's add the Serilog configuration to appsettings.json:
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "{Timestamp:HH:mm:ss}|{RequestId}|{SourceContext}|{Level:u3}|{Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "App_Data/logs/log.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 31,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.ffff}|{RequestId}|{SourceContext}|{Level:u3}|{Message:lj}{NewLine}{Exception}",
"restrictedToMinimumLevel": "Information"
}
}
]
}
}This configuration defines two logging destinations:
Console: Writes log events to the console, making them available through
docker logs.File: Writes log events to files in
App_Data/logs.
The default minimum logging level is Debug. Messages from the Microsoft namespace are logged at Information and above, while messages from System are logged at Warning and above.
The file sink has its own minimum level of Information, so debug-level events are not written to the files.
The rollingInterval: "Day" setting creates a new log file every day. With the specified path, the resulting files follow a date-based naming convention, such as log20261001.txt.
The retainedFileCountLimit: 31 setting limits the number of retained log files to 31. Older files are removed as the retention limit is exceeded.
Finally, we can use the standard ILogger<T> interface in our application code:
using Microsoft.AspNetCore.Mvc;
public class HomeController : Controller
{
private readonly ILogger<HomeController> logger;
public HomeController(ILogger<HomeController> logger)
{
this.logger = logger;
}
public IActionResult Index()
{
this.logger.LogInformation("Visit home page");
return View();
}
}That's all it takes to get Serilog working with both console and file logging.
2. Running the Application as a Non-Root User
Next, let's package the application into a Docker image.
For security reasons, application processes should generally run with the minimum privileges they need. Instead of running our ASP.NET Core application as root, we'll create a dedicated user and group in the Dockerfile.
Here's a multi-stage Dockerfile targeting .NET 10:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
COPY . /src
RUN dotnet restore "/src/MyApp.csproj"
RUN dotnet publish "/src/MyApp.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y adduser
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV COMPlus_EnableDiagnostics=0
COPY --from=build /app/publish .
RUN addgroup --group my-group --gid 2001 \
&& adduser --uid 2000 --gid 2001 my-user \
&& chown -R 2000:2001 /app
# Run the application as a non-root user
USER 2000:2001
ENTRYPOINT ["dotnet", "MyApp.dll"]The Dockerfile performs the following steps:
Builds and publishes the application in a dedicated build stage.
Copies the published application into
/appin the runtime image.Creates a group with GID
2001and a user with UID2000.Assigns ownership of
/appto the new user and group.Starts the application as UID
2000and GID2001.
The COMPlus_EnableDiagnostics=0 environment variable disables .NET diagnostics functionality. Whether this setting is appropriate depends on your operational requirements, as it can limit certain debugging and diagnostic capabilities.
Let's start the container and verify that the application is running under the expected user.
Open a shell inside the container:
docker exec -it <container_name> sh
Check the current user:
id
Expected output:
uid=2000(my-user) gid=2001(my-group) groups=2001(my-group)
We can also inspect the running processes:
ps aux
Example output:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND my-user 1 0.0 3.4 274066700 70844 ? Ssl Sep21 5:17 dotnet MyApp.dll ...
The exact output will vary, but the important point is that the application process runs under the dedicated non-root user.
3. Verifying That Log Files Are Created
Let's check whether Serilog has successfully created a log file inside the container:
ls /app/App_Data/logs
Example output:
log20261001.txt
Now inspect the file:
cat /app/App_Data/logs/log20261001.txt
For example:
2026-10-01 14:40:03.0288||Microsoft.AspNetCore.Server.Kestrel|WRN|As of "2026-10-01T14:40:02.9559252+00:00", the heartbeat has been running for "00:00:01.38..." which is longer than "00:00:01". This could be caused by thread pool starvation.
The exact log content depends on the application and runtime environment.
So far, everything works as expected. Serilog writes log events to files inside the container.
We can also check the directory's ownership:
ls -ld /app/App_Data/logs
Example output:
drwxr-xr-x 2 my-user my-group 4096 Oct 1 14:40 /app/App_Data/logs
The directory belongs to my-user:my-group, corresponding to UID 2000 and GID 2001.
Now let's make the logs persistent.
4. Mounting a Host Directory into the Container
At the moment, log files are stored in the container's writable filesystem. If the container is removed and recreated, those files will not necessarily survive.
The simplest way to persist them is to mount a directory on the host into the container.
For this example, we'll use /var/log/MyApp on the host.
Add a bind mount to docker-compose.yml:
services:
web:
image: MyImage
build:
context: ../
dockerfile: ./MyApp/Dockerfile
volumes:
- /var/log/MyApp:/app/App_Data/logsThis maps the host directory /var/log/MyApp to /app/App_Data/logs inside the container.
However, after restarting the container, we discover that the log directory is empty.
Let's investigate.
Open a shell inside the container and inspect the directory:
docker exec -it <container_name> sh
ls /app/App_Data/logs
There are no log files.
Now check the directory's ownership:
ls -ld /app/App_Data/logs
Example output:
drwxr-xr-x 2 root root 4096 Oct 2 07:29 /app/App_Data/logs
We've found the problem.
The mounted directory is owned by root:root, while the application runs as UID 2000 and GID 2001.
When Docker mounts an existing host directory, the container sees the directory's underlying ownership and permissions. The ownership assigned to /app in the Dockerfile does not automatically override the permissions of the mounted directory.
As a result, the application may be unable to create or write log files in the mounted directory.
5. Solution 1: Change Directory Ownership on the Host
One straightforward solution is to assign ownership of the host directory to the UID and GID used by the application.
On the host, run:
sudo mkdir -p /var/log/MyApp sudo chown -R 2000:2001 /var/log/MyApp
Then restart the container.
The application should now be able to write log files to /var/log/MyApp on the host, and those files will persist independently of the container's lifecycle.
However, this approach requires caution in production environments.
Before assigning ownership, check whether UID 2000 and GID 2001 are already used by other accounts or services on the host. Numeric user and group IDs are what matter for filesystem permission checks; matching names inside the container do not guarantee that the corresponding host account is the same entity.
Changing ownership recursively can also affect existing files and directories.
This solution is useful for demonstrating the underlying issue, but it may not be the best operational approach for every deployment.
Can we automate the permission setup instead?
6. Solution 2: Use an Initialization Container
Another approach is to run a short-lived initialization container that adjusts the log directory's ownership before the application starts.
This avoids requiring an administrator to run chown manually every time the environment is prepared.
Here's the updated docker-compose.yml:
services:
init-logs:
image: alpine:latest
user: "0:0"
volumes:
- /var/log/MyApp:/logs
command: ["sh", "-c", "chown -R 2000:2001 /logs"]
restart: "no"
web:
image: MyImage
build:
context: ../
dockerfile: ./MyApp/Dockerfile
depends_on:
init-logs:
condition: service_completed_successfully
volumes:
- /var/log/MyApp:/app/App_Data/logsHere's how this configuration works.
The init-logs service
The initialization container uses Alpine Linux and runs as root inside the container:
user: "0:0"
It mounts the host directory at /logs and executes:
chown -R 2000:2001 /logs
The command assigns ownership of the log directory and its contents to the UID and GID expected by the application.
The container then exits. The restart: "no" policy prevents Docker from automatically restarting it after completion.
The web service
The main application mounts the same host directory at /app/App_Data/logs.
The depends_on condition:
depends_on:
init-logs:
condition: service_completed_successfullyinstructs Docker Compose to wait until init-logs has completed successfully before starting web.
This ensures that the permission initialization runs before the application starts.
Notice that the two services use different images. This is intentional: the initialization task needs only a lightweight Linux environment with the required filesystem utilities, while the application runs in its own .NET runtime image.
If the initialization container and the application need to share the same application image for some other reason, that is also possible, but it is not required for this solution.
Important considerations
Although the initialization container automates permission management, it does not eliminate every operational concern.
UID and GID conflicts: Verify that the chosen IDs are appropriate for your host environment.
Recursive ownership changes:
chown -Rchanges ownership of existing files as well as the directory itself. Avoid using it blindly on directories containing unrelated data.Startup behavior: Docker Compose waits for the initialization service to complete successfully during startup. If the initialization fails, the dependent service will not start.
Image reproducibility: For production deployments, consider pinning the Alpine image to a specific version or digest rather than relying on
alpine:latest.
For more complex deployments, you may want to manage host directory ownership as part of infrastructure provisioning instead. In Kubernetes, the equivalent problem is typically handled through volume permissions and security-context configuration, depending on the storage provider and workload requirements.
Conclusion
Persisting Serilog log files from a Dockerized ASP.NET Core application is straightforward once filesystem permissions are configured correctly.
The key points are:
Configure Serilog to write to both the console and files when both outputs are needed.
Run the application as a non-root user to reduce its privileges.
Use a Docker bind mount to persist log files on the host.
Remember that mounting a host directory does not automatically grant write access to the container's user.
Choose a permission-management strategy that fits your deployment environment.
For a quick demonstration, changing the directory's ownership on the host is sufficient. For an automated Docker Compose deployment, a dedicated initialization container can handle the setup before the application starts.
Neither approach should be applied blindly in production. Review host UID/GID assignments, directory ownership, and the scope of permission changes before deploying.
If you have experience with other approaches to managing bind-mount permissions for non-root containers, I'd be interested to hear how you handle this in your own environments.





