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 onlyrequires it. - Place
public/rosanna-defer.js(done automatically by thecomposer installpost-install hook; manually it isvendor/bin/rosanna-defer publish). - The document root is
public/, with a front controller that routes everything except real files topublic/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. relayer compile writes both to <projectRoot>/var/cache/psx (skipping a missing directory).
php vendor/bin/relayer compileWhen using rosanna-defer compile directly, pass only directories that exist, all in one command (a missing path exits 1). The manifest is rewritten on every run, so compiling one directory at a time drops the components of the earlier run from the manifest. Do not use vendor/bin/rosanna compile: it does not write the deferred manifest Relayer uses.
php vendor/bin/rosanna-defer compile src/Components src/Pages --cache="$PWD/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). - The manifest stores the absolute paths of the compiled files (a relative
--cacheis resolved against the cwd). Here too, a different location at build and run time causes "Compiled PSX file not found". - The cache lives in one place,
<root>/var/cache/psx.Relayer::boot()pins both components and pages/layouts there. public/index.phppasses a normalized absolute projectRoot, likeRelayer::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\Shellat the top of that page. This tells the batch compiler to "resolve via the runtime manifest"; without it,rosanna-defer compile src/Pagesexits 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
pagevsroute.phpcollision or a route-group URL collision is caught byroutes:compile, not on the first production request. - It is a separate step from PSX precompilation (
rosanna-defer compilehandles.psx;routes:compilefolds the route table). Run both at build time. No absolute--cachepath 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.yamlorApp\AppConfigurator). They are caught bycontainer:compilerather 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/relayer compile # .psx, the route table, the DI container -> PHPWith route.go files, also run relayer native:build in a build stage that has Go, and copy var/cache/native/bin to the final image (the scaffolded Dockerfile has no Go, → native routes).
The committed .env does not set APP_ENV, and unset means production (the prod target of the scaffolded Dockerfile writes a .env.local with APP_ENV=prod). Take care not to bring a development .env.local with APP_ENV=dev onto the server: it exposes /_profiler. Run with 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, likeSERVER_NAME=":8080"(= plain HTTP, not using Caddy's automatic HTTPS). On$PORT-injecting PaaS, assembleSERVER_NAME=":$PORT"at startup. - Because Caddy writes data/config to XDG directories, on a read-only FS point
XDG_DATA_HOME/XDG_CONFIG_HOMEunder/tmp.
Worker mode (FrankenPHP)#
The scaffolded Caddyfile sends requests to public/worker.php through the index worker.php line and the worker block of php_server. This is the production setup that boots the app once and serves requests from memory; use it with the single binary (static-build.Dockerfile) or frankenphp run --config Caddyfile. Remove the index and worker lines for classic mode, where public/index.php handles each request.
- Not for development. A worker keeps loaded pages and components in memory, so an edit does not show until it restarts. Develop with
php -S 127.0.0.1:8000 -t publicordocker compose up --build(neither reads this file). - Container services live across requests. Do not keep per-request state in service properties; clear what you do keep after each request with
RequestLifecycle::onReset()(one callback per name; the same name replaces it). public/worker.phpcallsRelayer::endRequest()after each request, dropping the request-scoped state the framework and Rosanna keep in statics. Call it too if you write your own worker loop. SetMAX_REQUESTSto restart the worker after that many requests.
use Polidog\Rosanna\Runtime\RequestLifecycle;
RequestLifecycle::onReset(self::class, function (): void {
$this->items = [];
});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/relayer 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/relayer 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 routeslists 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 (7)
- v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
- v0.31.0: relayer compile に統一、キャッシュ先を 1 か所に修正
- 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