docker to be more inline with other projects and best practices
PHP Test / test (pull_request) Successful in 52s
Tests / test (pull_request) Successful in 52s

This commit is contained in:
2026-09-25 08:25:55 -04:00
parent db0cf77049
commit 538bd74100
9 changed files with 355 additions and 81 deletions
+76 -4
View File
@@ -1,12 +1,84 @@
.git/
# .dockerignore — defines the build context used by `COPY . .`.
#
# Ignoring a path keeps EVERYTHING under it out of the context, which is what
# makes "copy the tree instead of listing files" safe: the allowlist that used
# to live in eight `COPY ./x /app/x` lines is now simply the absence of a
# pattern here. The failure mode is inverted — a missed entry fails the build
# loudly ("not found in build context") instead of silently baking a file in.
#
# Rules that matter (Guiding Light §6.3): `config/`, `bin/`, `public/` and the
# composer manifests are never ignored, and /Caddyfile is the inverse case —
# it is ignored, because the final stage copies it from `docker/Caddyfile`.
# ── VCS & CI ────────────────────────────────────────────────────────────────
.git
.gitignore
.editorconfig
.gitea
.ci
# ── Secrets: never bake these into a layer (§6.1, §8.12) ────────────────────
# Bare `.env` included deliberately: it is exactly the file a developer has
# locally and must not land in an image layer.
.env
.env.local
.env.local.php
.env.*.local
.env.dev
.env.test
host.env
config/secrets/prod/prod.decrypt.private.php
# ── Local runtime state (§5.2) ──────────────────────────────────────────────
# var/ holds the dev cache and logs; the prod cache is warmed inside the image.
var/
*.sqlite
*.sqlite3
*.db
# ── Rebuilt inside the image from composer.lock ─────────────────────────────
vendor/
# ── Dev / test artefacts not needed at runtime ──────────────────────────────
tests/
.phpunit.cache/
.phpunit.result.cache
phpunit.xml
phpunit.dist.xml
.php-cs-fixer.cache
.php-cs-fixer.dist.php
.php-cs-fixer.php
phpstan.neon.dist
phpstan-baseline.neon
# ── Build inputs that aren't payload ────────────────────────────────────────
Dockerfile
.dockerignore
docker-bake.hcl
compose*.yaml
docker-compose*.yaml
# ── Docs, examples and host-side tooling ────────────────────────────────────
# The root Caddyfile is the host-side example (docs/examples/ has a longer
# one); the image's config is docker/Caddyfile, copied explicitly below.
docs/
*.md
.env
.env.test
.env.local
license.txt
Caddyfile
Domainfile
run.sh
deploy.sh
bin/composer
bin/dev.sh
bin/franken.sh
bin/phpunit
composer.phar
# ── Generated config reference (regenerate with config:dump) ────────────────
config/reference.php
# ── Editor / OS noise ───────────────────────────────────────────────────────
.idea
.vscode
*.swp
.DS_Store
+57
View File
@@ -66,6 +66,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
deliberately excluded: they are consumed by the proxy's `forward_auth`
check and never reach the browser.
### Changed
- **Dockerfile rebuild — same layout as the rest of the portfolio** (Guiding
Light §6.4). The build now copies the tree (`COPY . .`) and lets
`.dockerignore` decide what reaches the context, instead of maintaining a
hand-written `COPY ./x /app/x` allowlist that had to be kept in step with
the project layout. `var/` — which the old file list never copied — now
simply stays out via the ignore file.
- **The image runs as a non-root `app` user** (uid/gid 1000, the same
convention as task-loom/context-shuttle). `/data` (cache pools) and
`/config` are created and owned by it. This resolves the last failing
conformance check (§6.4 `dockerfile-nonroot`).
- **`.dockerignore` rebuilt on the Guiding Light §6.2 baseline** — in
particular `.env` is now excluded explicitly (§6.1), so a developer's
local environment file can never be baked into a layer.
- **`docker/php.ini` and `docker/Caddyfile` added.** The PHP overrides
(`expose_php=Off`, error/log settings, OPcache timestamps off, APCu for
CLI) and the FrankenPHP app config now live in the repository instead of
being three heredocs inside the Dockerfile, so what the image runs is
reviewable in a diff.
- **Runtime base image pinned to `dunglas/frankenphp:1-php8.5-trixie` and
APCu installed via the base image's `install-php-extensions`** — the
versioned tag replaces the floating one, and the build no longer drags a
compiler toolchain into the runtime layer to build one extension.
- **`/app` is now the whole project.** The old image only shipped
`bin/console`, `config`, `public`, `src`, `templates` and the composer
manifests; `config/reference.php` and other loose files are now present.
No application path changes: `public/index.php` and `bin/console` resolve
through the same relative paths.
- `bin/franken.sh` mounts the share dir at its new default
(`/app/var/share`) instead of the old `/app/var/share` bind that no longer
matched the image.
### Fixed
- **`composer dump-env prod --empty` removed.** preauth does not depend on
`symfony/dotenv` (it is not in `composer.lock`), so nothing reads a `.env`
file in the container — the command only produced a dead
`.env.local.php` in the build stage. The Dockerfile comment that claimed
otherwise is gone with it.
- **`composer install` no longer ships a classmap missing `App\`.** The old
build ran `install --optimize-autoloader` before `src/` was copied, and the
final `--classmap-authoritative` dump happened before any `COPY . .`; the
classmap is now rebuilt after the application is in place.
- **The HEALTHCHECK can actually pass.** It probed `curl -f http://localhost/`,
and preauth answers every unauthenticated request to `/` with the login page
and a `401` — so the probe failed 100% of the time and the container was
permanently marked unhealthy. It now probes Caddy's loopback admin endpoint
(the base image's own default probe, restated explicitly), which is why the
Caddyfile deliberately does not disable the admin API.
- **`expose_php` is now genuinely off in the runtime image.** The base image
ships the `php.ini-production` *template* but no active `php.ini`, so the
previous `cp` of the template was the only thing setting it — and the
`docker/php.ini` overrides are loaded after it, so stating it here makes the
intent explicit; verified against a real boot that no `X-Powered-By` header
is emitted.
- `bin/franken.sh` no longer passes `DEFAULT_URI`, which the application does
not read (`config/packages/routing.yaml` sets the router's `default_uri`).
## [1.0.0] — v1.0 Release
### Security
+109 -65
View File
@@ -1,84 +1,128 @@
# use build image, to simplify final image
# syntax=docker/dockerfile:1.7
#
# PreAuth — one app, one image.
#
# Multi-stage FrankenPHP build: dependencies in a builder, the runtime image
# only gets the finished tree. Runtime: FrankenPHP worker mode, non-root,
# state on /data. TLS is terminated upstream of the container; FrankenPHP
# serves :80.
#
# Build context: the whole tree (`COPY . .`), narrowed by .dockerignore. The
# allowlist that used to live in the eight `COPY ./x /app/x` below is in that
# file now — a directory that must ship is a directory it does not exclude.
#
# Secrets are injected at runtime as env vars, never baked in (§8.12).
# ── Stage: build — composer dependencies + prod app ────────────────────────
FROM php:8.5-trixie AS build
# install APCu and composer
RUN pecl install apcu && \
docker-php-ext-enable apcu
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get update && \
apt-get install -y unzip git
# Build-time set: git (composer resolves packages over VCS) and unzip (dist
# extraction). Neither reaches the runtime image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*
# symfony required environment variables
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# APCu and Composer, both only needed to compile the application.
RUN pecl install apcu \
&& docker-php-ext-enable apcu \
&& docker-php-ext-enable opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# load application into build image
RUN mkdir -p /data/preauth
RUN mkdir -p /app/bin
WORKDIR /app
COPY ./bin/console /app/bin/console
COPY ./config /app/config
COPY ./public /app/public
COPY ./src /app/src
COPY ./templates /app/templates
COPY ./composer.json /app/composer.json
COPY ./composer.lock /app/composer.lock
COPY ./symfony.lock /app/symfony.lock
# install application dependencies
RUN composer install --no-dev --optimize-autoloader
RUN composer dump-env prod --empty
# Manifests first so the dependency layer only rebuilds when they change.
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist \
--optimize-autoloader --no-scripts
# start creating final image
# Named `app` so docker-bake.hcl can target it explicitly. Naming the final
# stage changes nothing for a plain `docker build` — the last stage is still
# the default build target.
FROM dunglas/frankenphp:php8.5-trixie AS app
# Copy the application. .dockerignore keeps vendor/, var/, tests/ and the
# local env files out of the context; composer install has already run, so
# its vendor/ wins.
COPY . .
# install APCu and curl (needed for healthcheck)
RUN pecl install apcu && \
docker-php-ext-enable apcu
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# src/ was not in the context when composer install ran, so the authoritative
# classmap has to be rebuilt now that the application code is present.
#
# There is deliberately no `composer dump-env` step: preauth does not depend
# on symfony/dotenv (it is absent from composer.lock), so nothing reads a
# .env file at runtime and the dump would only add a dead file. Runtime
# configuration comes from the environment, with the defaults documented in
# config/services.yaml.
RUN composer dump-autoload --classmap-authoritative --no-dev
# symfony required environment variables
ENV APP_DEBUG=0
ENV APP_ENV=prod
ENV APP_SHARE_DIR=/data/preauth
# Build-time smoke of the autoloader + config compile. No APP_SECRET is
# needed: %env(APP_SECRET)% is not resolved at compile time, and the cache is
# cleared afterwards anyway — the real warm-up runs at container start with
# the injected secrets (entrypoint; §8.12).
#
# The cache is written to the share dir, not var/cache: the runtime image
# ships without a warmed var/cache, so the first container start does the
# build for its own APP_SECRET (and the pages/ filesystem pool needs a
# writable dir owned by the app user).
RUN APP_ENV=prod APP_SHARE_DIR=/data/preauth bin/console cache:warmup \
&& rm -rf var/cache/* var/log/*
# worker thread lifecycle: restart each PHP thread after N requests to
# contain slow memory growth. Matches the previous default loop count of
# runtime/frankenphp-symfony (removed in the Symfony 8.1 upgrade).
# Expose as a build arg so images can bake in a different default;
# MAX_REQUESTS=0 disables restarts. Runtime override: the same env var is
# read by the Caddyfile placeholder.
ARG MAX_REQUESTS=500
ENV MAX_REQUESTS=$MAX_REQUESTS
# ── Stage: app — the runtime image ─────────────────────────────────────────
FROM dunglas/frankenphp:1-php8.5-trixie AS app
# Runtime set: curl is the HEALTHCHECK's probe; APCu is the state store.
# The base image ships the install-php-extensions script, which builds the
# extension and removes its own build dependencies afterwards — so git,
# autoconf and gcc never reach this stage the way `pecl install` needed them.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& install-php-extensions apcu \
&& rm -rf /var/lib/apt/lists/*
# PHP configuration. The packaged production baseline is copied in first
# (the base image ships the template, not an active php.ini), then the app's
# own overrides are layered on top of it — they restate the security-critical
# switches so the intent survives a base-image default changing underneath us.
COPY docker/php.ini $PHP_INI_DIR/conf.d/zz-preauth.ini
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
# load application into final image
WORKDIR /app
COPY --from=build /data/preauth /data/preauth
COPY --from=build /app /app
# configure container
COPY ./Caddyfile /etc/frankenphp/Caddyfile
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini
# console needs apc to manage cache
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini
# Non-root runtime user (Guiding Light §6.4). uid/gid 1000, same convention
# as task-loom/task-weaver/context-shuttle. /data holds the cache pools the
# app writes at runtime, /config is Caddy's own XDG dir.
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app \
--home-dir /app --shell /usr/sbin/nologin app \
&& mkdir -p /data/preauth /config \
&& chown -R app:app /app /data
# app uses var folder for cache storage
USER app
# FrankenPHP listens on :80; TLS is terminated by the external proxy.
# APP_SHARE_DIR points the filesystem cache pools (sessions, rate limiter)
# at the volume. MAX_REQUESTS is a build arg so images can bake in a
# different worker-recycle default; the Caddyfile placeholder reads it.
ARG MAX_REQUESTS=500
ENV APP_ENV=prod \
APP_DEBUG=0 \
APP_SHARE_DIR=/data/preauth \
SERVER_NAME=:80 \
MAX_REQUESTS=$MAX_REQUESTS
# Persistent state: cache pools (sessions, backup codes, rate limits) and
# Caddy's data. Only /data is needed at runtime; /config is declared because
# the base image points XDG_CONFIG_HOME at it.
VOLUME ["/config", "/data"]
# runs http on standard port
EXPOSE 80
# healthcheck
HEALTHCHECK --interval=5m \
--retries=3 \
--start-interval=1s \
--start-period=10s \
--timeout=2s \
CMD curl http://localhost || exit 1
# Liveness: Caddy's own admin endpoint, bound to loopback inside the
# container, exactly as the base image declares it (restated here so the
# probe does not depend on the upstream default staying put). The app's own
# routes cannot serve this: an unauthenticated request gets the login page
# with a 401, so `curl -f` against HTTP would always report unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:2019/metrics || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
+7 -5
View File
@@ -62,8 +62,10 @@ services preauth protects.
## Deployment note
preauth runs as a container and, once
[GUIDING-LIGHT §6.4](https://code.devgnome.com/private/ci) is adopted here, will
drop privileges via `USER`. The image declares `VOLUME ["/config", "/data"]`;
if you pin a `user:` in your compose file, that user must be able to write both
paths — otherwise login state and backup codes cannot be persisted.
preauth runs as a container and drops privileges via `USER` (Guiding Light
§6.4): the image runs as the non-root `app` user (uid/gid 1000) and owns the
state paths it needs. Only `/data` is written at runtime — the cache pools
behind sessions, backup codes and rate limiting — and `/config` is declared
because the base image points Caddy's XDG config dir there. If you pin a
different `user:` in your compose file, that user must be able to write to
both paths — otherwise login state and backup codes cannot be persisted.
+1 -3
View File
@@ -9,8 +9,6 @@ docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
-e APP_SHARE_DIR=/app/var/share \
-p 8000:80 \
digitaladapt/preauth:dev
+36
View File
@@ -0,0 +1,36 @@
# PreAuth — Caddyfile / FrankenPHP app config.
#
# The container serves plain HTTP on :80; TLS is terminated by the upstream
# proxy. SERVER_NAME=:80 is set in the Dockerfile.
#
# This is the config the image ships (/etc/frankenphp/Caddyfile). The
# Caddyfile in the repository root is the example for host-side setups.
{
frankenphp {
# Restart each PHP worker thread after this many requests, containing
# slow memory growth across long uptime. Preserves the 7.4-era default
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
# in the default of 500 via build arg; override at runtime with:
# docker run -e MAX_REQUESTS=5000 ...
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
# directive under this block instead.
max_requests {$MAX_REQUESTS}
}
# The admin API is deliberately left at its default: bound to 127.0.0.1
# inside the container, where it is the target of the image's
# HEALTHCHECK. It is not reachable from outside the container. Do NOT set
# `admin off` here without also changing that probe — the app has no 2xx
# liveness route to fall back on, because every anonymous request is
# answered with the login page and a 401.
}
http:// {
root public/
rewrite index.php
php {
root /app/public
worker index.php
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
#
# PreAuth container entrypoint.
#
# Responsibilities:
# 1. Warm the prod cache with the injected secrets.
# 2. Hand off to CMD (FrankenPHP server, or a console override:
# `docker exec -it preauth bin/console app:generate-backup-codes`).
#
# Secrets are env vars injected at runtime, never baked into images (§8.12).
# The container has no shell to hand out otherwise — it runs as an unprivileged
# user with a nologin shell — so the real boot validation is
# `cache:warmup` failing here, which is also what makes it worth doing.
set -e
if [ "$APP_ENV" = "prod" ]; then
echo "Warming cache..."
php bin/console cache:warmup
fi
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
; PreAuth php.ini overrides — merged on top of the FrankenPHP base image
; defaults.
;
; The base image ships no php.ini (only the php.ini-production template), so
; the production switches that matter are stated explicitly here rather than
; inherited — verified against a real boot: without them the response carries
; `X-Powered-By: PHP/8.5.10` and errors would render into the body.
;
; PreAuth keeps its session state in APCu plus a filesystem cache pool, so the
; settings that matter most are the cache ones.
; Never advertise the interpreter, never print errors to the client. This is
; an authentication gateway: a stack trace in a 500 body is an information
; leak. Errors go to stderr for the log collector.
expose_php = Off
display_errors = Off
log_errors = On
error_log = /proc/self/fd/2
memory_limit = 256M
upload_max_filesize = 2M
post_max_size = 8M
; OPcache for the FrankenPHP worker: the image is immutable, so timestamps
; never need revalidating. The CLI console also runs the app, hence
; enable_cli = 1.
opcache.enable = 1
opcache.enable_cli = 1
opcache.validate_timestamps = 0
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
; APCu — nonce cache, rate limiter and session cache all live in it, and the
; console needs it too (`bin/console` commands manage cache state).
apc.enabled = 1
apc.enable_cli = 1
apc.shm_size = 64M
apc.ttl = 0
date.timezone = UTC
+6 -4
View File
@@ -1,7 +1,8 @@
services:
preauth:
env_file:
# copy ".env.example" to ".env", edit as needed
# copy ".env.example" to ".env", edit as needed, and put APP_SECRET
# in it (any long random string).
# strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file
- .env
@@ -9,8 +10,10 @@ services:
- 80
image: digitaladapt/preauth:latest
restart: unless-stopped
# if you wish to set the user, you must make sure that the user
# can write to /config and /data within the container
# The image runs as the non-root `app` user (uid/gid 1000) and creates
# its state directories owned by that user, so a named volume inherits
# the right ownership on first start — no `user:` override is needed.
# If you pin one anyway, it must be able to write /config and /data.
#user: <uid>:<gid>
volumes:
- preauth-config:/config
@@ -19,4 +22,3 @@ services:
volumes:
preauth-config:
preauth-data: