Deployment (Dockerfile)#
This page summarizes how to assemble a Dockerfile for shipping a Relayer app to production in a container. The three key points are: "precompile PSX in production", "the same absolute path at build and run time", and "a front controller with public/ as the document root".
What to get right in production#
- PHP 8.5+.
composer install --no-dev --optimize-autoloader. APP_ENVmust not bedev(in dev, on-the-fly compilation plus the
profiler run on every request; both are disabled in production).
- Precompile PSX (see below). The dev on-the-fly path is not used in
production.
- Precompile the routes (see below). By default the router walks
src/Pages/ per request; collapse it into one file at deploy time.
- Precompile the DI container (see below). The per-request
ContainerBuilder::compile() is usually the largest avoidable cost; dump it to a PHP class so production only requires it.
- Place
public/usephp.js(done automatically by thecomposer install
post-install hook; manually it is vendor/bin/usephp publish).
- If you use
StorageType::Snapshot, setUSEPHP_SNAPSHOT_SECRET. - The document root is
public/, with a front controller that routes
everything except real files to public/index.php.
Key points of PSX precompilation (this is the crux)#
When APP_ENV is not dev, .psx files are not compiled automatically, so you compile both components and pages/layouts at build time (the examples assume the runtime WORKDIR is /app; the idea is the same for /var/www/html and the like).
# Components (PascalCase) → <projectRoot>/var/cache/psx (manifest generated)
php vendor/bin/usephp compile src/Components --cache="$PWD/var/cache/psx"
# Pages/layouts → <projectRoot>/src/var/cache/psx
# AppRouter looks at dirname(src/Pages)/var/cache/psx = src/var/cache/psx
php vendor/bin/usephp compile src/Pages --cache="$PWD/src/var/cache/psx"Why it works this way:
- The cache file name is
sha1(realpath(source)). That is why you
need the same absolute path at build and run time (compiling in a single stage, with the same WORKDIR as the runtime, is the safe choice).
- Pass
--cacheas an absolute path. A relative one puts a relative
path into the component manifest, which mismatches the runtime cwd and causes "Compiled PSX file not found".
- The cache is split across two locations. For components,
PsxComponentRegistrar looks at <root>/var/cache/psx; for pages/layouts, AppRouter looks at dirname(<root>/src/Pages)/var/cache/psx (i.e. <root>/src/var/cache/psx).
public/index.phppasses a normalized absolute projectRoot, like
Relayer::boot(\dirname(__DIR__), …) (do not leave the /.. of __DIR__ . '/..'). This keeps the cache key stable.
- When a page uses a component such as
<Shell>, declare
// @psx-runtime App\Components\Shell at the top of that page. This tells the batch compiler to "resolve via the runtime manifest"; without it, usephp compile src/Pages exits 1 (the file is not generated).
If the design has no file writes at runtime (external DB / static ETag / profiler disabled), a read-only container FS is fine. Point sessions and temp files at /tmp.
Route precompilation#
By default the router walks src/Pages/ on every request. Running vendor/bin/relayer routes:compile at deploy time writes one readable, portable route snapshot to var/cache/routes/routes.php, and production reads just that OPcache'd file instead of walking the tree.
php vendor/bin/relayer routes:compile- Only its presence is the gate. If the file exists it is used; if
not, it falls back to live scanning. dev does not compile, so it always reflects the live tree and never goes stale.
- Scan-time ambiguities fail at deploy time. A
pagevs
route.php collision or a route-group URL collision is caught by routes:compile, not on the first production request.
- It is a separate step from PSX precompilation (
usephp compile
handles .psx; routes:compile folds the route table). Run both at build time. No absolute --cache path is needed; the output is always <projectRoot>/var/cache/routes/routes.php (an auto-generated snapshot — do not edit by hand; regenerate with the same command).
- Exit codes:
0success /1missing or unscannablesrc/Pages, or
a write failure.
See CLI Commands for details.
DI container precompilation#
By default Relayer::boot() builds Symfony's ContainerBuilder and runs compile() on every request — usually the largest avoidable per-request cost. Running vendor/bin/relayer container:compile at deploy time dumps it to a plain PHP class at var/cache/container/CompiledContainer.php, and production requires that instead of building a ContainerBuilder.
php vendor/bin/relayer container:compile- Only its presence is the gate. If the file exists production
uses it; if not, it falls back to a live build. dev does not compile, so it always reflects the latest service configuration and never goes stale.
- Bad service definitions fail at deploy time (configuration
errors in config/services.yaml or App\AppConfigurator). They are caught by container:compile rather than on the first production request, so the shipped image is never broken.
- It is a separate step from PSX precompilation and
routes:compile
(each produces its own cache). The output is always the path above (auto-generated; do not edit by hand). Regenerate after changing service configuration (re-run the same command).
- Exit codes:
0success /1build, dump, or write failure.
Build steps at a glance#
After composer install, generate the three build artifacts to get an OPcache'd, walk-free image:
composer install --no-dev --classmap-authoritative
vendor/bin/usephp compile src/Components --cache="$PWD/var/cache/psx"
vendor/bin/usephp compile src/Pages --cache="$PWD/src/var/cache/psx"
vendor/bin/relayer routes:compile # the route table -> PHP
vendor/bin/relayer container:compile # the DI container -> PHPRun with APP_ENV not set to dev (unset is fine) and OPcache validate_timestamps=0. Every step is presence-gated, so a missing artifact only falls back to the live path — nothing breaks.
Read-only filesystem. The scaffolded Dockerfile runs all three steps above in its prod target, so the image generates nothing at runtime and can run with an immutable filesystem. compose.yaml ships that shape as a commented app-prod service: read_only: true plus a small tmpfs only on paths that still receive writes — the ETag store (var/cache/etags), PHP sessions and upload temp files (both relocated under var/cache/ by the prod image), and Caddy's own /data and /config. Do not mount over var/cache/ as a whole: compiled routes, the compiled container, and compiled .psx files live there, and an empty tmpfs on top hides them, falling back to per-request scanning and rebuilding.
Server configuration#
FrankenPHP (single binary, recommended)#
FrankenPHP is a single binary that embeds PHP into Caddy. No nginx or php-fpm needed — the php_server directive performs the front controller (root at public/, everything except real files to index.php) out of the box. Environment variables pass straight through to PHP (no clear_env issue).
- The listen address is the environment variable
SERVER_NAME. If TLS
is terminated upstream (Fly.io / load balancer), specify only the port, like SERVER_NAME=":8080" (= plain HTTP, not using Caddy's automatic HTTPS). On $PORT-injecting PaaS, assemble SERVER_NAME=":$PORT" at startup.
- Because Caddy writes data/config to XDG directories, on a read-only FS
point XDG_DATA_HOME / XDG_CONFIG_HOME under /tmp.
nginx + php-fpm#
Set the root to public/ and route everything except real files to index.php. php-fpm passes the container's environment variables to PHP with clear_env = no (see Environment Variables).
server {
listen __PORT__;
root /var/www/html/public;
location / { try_files $uri /index.php$is_args$args; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}; php-fpm.d/zz-app.conf
[www]
clear_env = no ; pass APP_ENV / DB credentials etc. to PHP
catch_workers_output = yes ; send PHP errors to the container log (stderr)On a $PORT-injecting PaaS the config cannot read environment variables directly, so substitute it in a startup script before starting both processes.
#!/bin/sh
set -e
: "${PORT:=8080}"
sed -i "s/__PORT__/${PORT}/g" /etc/nginx/nginx.conf
php-fpm --nodaemonize &
exec nginx -g 'daemon off;'Reference Dockerfiles#
FrankenPHP#
FROM dunglas/frankenphp:php8.5
# The FrankenPHP image bundles install-php-extensions
RUN install-php-extensions mbstring opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Production ini (base on php.ini-production, then layer overrides)
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
ENV APP_ENV=production \
COMPOSER_ALLOW_SUPERUSER=1 \
SERVER_NAME=":8080" \
XDG_DATA_HOME=/tmp/caddy \
XDG_CONFIG_HOME=/tmp/caddy
# FrankenPHP's default document root is /app/public (php_server handles
# the front controller). Compile in the same /app as the runtime.
WORKDIR /app
COPY . .
RUN set -eux; \
composer install --no-dev --optimize-autoloader --no-interaction; \
php vendor/bin/usephp compile src/Components --cache=/app/var/cache/psx; \
php vendor/bin/usephp compile src/Pages --cache=/app/src/var/cache/psx; \
php vendor/bin/relayer routes:compile; \
php vendor/bin/relayer container:compile
COPY docker/php.ini "$PHP_INI_DIR/conf.d/zz-app.ini"
EXPOSE 8080
# The default CMD (frankenphp run) starts with a Caddyfile from SERVER_NAMEOn a $PORT-injecting PaaS, add an entrypoint that assembles SERVER_NAME at startup.
#!/bin/sh
set -e
export SERVER_NAME=":${PORT:-8080}"
exec frankenphp run --config /etc/frankenphp/Caddyfilenginx + php-fpm#
FROM php:8.5-fpm
# php:8.5-fpm bundles mbstring/opcache/ctype/curl/openssl/json/tokenizer
RUN set -eux; apt-get update; \
apt-get install -y --no-install-recommends nginx unzip; \
rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APP_ENV=production COMPOSER_ALLOW_SUPERUSER=1 PORT=8080
WORKDIR /var/www/html
COPY . .
RUN set -eux; \
composer install --no-dev --optimize-autoloader --no-interaction; \
php vendor/bin/usephp compile src/Components --cache=/var/www/html/var/cache/psx; \
php vendor/bin/usephp compile src/Pages --cache=/var/www/html/src/var/cache/psx; \
php vendor/bin/relayer routes:compile; \
php vendor/bin/relayer container:compile; \
chown -R www-data:www-data /var/www/html
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]php:8.5-apache also works (mod_php + FallbackResource /index.php makes it a front controller).
Verification#
- After
composer install --no-dev, check thatvendor/bin/relayer routes
lists the routes as expected (Routing & Pages).
- Run the built image locally and check that
/, dynamic segments,
route.php, and 404 return correctly, and that "Compiled PSX not found" does not appear in the log.
How this site does it#
This site deploys a FrankenPHP image (the shape above) to Fly.io, with the article store on Turso (libSQL/HTTP, falling back to a local SQLite if unconfigured). The store connection switches automatically via environment variables — the server only reads; article updates go through the local CLI bin/docs (see Environment Variables / Database). CI runs flyctl deploy --remote-only from GitHub Actions so Fly's remote builder builds this Dockerfile.
Revisions (5)
- Reflect v0.26.0 read-only prod container guidance
- Add the DI container precompilation section + build-steps summary (v0.19.0)
- Sidebar reorg: order shift for the new Development category
- Add the route-precompilation section + Dockerfile step
- Created