Tightening a file's permissions is supposed to be the safe direction. In July, on my search engine's frontend server, setting the application's .env file to mode 0600 instead did two things at once: it broke every newly started PHP process, and it left the admin area answering without a password for a few minutes. A smoke test after the deploy caught it.
Who owns the file, and who reads it
The deploy used Ansible to render .env from a template and the project's encrypted secrets. That was the first real run of that step, and the template task set mode: "0600": readable and writable by the owner, nothing for anyone else. The owner was the deploy user on the host, uid 1000.
The application didn't run as that user. PHP-FPM, the crawler and the indexer ran inside containers as the container's www-data, uid 33, with the project directory bind-mounted into the container (.:/var/www/html). A bind mount doesn't translate user IDs, and there was no remapping configured, so inside the container the file still belonged to uid 1000. uid 33 wasn't the owner and didn't share a group with it. With 0600, it couldn't read the file at all.
The failure that wasn't loud
Any PHP process that started after the change failed. Symfony's Dotenv component threw a PathException, "Unable to read the .env environment file", and a one-off cache:clear failed outright. That part was loud and easy to see.
The PHP-FPM workers that were already running didn't fail. They kept serving requests from the compiled container they'd built before the change. According to the commit that fixed it, request-time resolution of environment variables broke quietly in those workers instead of failing, and the visible effect was on the admin statistics page: /admin/stats started returning 200, with no WWW-Authenticate challenge, to requests without credentials. For a few minutes, the admin area on the live server was open.
A routine post-deploy smoke test caught it within minutes. It was caught because the deploy was checked afterwards, not assumed to have worked.
The fix, and its cost
The fix changed the template to mode: "0644", readable by everyone, writable by the owner. After redeploying, cache:clear worked again and /admin/stats answered 401 without credentials, as it should.
The commit calls 0644 the tightest mode that still gives www-data read access without a bigger change to how users map between the host and the containers. It's worth being clear about what that trades away: 0644 makes the secrets file readable by every account on the host, not just the application. On a server with few accounts that may be a small cost, but it's a cost. The narrower alternative is to give the file the group the container's www-data runs with and mode 0640, so the owner can write and only that group can read it. That works through a bind mount, because the kernel compares numeric IDs and they're the same on both sides. That's a decision about the host's user model, and it was out of scope for a fix made in the middle of an incident.
One more detail made the incident recoverable at all: the Ansible task kept a timestamped backup of the previous .env next to the new one. That costs nothing and turns a bad render into a file copy.
What generalises
Two things. First, the kernel decides which permission bits apply by comparing the process's user and group IDs with the file's owner and group, and with containers the process is often not the user you deployed as. Before tightening a mode, check which uid reads the file, inside the container, not on the host.
Second, the dangerous failure wasn't the crash; it was the half of the system that kept running on stale state. New processes failed loudly. Old processes failed quietly, in the one place where failing quietly means failing open. Checking a deploy afterwards, rather than assuming it worked, is what found it.
Leave a comment
Comments are reviewed before they appear.