Relayer
DocsOperationsdeployment

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_ENV must not be dev (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 the composer install

post-install hook; manually it is vendor/bin/usephp publish).

  • If you use StorageType::Snapshot, set USEPHP_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 --cache as 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.php passes 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 page vs

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: 0 success / 1 missing or unscannable src/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: 0 success / 1 build, 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 -> PHP

Run 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 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_NAME

On 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/Caddyfile

nginx + 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 that vendor/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.

Last revised — 2026-08-18
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