Fixing Permission Denied on Container Output Directories

A reconstruction completes, writes 80 GB of results, and the operator cannot delete them: every file is owned by root. Or the reverse — the container starts, runs for a minute and exits with Permission denied trying to create its output directory on a mount that is plainly writable from the host.

Both are the same mismatch seen from two directions. A container process has a numeric user id, the host filesystem enforces ownership by numeric id, and nothing reconciles them unless somebody arranges it. This page covers arranging it, for both the running worker and the files it leaves behind. It completes the operational detail in containerising photogrammetry workers with Docker.

Why the ids do not line up

A container image defines a user — often root, sometimes an application user created at build time with an arbitrary id such as 1000 or 999. That id is what the kernel sees when the process writes to a bind-mounted host directory, and the host filesystem has no notion of “inside the container”.

Three consequences follow. A container running as root writes root-owned files, which a non-root host user cannot delete. A container running as uid 999 cannot write to a directory owned by uid 1000 unless the permissions allow it. And a chown applied afterwards needs elevation, which is precisely what the setup was trying to avoid.

The fix is to run the container as the id that owns the output directory, supplied at run time rather than baked into the image — because the right id depends on the host, and an image should not.

How a container user id meets a host directory's ownership Three scenarios across a bind mount. In the first, the container runs as root with user id zero and writes files owned by root, which the host user with id one thousand cannot delete. In the second, the container runs as a build-time user with id nine hundred and ninety-nine and cannot write to a directory owned by id one thousand, failing immediately. In the third, the container runs as id one thousand supplied at run time, matching the directory's owner, and both the write and the later cleanup succeed. A note records that the kernel compares numeric identifiers and knows nothing about container boundaries. container as root writes as uid 0 host user is uid 1000 job succeeds cleanup needs elevation container as uid 999 from the image build directory owned by 1000 cannot write at all fails in the first minute container as uid 1000 supplied at run time matches the directory writes and cleans up no elevation anywhere The kernel compares numbers and knows nothing about containers. So the right id is a property of the host, which is why it cannot be baked into an image.

Figure 1 — Three ids, three outcomes, one comparison in the kernel.

Minimal reproducible solution

import os
from pathlib import Path

import docker


