diff --git a/bootstrap/preflight.php b/bootstrap/preflight.php
index 8ef7d226..9c2b6a54 100644
--- a/bootstrap/preflight.php
+++ b/bootstrap/preflight.php
@@ -62,9 +62,23 @@ function projectsend_preflight_failure(string $root): ?array
$envFile = $root.'/.env';
$hasEnvFile = is_file($envFile);
+ // A .env that is there but unreadable looks exactly like one that was
+ // never created — is_file() is false either way, since it has to follow
+ // the link and stat the target — and the two need opposite advice.
+ // Reported from the official Docker image, where .env is a symlink into
+ // storage/: the image left /var/www/html world-writable and owned by a
+ // uid that no longer existed, so the kernel's fs.protected_symlinks
+ // refused to let the php-fpm worker follow it. Every request said "no
+ // .env file was found" while `docker exec ... cat .env`, as root, printed
+ // it back perfectly — which is the most misleading pair of facts this
+ // guard could possibly hand somebody.
+ $unreadableEnv = $hasEnvFile ? ! is_readable($envFile) : is_link($envFile);
+
// Fall back to a cheap read of just APP_KEY from the file — we are not
- // going to boot Dotenv or parse the whole thing for one value.
- if ($appKey === null && $hasEnvFile) {
+ // going to boot Dotenv or parse the whole thing for one value. Skipped
+ // when the file cannot be read, or file() emits a PHP warning into the
+ // page this guard exists to keep clean.
+ if ($appKey === null && $hasEnvFile && ! $unreadableEnv) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (preg_match('/^\s*(?:export\s+)?APP_KEY\s*=\s*(.*)$/', $line, $m) === 1) {
// Strip the surrounding quotes Dotenv would also strip.
@@ -79,6 +93,18 @@ function projectsend_preflight_failure(string $root): ?array
return null;
}
+ if ($unreadableEnv) {
+ return [
+ 'ProjectSend cannot read its configuration',
+ 'A .env is in place, but the user PHP runs as cannot read it — or, if it '
+ .'is a symlink, cannot follow it. Note that root is exempt from both '
+ .'checks, so reading the file over docker exec or sudo '
+ .'proves nothing here.',
+ "Compare who owns the file with who PHP runs as, and check the directory holding it:\n\n"
+ ."ls -ln .env\nstat -c '%n %U %a' . .env",
+ ];
+ }
+
if (! $hasEnvFile) {
return [
'ProjectSend is not configured yet',
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index 9b3b5d07..f7b04d91 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -80,10 +80,24 @@ WORKDIR /var/www/html
COPY --chown=www-data:www-data app/ /var/www/html/
# storage/ and bootstrap/cache must be writable by the runtime user.
+#
+# /var/www/html itself needs re-owning as well, and it is easy to miss: COPY
+# --chown re-owns what it copies *into* the directory, never the directory,
+# so it keeps what the base image gave it — uid 82 (the www-data this image
+# replaced above) and mode 1777, which php:*-fpm sets so that an image can
+# run as an arbitrary user. Left that way, the directory is world-writable,
+# sticky, and owned by nobody the container knows about, and the kernel's
+# fs.protected_symlinks (on by default on Ubuntu, Debian and most current
+# distributions) then refuses to let the php-fpm worker follow the .env
+# symlink the entrypoint puts there. Root is exempt, so `docker exec ... cat
+# .env` reads it back perfectly while every real request 503s with
+# "ProjectSend is not configured yet".
RUN mkdir -p storage/app/files storage/framework/cache storage/framework/sessions \
storage/framework/views storage/logs bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R u+rwX storage bootstrap/cache \
+ && chown www-data:www-data /var/www/html \
+ && chmod 755 /var/www/html \
&& mkdir -p /run/nginx
# The whole of storage/, not just storage/app/files. Uploaded files are the
diff --git a/docker/production/entrypoint.sh b/docker/production/entrypoint.sh
index 49e1b3c4..27efce96 100755
--- a/docker/production/entrypoint.sh
+++ b/docker/production/entrypoint.sh
@@ -40,7 +40,15 @@ if [ ! -f storage/.env ]; then
cp .env.example storage/.env
chown www-data:www-data storage/.env
fi
+#
+# The symlink is owned by the runtime user, not by root who creates it:
+# fs.protected_symlinks lets a process follow a symlink in a world-writable
+# sticky directory only when it owns the link (or the directory). The image
+# no longer leaves /var/www/html in that state, so this is the second lock
+# on the same door — cheap, and it is what keeps the failure from coming
+# back silently if that directory's mode ever drifts.
[ -L .env ] || ln -sf storage/.env .env
+chown -h www-data:www-data .env
if [ -z "$APP_KEY" ] && ! grep -q '^APP_KEY=base64:' storage/.env; then
echo "projectsend: generating APP_KEY (first boot, persisted to the storage volume)"
diff --git a/tests/Unit/PreflightTest.php b/tests/Unit/PreflightTest.php
index d2111528..77a416fa 100644
--- a/tests/Unit/PreflightTest.php
+++ b/tests/Unit/PreflightTest.php
@@ -68,6 +68,34 @@ it('fails when a .env exists with no APP_KEY line at all', function (): void {
expect(projectsend_preflight_failure(preflightFixture(null, true)))->not->toBeNull();
});
+it('says so when .env is a symlink it cannot follow, rather than that none exists', function (): void {
+ // The official image symlinks .env into storage/. When that link cannot
+ // be followed — a missing target, or a directory whose permissions stop
+ // the web server user traversing it — every stat says "no such file",
+ // and "copy .env.example" is then exactly the wrong thing to be told.
+ $dir = preflightFixture(null, false);
+ symlink($dir.'/storage/.env', $dir.'/.env');
+
+ $failure = projectsend_preflight_failure($dir);
+
+ expect($failure)->not->toBeNull()
+ ->and($failure[0])->toBe('ProjectSend cannot read its configuration')
+ ->and($failure[1])->toContain('cannot read it')
+ ->and(projectsend_preflight_command($failure[2]))->toContain('ls -ln .env')
+ ->and(projectsend_preflight_command($failure[2]))->not->toContain('cp .env.example');
+});
+
+it('says so when .env exists but its permissions deny the reader', function (): void {
+ $dir = preflightFixture('base64:'.base64_encode(random_bytes(32)), true);
+ chmod($dir.'/.env', 0000);
+ clearstatcache();
+
+ $failure = projectsend_preflight_failure($dir);
+
+ expect($failure)->not->toBeNull()
+ ->and($failure[0])->toBe('ProjectSend cannot read its configuration');
+})->skip(fn (): bool => function_exists('posix_geteuid') && posix_geteuid() === 0, 'root can read anything, so the mode says nothing');
+
it('reads a quoted APP_KEY the way Dotenv would', function (): void {
$dir = preflightFixture('"base64:'.base64_encode(random_bytes(32)).'"', true);