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.
Three findings from one report of intermittent 502s behind Nginx Proxy
Manager, all of them ours.
Stop sending the Link: preload header. AddLinkHeadersForPreloadedAssets
copied every Vite preload into a response header, duplicating tags the
document already carried in its head — twenty on the login page. nginx
buffers a response's headers into a single block defaulting to 4 KB, so
/files, at 6060 bytes of headers, was refused with "upstream sent too big
header" and the proxy answered 502. Which pages went over depended on how
many assets they loaded, which is why it read as intermittent rather than
as a header that is always too big: the login screen fitted, the
application did not. Removing it takes /files to 1247 bytes and
/dashboard from 4544 to 1247. Nothing is lost — the browser reads the
tags in the document, and we send no 103 Early Hints.
Send nginx's logs to the container's streams. supervisord captures what
each program writes to its own stdout, but nginx opens the files named in
the package's nginx.conf as soon as it reads its config, so access and
error logs went to /var/log/nginx/ inside the container. That is where
the reason for every 502 and every 403 was written, and docker logs never
showed it — so a proxy problem presented as no logs on either side, which
is exactly how it was reported.
Document the thing neither guide covered. DOCKER.md had no reverse-proxy
section at all: no mention of proxies, of 502s, or of TRUSTED_PROXIES,
which until now was explained only in a comment in the compose example.
It gains one, including that TRUSTED_PROXIES cannot cause a 502 and is
the wrong place to dig. INSTALL.md's nginx-in-front-of-Apache path gains
the proxy_* buffer settings its fastcgi_* equivalents already had.
Reported by @denkfabrik-li (#1664), who traced it to the middleware
independently, and separately by a user running Nginx Proxy Manager who
found the too-big-header line in the proxy's own log.
ProjectSend prints the update instructions for the way this server was
installed, and it knew two answers where it needed three: anything inside
a container was handed `docker compose pull && docker compose up -d`. On
the Compose stack that builds from a checkout there is no image behind
those containers, so `pull` skips every ProjectSend service and `up -d`
then finds them all current — the update reports success, changes
nothing, and the dashboard goes on offering the same release. Reported by
@mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661).
Those installations are now their own kind, told to `git pull` and
rebuild, with the two steps a checkout needs that an image does not: its
dependencies and its compiled frontend live outside git, so a release
that moved either leaves them stale.
Two signals decide it, in that order. The published image now declares
itself with PROJECTSEND_IMAGE, which is the only evidence an operator
bind-mounting over /var/www/html can neither hide nor forge; failing that
— images published before this — a working tree in the install directory,
which the image never has and the repository's own stack always does.
getenv() rather than env(), because a cached configuration makes env()
outside a config file return null, and the answer would flip silently on
exactly the installs most likely to have cached it.
The stale-code banner keeps treating both container kinds alike: what
clears it is recreating the container, whichever way its image was built.
The changelog also credits the reporter of #1663, which was missed when
that entry was written.
The image creates a fixed-uid www-data for php-fpm but left nginx on the
alpine package's own `user nginx;` (uid 100), while nginx.conf's header
claimed the two already matched. They did not, and the claim is why
nobody looked.
It matters because php-fpm hands nginx files to serve: protected
downloads and thumbnails go out by X-Accel-Redirect. Directories the
application creates on demand come out at Flysystem's private mode, 0700,
owned by www-data — and traversing one of those means being its owner,
since there are no group or other bits to fall back on. So the first
thumbnail an installation ever renders answers 403, from nginx, with
nothing in the application's log to show for it (#1614).
Pointing the directive at www-data is half the fix. /var/lib/nginx and
its tmp/ arrive from the package owned by the old user, and nginx
recreates only the leaf temp directories at boot — as the new user, so
they look correct while their parent stays untraversable. Every request
nginx buffers to disk then fails with a bare 500 that never reaches
php-fpm, which is every upload chunk, which is every upload (#1618).
Fixing one without the other trades a broken thumbnail for a broken
upload, so both land together.
Verified by building the image three ways and driving a real upload and
thumbnail through each: unchanged, the part PUT succeeds and the
thumbnail 403s; with the user directive alone, the thumbnail works and
the part PUT is a bare nginx 500 with "open()
/var/lib/nginx/tmp/client_body/0000000001 failed (13: Permission
denied)"; with both, the chunked upload completes, the thumbnail renders,
and the file downloads back byte for byte.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The base php:*-fpm image creates /var/www/html owned by its own www-data
(uid 82) and mode 1777, so that an image can run as an arbitrary user.
This image replaces www-data with a fixed uid 1000 and copies the
release in with COPY --chown — which re-owns what it copies into the
directory, never the directory itself. It was left world-writable,
sticky, and owned by a uid the container no longer has.
fs.protected_symlinks — on by default on Ubuntu, Debian and most current
distributions — then refuses to let a non-root process follow a symlink
in such a directory, and .env is exactly that: the entrypoint keeps it
on the storage volume so a generated APP_KEY survives container
replacement, and links it into place. So every request 503'd with
"ProjectSend is not configured yet" while `docker exec ... cat .env`,
run as root, printed the file back perfectly (#1615).
Three changes, each independently sufficient for the reported case, and
deliberately so — this failure is silent and its symptom points away
from its cause:
- the image owns /var/www/html as the runtime user, at mode 755;
- the entrypoint owns the symlink it creates, so it stays followable
even if that directory's mode ever drifts back;
- preflight distinguishes "no .env" from ".env is there and cannot be
read", instead of reporting the second as the first and sending the
operator off to create a file they already have.
Verified by building the production image before and after: every
request 503s beforehand, with /var/www/html at uid 82 mode 1777 and
www-data denied on the symlink while root reads it; afterwards /up
answers 200, the container reports healthy, and / redirects to /setup.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.
This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.
Free software under the GNU General Public License v2, or (at your
option) any later version.