From 74077993de63d1537164d30192def3a016535a26 Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:07:12 +0200 Subject: [PATCH] Have the production image default to production The entrypoint seeds the .env on the storage volume when there is none: if [ ! -f storage/.env ]; then cp .env.example storage/.env .env.example is the development template. It carries APP_ENV=local and APP_DEBUG=true, and the Dockerfile set no defaults of its own -- its only ENV was PROJECTSEND_IMAGE=1. Real environment variables win over that file, so compose.example.yaml (APP_ENV: production, APP_DEBUG: "false") was never affected, and neither was anybody following the documentation. Everybody else was: `docker run` with nothing but a database address, the Portainer / unRAID / TrueNAS templates people actually use, a Kubernetes manifest naming only DB/Redis/APP_URL. All of them booted a debug build and nothing said so. Two things follow, and neither is visible from inside the application: 1. **Every 500 hands its stack trace to whoever caused it**, signed in or not -- Laravel's exception page, with the file, the line and the surrounding source. docker/production/php.ini sets display_errors=Off and that does not help, because Laravel renders the page itself rather than letting PHP print it. 2. **"Reject known-breached passwords" never ran.** PasswordPolicy::rule() appends ->uncompromised() only when app()->isProduction(). On APP_ENV=local an administrator could switch the setting on, watch descriptor() advertise it on every password form and the security settings screen report it as active, and have it do nothing. The image now states its own environment. Set in the Dockerfile rather than in the seeded .env on purpose: the copy only happens when no .env exists, so seeding would fix a fresh install and leave every installation already running on a stale one exactly as it is. As an ENV it takes effect on the next pull. What it does not outrank: Laravel builds its env repository immutable (Illuminate\Support\Env), so a real environment variable beats the .env file. `docker run -e`, compose `environment:` and Kubernetes `env:` all set real environment variables, so an operator who asks for something explicitly still gets it -- verified against this image, where a .env saying local/true is overridden to production/false by the variables. The trade-off, stated because it is a behaviour change: editing APP_ENV or APP_DEBUG inside storage/.env no longer has any effect, since these are real environment variables and that file is not. Turning debug on deliberately is `-e APP_DEBUG=true`, which still works. That is written into the Dockerfile comment so the next person finds it there. Not changed: PasswordPolicy's isProduction() test itself. Tying an administrator's setting to the environment rather than to the setting is arguably wrong on its own, but it is a separate question with its own blast radius, and this change makes the shipped image behave the way that code already assumes. No test: the environment an image ships is not observable from the suite. `docker build --check` reports no warnings on the edited file. --- docker/production/Dockerfile | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index c4dda832..2be519cb 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -147,6 +147,43 @@ VOLUME ["/var/www/html/storage"] # an environment variable survives that. ENV PROJECTSEND_IMAGE=1 +# This is a production image, so it says so itself. +# +# The entrypoint seeds storage/.env from .env.example on first boot when no +# .env exists yet, and .env.example is the development template: +# APP_ENV=local, APP_DEBUG=true. compose.example.yaml sets both correctly, +# so the documented way to run this was never affected -- but `docker run` +# with nothing but a database address, a Portainer/unRAID/TrueNAS template, +# or a Kubernetes manifest naming only DB/Redis/APP_URL, all quietly got a +# debug build. +# +# Two consequences an operator would not expect and cannot see from the +# outside: +# +# - APP_DEBUG=true renders Laravel's exception page -- stack trace, +# file, surrounding source -- to whoever triggered the 500, signed in +# or not. php.ini's display_errors=Off does not prevent it: Laravel +# renders that page itself. +# - PasswordPolicy appends ->uncompromised() only when +# app()->isProduction(), so on APP_ENV=local an administrator's +# "reject known-breached passwords" never ran, while descriptor() went +# on advertising it and the security settings screen went on showing +# it as active. +# +# Set here rather than in the seeded .env so that an installation already +# running on a stale .env is fixed by pulling the image, not only a fresh +# one. +# +# What this does and does not outrank. Laravel builds its env repository +# immutable (Illuminate\Support\Env), so a real environment variable wins +# over the .env file. `docker run -e`, compose `environment:` and a +# Kubernetes `env:` all set real environment variables and therefore still +# win over these -- an operator who asks for something explicitly gets it. +# Editing APP_ENV or APP_DEBUG *inside* storage/.env no longer takes +# effect, because these are real environment variables and that file is +# not; `-e APP_DEBUG=true` is the way to turn debug on deliberately. +ENV APP_ENV=production APP_DEBUG=false + EXPOSE 80 # Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it