System.IO.IOException: Read-only file system : '/app/data/visits.log' from a .NET app in Docker means the container was started with --read-only and the app wrote to a path that is part of the image. Give that path writable storage: a --tmpfs mount for scratch files or a volume for data you keep.

Both were tested: --tmpfs /tmp with the app pointed at /tmp/data, and a named volume mounted at /app/data. Each made the same write succeed with --read-only still on.

The error

PS> docker run --rm --name fix10-dn-ro --read-only fix10-dn-nonroot-fix write
Unhandled exception. System.IO.IOException: Read-only file system : '/app/data/visits.log'
   at Interop.ThrowExceptionForIoErrno(ErrorInfo errorInfo, String path, Boolean isDirError)
...
   at System.IO.File.WriteToFile(String path, FileMode mode, ReadOnlySpan`1 contents, Encoding encoding)
   at Program.<Main>$(String[] args) in /src/Program.cs:line 15

Why it happens

--read-only makes the container's own file system read-only. The image used here has a /app/data folder owned by app (drwxr-xr-x 2 app root in an ls -ld), and the same image wrote /app/data/visits.log without --read-only. With the flag, the write failed. The app's call to File.AppendAllText on line 15 is where the exception surfaced.

/tmp is not an exception on its own. Pointing the app at /tmp/data without a tmpfs mount failed the same way, one step earlier, when it created the folder:

PS> docker run --rm --name fix10-dn-ro4 --read-only -e DATA_DIR=/tmp/data fix10-dn-nonroot-fix write
Unhandled exception. System.IO.IOException: Read-only file system : '/tmp/data'
   at System.IO.FileSystem.CreateDirectory(String fullPath, UnixFileMode unixCreateMode)
   at System.IO.Directory.CreateDirectory(String path)
   at Program.<Main>$(String[] args) in /src/Program.cs:line 12

The fix

docker run --rm --name fix10-dn-ro2 --read-only --tmpfs /tmp -e DATA_DIR=/tmp/data fix10-dn-nonroot-fix write
Wrote /tmp/data/visits.log

docker run --rm --name fix10-dn-ro3 --read-only -v fix10-dn-data:/app/data fix10-dn-nonroot-fix write
Wrote /app/data/visits.log

--tmpfs /tmp gives the container a writable /tmp mount of its own; use it for scratch files the app can lose. The named volume fix10-dn-data is a writable mount at exactly the path the app writes to, and the rest of the file system stays read-only. The owner of the image's folder mattered: a volume created with docker volume create and mounted at /app/data was writable on this image, which chowns the folder to app, and the same test on an image without that line was refused with UnauthorizedAccessException: Access to the path '/app/data/visits.log' is denied.

How it was reproduced

A console app from dotnet new console -n ClinicApi, no packages, whose write mode creates a folder (from DATA_DIR, default /app/data) and appends a line to visits.log. Published on mcr.microsoft.com/dotnet/sdk:10.0 (SDK 10.0.302) and run on mcr.microsoft.com/dotnet/runtime:10.0 (Microsoft.NETCore.App 10.0.13) as USER $APP_UID, with RUN mkdir /app/data && chown $APP_UID /app/data in the Dockerfile. Docker engine 29.9.3 on Windows 11. The container, volume and image names in the commands are the test's own.

Frequently asked

How do I give a read-only .NET container a writable folder?
Add --tmpfs /tmp for scratch files, or mount a volume at the exact path the app writes to, for example -v app-data:/app/data. Both made the write succeed in the test while --read-only stayed on.
Is /tmp writable in a Docker container started with --read-only?
Not in this test. Creating /tmp/data failed with Read-only file system until the container was started with --tmpfs /tmp.
Why does my .NET app get IOException Read-only file system in Docker?
The container was started with --read-only, so the image's files cannot be changed. Any path the app writes to needs a tmpfs or volume mount.

Without --read-only but as a non-root user, the same write can fail on permissions instead: Access to the path is denied. More decoded errors in the Fixes category.