System.UnauthorizedAccessException: Access to the path '/app/data' is denied. in a .NET container that runs as USER $APP_UID means the app's user may not write to /app. Create the folder in the Dockerfile and give it to that user, or write somewhere the user can write, such as /tmp or a mounted volume.

The tested Dockerfile fix is one line before the USER line: RUN mkdir /app/data && chown $APP_UID /app/data.

The error

PS> docker run --rm --name fix10-dn-nr fix10-dn-nonroot write
Unhandled exception. System.UnauthorizedAccessException: Access to the path '/app/data' is denied.
 ---> System.IO.IOException: Permission denied
   --- End of inner exception stack trace ---
   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

Why it happens

The final stage ends with USER $APP_UID. In this image, built on mcr.microsoft.com/dotnet/runtime:10.0, that user is:

PS> docker run --rm --name fix10-dn-nr4 --entrypoint id fix10-dn-nonroot
uid=1654(app) gid=1654(app) groups=1654(app)

WORKDIR /app and COPY came before the USER line, and the folder they left is owned by root with no write bit for anyone else:

PS> docker run --rm --name fix10-dn-r1 --entrypoint ls fix10-dn-nonroot -ld /app
drwxr-xr-x 1 root root 4096 Nov 13 05:19 /app

The app then called Directory.CreateDirectory("/app/data") to keep a visit log next to itself, and Linux refused with "Permission denied", which .NET reports as an UnauthorizedAccessException with the IOException as the inner exception. Read both lines: the inner one says it is a permission problem, not a missing folder, and the id command above, run with --entrypoint, tells you which user the image runs as before you start debugging the app itself.

The fix

FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /out .
RUN mkdir /app/data && chown $APP_UID /app/data
USER $APP_UID
ENTRYPOINT ["dotnet", "ClinicApi.dll"]
PS> docker run --rm --name fix10-dn-nr2 fix10-dn-nonroot-fix write
Wrote /app/data/visits.log

Only the data folder changed owner; /app itself stayed with root:

PS> docker run --rm --name fix10-dn-r2 --entrypoint ls fix10-dn-nonroot-fix -ld /app /app/data
drwxr-xr-x 1 root root 4096 Nov 13 05:20 /app
drwxr-xr-x 2 app  root 4096 Nov 13 05:20 /app/data

If the path is configurable, pointing it at /tmp also worked without changing the image:

PS> docker run --rm --name fix10-dn-nr3 -e DATA_DIR=/tmp/data fix10-dn-nonroot write
Wrote /tmp/data/visits.log

These runs used --rm, so the container and the files it wrote inside it were removed when it exited. For data you keep, mount a volume at the data path; with the folder created and owned as above, a named volume mounted at /app/data was writable in the read-only test linked below; the same volume test on the image without the chown line was refused.

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 in a multi-stage Dockerfile 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) with USER $APP_UID, Docker engine 29.9.3 on Windows 11.

Frequently asked

What user does USER $APP_UID run as in a .NET Docker image?
In the runtime:10.0 image it is uid 1654, named app, as the id command inside the container printed. In the test /app was owned by root with mode drwxr-xr-x, so this user could not create a folder in it.
How do I let a non-root .NET container write to a folder?
Create the folder and change its owner before the USER line, for example RUN mkdir /app/data && chown $APP_UID /app/data, then switch to USER $APP_UID.
Why does Directory.CreateDirectory throw UnauthorizedAccessException in Docker?
The process user has no write permission on the parent folder. Linux returned Permission denied and .NET wrapped it in UnauthorizedAccessException, with the IOException as the inner exception.

A container started with --read-only fails on the same write with a different message: IOException: Read-only file system. More decoded errors in the Fixes category.