I set a container's umask with the usual entrypoint trick, umask 027; exec myapp, and it worked exactly as documented: a file the main process created came out 640. Then I ran docker exec into the same container, touched a second file, and got 644. Same container, same intended policy, two different answers depending on which process asked.
Docker Engine 29.8.0, released 3 September 2026, adds a native fix for this: HostConfig.Umask and a --umask flag on docker create and docker run, documented to cover "a container's main process, execs, and healthchecks". I wanted to know exactly how big the gap was that it closes, and where the new flag still falls short.
My installed Docker was 29.3.1, which predates the flag entirely — docker run --help has no --umask line on it. I downloaded the 29.8.1 static build from download.docker.com, ran its containerd and dockerd as a second, isolated daemon on its own socket and data directory, and pointed a second docker client at it. That let me compare old and new on the same machine without touching the daemon everything else in this container relies on.
What the flag actually changes
With no umask set, Alpine's default is 022: new files come out 644, directories 755. Setting --umask 027 drops that to 640 and 750. --umask 000 removes the mask entirely and files come out 666.
| umask passed | file mode | directory mode |
|---|---|---|
| (default, none) | 644 | 755 |
027 |
640 | 750 |
000 |
666 | — |
None of that is surprising; it is a umask, doing what a umask does. The interesting part is what happens once you look past the process the flag was set on.
Where the old workaround quietly stopped working
Before 29.8, the documented way to fix a container's umask was an entrypoint script: umask 027 followed by exec into the real process. That sets the umask of PID 1, and PID 1's umask is what child processes inherit. It does nothing for a process that docker exec starts, because that process is attached by the runtime directly into the container's namespaces — it is not a child of PID 1, and it does not inherit PID 1's umask. It starts with whatever the container runtime's own default is, which on my setup was the plain 022.
I ran both approaches side by side. For the old approach, the container's entrypoint set umask 027 before exec'ing sleep. For the new approach, I dropped the script entirely and passed --umask 027 to docker run.
| where a file is created | old (entrypoint umask) |
new (--umask flag) |
|---|---|---|
| main process | 640 | 640 |
docker exec session |
644 | 640 |
HEALTHCHECK command |
644 (both runs) | 640 (both runs) |
The main process was fine either way, which is exactly why this bug is easy to miss in normal use — whoever wrote the entrypoint script tested the thing they could see running in the foreground. It's docker exec and the healthcheck that quietly get the wrong permissions, and both are the kind of thing you'd only notice once a deploy script or a monitoring sidecar started failing to read a file it should have had access to.
I checked the healthcheck case across two consecutive runs (--health-interval 2s) rather than one, since a single healthy-looking pass could have been a fluke of timing. Both runs gave the same result on both sides: 644 every time under the old approach, 640 every time under the new one. This is a consistent, not intermittent, gap.
What it refuses, and what it doesn't refuse but should
The flag validates its input as an unsigned integer parsed with Go's strconv.ParseUint, so anything that isn't digits is rejected immediately, before the container is even created:
$ docker run --rm --umask 099 alpine:3.20 sh -c 'umask'
invalid argument "099" for "--umask" flag: strconv.ParseUint: parsing "099": invalid syntax
The same error shape shows up for -1, for symbolic notation like u=rwx, and for an empty string. All good, all fail before anything runs, all point at the actual problem.
What surprised me is what it does not refuse. A umask can carry a fourth leading octal digit for the setuid, setgid and sticky bits — 1777, 2027, 4027 and so on. Docker's own --help just says "Set umask for the container" with no mention of range. I passed 1777 expecting either a rejection or a sticky bit applied somewhere meaningful:
$ docker run --rm --umask 1777 alpine:3.20 sh -c 'umask; touch /tmp/f; stat -c %a /tmp/f'
0777
0
The daemon accepted it silently and the leading 1 simply vanished — the umask that landed was 0777, not 1777. 2027 and 4027 behave the same way, both collapsing to 0027. There's no warning in the daemon log and no error to the client. If you paste a umask value from somewhere that includes a special-bit prefix, expecting Docker to either honour it or complain, it will quietly do neither.
What I got wrong on the way
My first pass at the healthcheck comparison used --health-retries 1 with no --health-start-period, and I read the very first health log entry as the result. That entry came back healthy under both the old and new setup, which looked like "no difference" — exactly the kind of result the instructions for this post told me to distrust. The problem was that Docker's health check output only reports the health status, and I'd forgotten I needed the stdout of the probe command itself, not just its exit code, to see the file mode. Once I inspected .State.Health.Log[].Output directly, rather than the pass/fail state, the 644 versus 640 split showed up immediately. The lesson was ordinary but worth stating: a green healthcheck tells you the command exited zero, not that its output was what you expected.
Where the new flag doesn't reach yet
Three places I checked where I expected either a limitation or a clean pass:
docker update has no --umask option, so a running container's umask cannot be changed after creation — you have to recreate it. Not unusual for a HostConfig field, but worth knowing before you plan around adjusting it live.
Docker Compose (v5.1.1, the version available to me) does not recognise a umask: key at all:
$ docker compose -f compose.yaml up
validating compose.yaml: services.app additional properties 'umask' not allowed
Anyone using Compose has to fall back to an entrypoint/command override for now, which means the exec and healthcheck gap this feature exists to fix is still open for every Compose-managed service until the spec catches up.
Everything else I tried worked exactly as advertised. --umask 027 combined with --user 1000:1000 still produced 640. Files written through a bind mount landed at the same mode on the host side as inside the container. A named volume with --umask 077 gave 600 on files created there. Ten docker exec sessions fired in parallel against the same container all reported the same 0027 with no divergence, so whatever the daemon does to apply this per-container is stable under concurrent access, not a race condition waiting to happen.
One more thing worth flagging for anyone scripting around this rather than reading it by eye: docker inspect --format '{{.HostConfig.Umask}}' on a container started with --umask 027 prints 23, not 027 or 0027. The API stores and returns it as a plain decimal integer. 27 in octal is 23 in decimal, and if your monitoring script is grepping inspect output for the string 027, it will never match.
Run it yourself
This needs a Docker Engine build from 29.8.0 onward. If your installed daemon is older, you can run a second one from the static binaries without touching your existing setup, the way I did:
mkdir -p /opt/docker298 && cd /opt/docker298
curl -sL https://download.docker.com/linux/static/stable/x86_64/docker-29.8.1.tgz -o d.tgz
tar xzf d.tgz
mkdir -p data containerd-root containerd-state run
cat > containerd.toml <<'EOF'
version = 2
root = "/opt/docker298/containerd-root"
state = "/opt/docker298/containerd-state"
[grpc]
address = "/opt/docker298/run/containerd.sock"
EOF
./docker/containerd --config containerd.toml &
./docker/dockerd \
--data-root /opt/docker298/data \
--pidfile /opt/docker298/run/dockerd.pid \
--host unix:///opt/docker298/run/docker.sock \
--containerd /opt/docker298/run/containerd.sock \
--iptables=false --bridge=none &
export DOCKER_HOST=unix:///opt/docker298/run/docker.sock
Then the comparison itself:
D=/opt/docker298/docker/docker
# old workaround, exec falls back to the default umask
$D run -d --rm --network none --name old alpine:3.20 sh -c 'umask 027; exec sleep 3600'
$D exec old sh -c 'umask; touch /tmp/f; stat -c %a /tmp/f' # 0022, 644
# native flag, exec inherits the same umask as the main process
$D run -d --rm --network none --umask 027 --name new alpine:3.20 sleep 3600
$D exec new sh -c 'umask; touch /tmp/f; stat -c %a /tmp/f' # 0027, 640
What to do with this
If you already have an entrypoint script setting umask for a container, check whether anything reaches that container through docker exec or a HEALTHCHECK — a deploy hook, a debugging shell, a monitoring probe. Those have been getting the default umask the whole time, silently, regardless of what the entrypoint set. Moving to --umask on Docker 29.8 or later fixes all three surfaces at once, but only if you're not going through Compose, and only if you remember that a leading special-bit digit will vanish without complaint rather than being applied or rejected.
Top comments (1)
Great read!