Files
pl_mail/Dockerfile
T
Paul LütznerandClaude Opus 5 3234fecbf6 Stamp the build after the vendors, not before them
The Dockerfile writes APP_VERSION and APP_COMMIT into the prod image so
the admin panel can say which build it is. Both sat above the vendor
install, and every layer after an ENV is keyed on that ENV's value — so
a commit hash, which is different on every commit by construction, meant
`composer install` was never once served from cache. The comment on the
line directly below promises exactly the opposite:

    # prevent the reinstallation of vendors at every changes in the source code

Every build of every branch and tag therefore re-resolved and
re-downloaded 111 packages, and the per-architecture gha cache
docker.yml is careful to scope could only ever hit the layers above that
point.

The cost was minutes; the exposure is what found it. A step that runs on
every build is a step that needs the network on every build, and the
`main` image build failed twice at that line while the release tag of
the same commit went through on a retry — the difference being which one
happened to ask a package host during a bad minute. Self-hosters follow
`:main`, so that image had been stale since.

Moved below the install, keyed on composer.lock alone. It cannot go
further: `composer dump-env prod` below snapshots the environment, and
these have to be in it by then.

Verified under BuildKit, which is what CI builds with — podman cannot
answer the question, since its handling of `COPY --link` busts that
layer whatever the ENVs do:

  before   change only APP_COMMIT -> "Installing dependencies from lock file"
  after    change only APP_COMMIT -> "#15 CACHED"

and the stamp still reaches the image: APP_VERSION and APP_COMMIT are
both in its environment, where AppVersion reads them.

This removes the exposure rather than explaining the upstream failure —
the log for it is behind an authenticated endpoint and says only that
composer exited 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:33 +02:00

172 lines
6.7 KiB
Docker

#syntax=docker/dockerfile:1
# Versions
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target
# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base
WORKDIR /app
# NOTE: there is deliberately no `VOLUME /app/var/` here.
#
# It gave every container its own anonymous volume at /app/var, so attachments
# and raw messages written by the sync workers were invisible to the web
# container that serves them — downloads 404'd, and the data vanished on every
# container recreate. The durable paths are now bound explicitly per service
# (var/attachments, var/raw, var/uploads); see compose.override.yaml and
# truenas.compose.yaml. Re-adding this line silently breaks blob download again.
# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
file \
gettext \
git \
&& rm -rf /var/lib/apt/lists/*
# pg_dump, for `app:backup`.
#
# From PGDG rather than Debian, and pinned to the same major as the server:
# pg_dump refuses outright to dump a server newer than itself, and Debian's
# postgresql-client trails 18 — so the distro package would install cleanly and
# then fail at the moment someone actually needed a backup.
#
# PGDG publishes amd64 and arm64, which the two-runner image build requires.
# hadolint ignore=DL3008
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends curl ca-certificates gnupg; \
install -d /usr/share/postgresql-common/pgdg; \
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc; \
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list; \
apt-get update; \
apt-get install -y --no-install-recommends postgresql-client-18; \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \
zip \
pdo_pgsql \
pgsql \
pcntl \
gmp \
# Attachment previews only — see AttachmentThumbnailer, which degrades
# to the paperclip icon where this is missing.
gd \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1
COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/conf.d/
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
# Its own binary, not just a step inside the entrypoint: the secrets-init
# service runs it on its own, before Postgres and Mercure start.
COPY --link --chmod=755 frankenphp/generate-secrets.sh /usr/local/bin/generate-secrets
COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
# /healthz, not Caddy's metrics port. The old probe answered as soon as the web
# server was listening, which is true well before PHP can reach the database —
# so a stack with an unreachable database reported itself healthy, and
# `depends_on: service_healthy` waited for nothing worth waiting for.
#
# The app endpoint returns 503 when the database is down and 200 otherwise; see
# App\Controller\HealthController for why a backed-up queue deliberately stays
# 200. The worker services have no HTTP server and disable this in compose —
# their liveness reaches the same endpoint through heartbeats instead.
HEALTHCHECK --start-period=60s CMD curl -f http://localhost/healthz || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ]
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off
ENV FRANKENPHP_WORKER_CONFIG=watch
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
install-php-extensions \
xdebug \
;
COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch" ]
# Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/frankenphp/worker.Caddyfile
# prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./
RUN set -eux; \
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
# Which build this is, for the admin panel to show.
#
# It has to arrive here as an argument, because the image has no history to ask:
# the source is copied in and .git stays behind. Left empty by a plain
# `docker build`, which is correct — an image nobody stamped honestly does not
# know what it is, and AppVersion says "development" rather than guessing.
# The release workflow passes both from the metadata action.
#
# BELOW THE VENDOR INSTALL, AND THAT IS THE WHOLE POINT OF WHERE IT SITS.
# These two lines were above it, which quietly defeated the line the comment
# four lines up promises: every layer after an ENV is keyed on that ENV's value,
# and APP_COMMIT is a different value on every single commit. So the vendor
# layer was never once reused — every build of every branch re-ran
# `composer install` and re-downloaded 111 packages, and the cache the workflow
# carefully scopes per architecture could only ever hit the layers ABOVE this
# point.
#
# It is a build-time cost and it was also a reliability one, which is how it was
# found: a step that runs on every build is a step that needs the network on
# every build, and `composer install` failing on a release is a release that
# does not ship until somebody presses a button. Moved down, the vendors are
# keyed on composer.lock alone — they change when the dependencies change, which
# is what the original comment always meant.
#
# It cannot move any further down: `composer dump-env prod` below snapshots the
# environment into .env.local.php, and these have to be in it by then.
ARG APP_VERSION=""
ARG APP_COMMIT=""
ENV APP_VERSION=$APP_VERSION
ENV APP_COMMIT=$APP_COMMIT
# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
php bin/console tailwind:build --minify; \
php bin/console asset-map:compile; \
chmod +x bin/console; sync;