def run_as_owner(image: str, command: list[str], *, output_dir: str,
                 mounts: list) -> dict:
    """Run a container as the user that owns the output directory.

    Reading the ids from the directory rather than from the calling process
    handles the case where a scheduler runs as a service account while the
    output belongs to a project group — which is the usual arrangement on a
    shared machine.
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)
    stat = out.stat()

    client = docker.from_env()
    container = client.containers.run(
        image, command=command, mounts=mounts, detach=True,
        user=f"{stat.st_uid}:{stat.st_gid}",
        group_add=[str(g) for g in os.getgroups()[:16]],
        environment={"HOME": "/tmp"})
    status = container.wait()
    logs = container.logs(tail=200).decode("utf-8", "replace")
    container.remove()
    return {"exit_code": status["StatusCode"], "ran_as": f"{stat.st_uid}:{stat.st_gid}",
            "logs": logs}

Setting HOME=/tmp is a small but necessary detail. A container run as an arbitrary uid has no entry in the image’s password file, so HOME is unset and libraries that write caches into it — matplotlib, pip, several GDAL configurations — fail with errors that name a cache directory rather than a permission problem.

Making the image tolerate any uid

An image that only works for one uid is fragile. Building it so any numeric id can run it removes the coupling entirely.

# Create the application directories with group write, and make the group
# root (gid 0), which every container user belongs to regardless of uid.
RUN mkdir -p /app /data/scratch \
    && chgrp -R 0 /app /data/scratch \
    && chmod -R g+rwX /app /data/scratch

# No USER directive pinning a numeric id; the run supplies it.
WORKDIR /app
ENV HOME=/tmp \
    XDG_CACHE_HOME=/tmp/.cache \
    MPLCONFIGDIR=/tmp/.mpl

Assigning group ownership to gid 0 is the pattern used by platforms that run containers as arbitrary uids. Every user is in group 0 inside a container, so group-writable directories are writable by whichever id the run specifies, without the image knowing anything about it.

Three ways to reconcile container and host ownership Three rows. Running the container as the host user's numeric identifier makes every file the container writes owned by that user, which is the approach that needs no changes on the host and is correct on any single-tenant machine. Matching a fixed identifier inside the image and setting the host directory's group to it works where many users share one output area, and survives the container being run by different people. Relaxing permissions on the host directory works immediately and is the approach that quietly accumulates world-writable directories across a fleet, which is why it should be the last resort rather than the first. run as the host user's uid everything written is owned correctly; no host changes needed a fixed uid, host group set to match survives different people running the same container relax the host permissions works immediately, and accumulates world-writable directories The first two express the intent. The third hides it, and a fleet ends up full of them.

Figure 3 — Three fixes, two of which are still correct a year later.

Edge-case matrix

Situation Symptom Handling
Container runs as root Output undeletable by host user Run as the output directory’s owner
Image user id mismatched Permission denied on write Same fix; ids come from the host
No passwd entry for the uid HOME unset, cache errors Set HOME and cache paths explicitly
Mount directory created by Docker Owned by root Create it on the host first
SELinux enforcing Denied despite correct ownership Add :z to the mount
Rootless Docker Ids are remapped Ownership differs; check from the host side
Group-only access needed uid matches, gid does not Pass uid:gid, not just uid
Output on a network filesystem Ownership squashed Check the export options before debugging locally

The Docker-created-directory row is the sharpest edge. If a bind mount’s host path does not exist, Docker creates it as root, and a subsequent run as a normal user then fails on a directory that the pipeline itself caused. Creating mount paths explicitly on the host before the run removes it.

from pathlib import Path


def prepare_mount_paths(paths: list[str], *, uid: int, gid: int,
                        mode: int = 0o775) -> None:
    """Create mount targets on the host with the right owner before running.

    Letting Docker create a missing mount path produces a root-owned
    directory that the container then cannot write to — a failure the
    pipeline caused itself.
    """
    import os
    for p in paths:
        path = Path(p)
        path.mkdir(parents=True, exist_ok=True)
        try:
            os.chown(path, uid, gid)
        except PermissionError:
            if path.stat().st_uid != uid:
                raise PermissionError(
                    f"{path} is owned by uid {path.stat().st_uid}, not {uid}, "
                    "and cannot be changed — create it as the right user")
        path.chmod(mode)

Verification snippet

import subprocess
from pathlib import Path


def verify_output_ownership(output_dir: str, *, expect_uid: int,
                            expect_gid: int, sample: int = 200) -> dict:
    """After a run, confirm the results are owned by the right account."""
    files = list(Path(output_dir).rglob("*"))[:sample]
    wrong = [str(f) for f in files
             if f.is_file() and (f.stat().st_uid != expect_uid
                                 or f.stat().st_gid != expect_gid)]
    return {"checked": len(files), "wrong_owner": len(wrong),
            "examples": wrong[:5],
            "ok": not wrong,
            "note": ("output is owned correctly" if not wrong else
                     "some output is owned by another account — the container user "
                     "did not match the output directory")}

Running this immediately after a job, rather than when somebody tries to delete the results a week later, is the difference between a one-line fix and an operator with a permissions problem and no context.

Fixing ownership at run time against fixing it afterwards Two approaches compared for an eighty gigabyte result set. Running the container as the correct user costs nothing: the files are written with the right ownership in the first place. Running as root and changing ownership afterwards requires elevated privileges on the host, touches every one of two hundred thousand files, takes about four minutes, and fails entirely on storage that does not permit the operation. A note states that the second approach also leaves a window in which the output is unusable. run as the right user files written correctly no elevation needed no extra work at all 0 seconds chown afterwards needs host elevation touches 200 000 files fails on some storage 4 minutes, and a gap The second approach also leaves a window where the output is unusable. On a fleet, that window is where a downstream job reads a file it cannot open.

Figure 2 — Why the fix belongs at run time rather than afterwards.

Why not simply run everything as root

Running containers as root makes all of this go away, which is why it is so common, and it is worth being explicit about what it costs beyond the undeletable output.

A root process in a container that escapes its isolation is root on the host. The escape routes are rarer than they used to be and they are not zero, and a photogrammetry worker processing files supplied by a client is exactly the kind of workload that parses untrusted input with large C++ libraries.

Root also removes a useful safety property. A worker running as an ordinary user cannot modify the survey it was given even if a bug tries to, because the mount is read-only and the process lacks the privilege to override it. As root, only the mount flag stands between a buggy pipeline and the original imagery.

The practical position is that running as a normal user costs the few lines on this page and removes both problems, so there is no case for root in a processing worker. Where a base image insists on it — some vendor images do — that is a reason to look for a different base rather than to accept it.

When to escalate

  • The output lives on a network filesystem with squashed ownership. The export options decide the answer, not the container. Check them before debugging anything locally.
  • A tool inside the image insists on writing to a fixed path. Mount a writable directory at that path rather than running as root to satisfy it.
  • Rootless Docker is in use. Ids are remapped between the container and the host, so the numbers do not match directly. Verify from the host side and use the remapped values.

Containerising Photogrammetry Workers with Docker