Docker EACCES: permission denied, mkdir as a non-root user

A containerized Node app failed with EACCES: permission denied, mkdir './.data' after dropping to a non-root user. Here's the cause and the fix.

You build the image, docker images shows it sitting there, and then the container dies on startup with the following error message:

Error: EACCES: permission denied, mkdir './.data'
    at Object.mkdirSync (node:fs:1372:26)
    at Object.<anonymous> (/app/dist/src/db/file/users.js:13:18)
  ...
  errno: -13,
  code: 'EACCES',
  syscall: 'mkdir',
  path: './.data'

The process never gets past initialization. This came up on GitProxy when a contributor reported that main would not start at all (the original “Broken build” issue): docker compose up --build brought the container up and it immediately exited with that EACCES: permission denied, mkdir './.data'. If you write Dockerfiles that drop to a non-root user, this is a common way for a Node app to fall over on first run, and the fix belongs in the image rather than in the app.

The setup

GitProxy ships a multi-stage Dockerfile. The build stage compiles the UI and the server; the production stage copies the build output into a slim image and then, for security, switches away from root to an unprivileged user (UID 1000) before the process starts. Running a network-facing proxy as root is the kind of thing security scanners flag and FINOS policy discourages, so dropping to a non-root user is the right call.

The catch is what the app does on startup. GitProxy’s file-based store creates a .data directory for its data, and the push parser creates a .tmp directory for scratch space. Both are created relative to the working directory, which is /app. So on first run the process calls mkdirSync('./.data'), that resolves to /app/.data, and that is where it dies.

Red herring: “pull access denied” for an image you already built

Before the permission error there was a confusing detour worth flagging, because it points you in the wrong direction. Running the freshly built image failed like this:

Unable to find image 'finos/git-proxy:test' locally
docker: Error response from daemon: pull access denied for finos/git-proxy, repository does not exist or may require 'docker login'

That reads like the image does not exist, but docker images clearly listed it. When Docker says it cannot find an image locally and then tries to pull it, while docker images shows it right there, you are almost always talking to two different Docker daemons. The usual cause on Linux is mixing sudo: building as your user and then running the container with sudo (or the reverse). Each context can have its own image store, so the image you built is not visible to the daemon the other command is using. Running it the same way it was built, without sudo, found the image straight away. Rule this one out early so you do not waste time re-tagging or rebuilding an image that was fine the whole time.

Why the non-root user can’t write

With the image actually running, the real failure showed up: the EACCES on ./.data. The stack trace points at /app/dist/src/db/file/users.js calling fs.mkdirSync, and errno: -13 is plain filesystem permission denial.

The reason is in the production stage. The files are copied in as root, so /app and everything under it is owned by root. Then the Dockerfile switches to UID 1000 and sets the working directory:

RUN chown 1000:1000 /app/dist/build \
    && chmod g+w /app/dist/build
USER 1000
WORKDIR /app

That chown only hands one subdirectory (/app/dist/build, the compiled UI) to UID 1000. The working directory /app itself stays root-owned. A non-root user cannot create a new entry inside a directory it has no write access to, so mkdir /app/.data is refused.

The tell that you are chasing the wrong thing is what happens if you patch just .data. Bind-mount a writable directory onto /app/.data and the app gets one step further before dying on the next directory it needs:

Error: EACCES: permission denied, mkdir './.tmp/'
    at Object.mkdirSync (node:fs:1372:26)
    at Object.<anonymous> (/app/dist/src/proxy/processors/push-action/parsePush.js:24:18)

Same error, different path, this time from parsePush.js. When fixing one directory just moves the failure to the next one, the problem is not any single directory. The runtime user cannot write to its working directory at all.

The fix: give the working directory to the runtime user

The fix that landed (PR #1341) is to hand ownership of the working directory to UID 1000 in the production stage, before the USER 1000 switch, so the process can create whatever it needs under /app:

WORKDIR /app
USER root
RUN apt-get update && apt-get install -y git tini \
    && rm -rf /var/lib/apt/lists/*
RUN chown -R 1000:1000 /app
USER 1000

The line doing the work is chown -R 1000:1000 /app. It recursively gives the working directory to the runtime user, so mkdirSync('./.data') and later mkdirSync('./.tmp/') both succeed. After this, docker compose up --build starts the proxy cleanly, still as a non-root user. Docker’s Dockerfile reference covers how USER, WORKDIR, and COPY interact with ownership if you want the exact rules.

A few alternatives, and why they are worse. Running the container as root makes the error vanish, but it throws away the reason the image drops to UID 1000 in the first place, and a scanner will rightly flag it; do not reach for this to silence a permission error. Bind-mounting a writable host directory onto /app/.data is fine as a quick local test, but it does nothing for anyone else pulling the image, and it pushes the permission problem onto the host. If a recursive chown over all of /app bothers you, since it touches a large node_modules, you can instead create just .data and .tmp ahead of time and chown only those, which is leaner at build time. I went with chown -R /app because it is simple and obviously correct, and the extra build cost here is not worth optimizing away.

The takeaway

When a containerized app crashes on startup with EACCES ... mkdir and you are running as a non-root user, it is rarely the app’s fault. The directory it writes to is owned by root from the image build, and the runtime user cannot create anything inside it. Fix it in the image by giving the runtime UID ownership of its working directory, not by switching back to root and not by patching one directory at a time.

There is a maintainer lesson sitting underneath this one too. A broken docker compose up on main quietly turns away contributors: this whole thing surfaced because someone who wanted to add features could not get a baseline running at all. Keeping the container’s start path green is worth the attention, because the people it blocks are the ones you most want to keep. The full change lives in the git-proxy repo if you want to read the final diff.