RRelayer
Home/Operations

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.

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 updated: 2026-05-20
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