Files
projectsend/docker/production/entrypoint.sh
T
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00

92 lines
4.0 KiB
Bash
Executable File

#!/bin/sh
set -e
# First-boot bootstrap for the official image. Adapted from
# docker/app/entrypoint.sh, with the differences that matter in production:
#
# - The database is external and may not be up yet, so migrations wait
# for it instead of failing the container into a restart loop.
# - APP_KEY is generated once and persisted, because a key that changes
# between restarts invalidates every session and makes every encrypted
# column unreadable.
# - Nothing is bind-mounted, so there is no uid juggling to do.
#
# Deliberately NOT run: `config:cache`. It stops .env from being read, which
# silently disables TRUSTED_PROXIES — every visitor then appears to come
# from the proxy, the login rate limiter treats all users as one attacker,
# and the download log records the wrong address. See INSTALL.md.
cd /var/www/html
# storage/ is the declared volume, so anything that must outlive the
# container lives there. Recreate the tree first: a bind-mounted host
# directory arrives empty, unlike a named volume which Docker seeds from
# the image.
mkdir -p storage/app/files \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs
chown -R www-data:www-data storage
# .env is kept on that volume and symlinked into place, so a generated
# APP_KEY survives container replacement. A key that changes between
# restarts invalidates every session and makes every encrypted column
# permanently unreadable — silently, since nothing errors at boot.
#
# Operators who set APP_KEY (and the rest) in the environment need none of
# this: Laravel reads the environment directly and it wins over .env.
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)"
su-exec www-data php artisan key:generate --force --no-interaction
fi
# Wait for the database. `migrate` against a database still starting up is
# the single most common first-run failure, and a bare failure here would
# restart-loop the container with a stack trace instead of a clear message.
if [ "$1" = "supervisord" ] || [ "$1" = "/usr/bin/supervisord" ]; then
i=0
until su-exec www-data php artisan db:show --quiet >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -ge 60 ]; then
echo "projectsend: database unreachable after 60s — check DB_HOST, DB_DATABASE and credentials" >&2
exit 1
fi
[ "$i" = 1 ] && echo "projectsend: waiting for the database..."
sleep 1
done
# One command, shared with update.sh and with the dev image: migrate,
# ensure the roles, relink storage, clear the compiled caches, restart
# the workers, record the version applied. See UpdateInstallation for
# why the order is what it is, and why maintenance mode is not part of
# it (`artisan down` would write its flag onto this container's
# persisted storage volume and outlive the container that wrote it).
su-exec www-data php artisan projectsend:update
# Unattended provisioning: create the first administrator from the
# environment. Without these, the web setup screen prompts instead.
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
su-exec www-data php artisan projectsend:admin --if-none \
--name="${ADMIN_NAME:-Administrator}" \
--email="$ADMIN_EMAIL" \
--password="$ADMIN_PASSWORD"
fi
fi
exec "$@"