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.
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.
Change history (4)
- 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