Deployment

Configuration for FrankenPHP, nginx+FPM and Apache, plus the checklist before you go live.

The same application runs on all three. Nothing in app/ or src/ changes — only the server configuration and which adapter public/index.php selects, which it does by itself.

Before you go live

  • APP_DEBUG=false. Debug mode puts stack traces, file paths and SQL into responses.
  • APP_URL set to the real origin, so generated absolute URLs and link previews are right.
  • Configuration supplied by the environment, not a .env on the server.
  • ./orbit migrate run as a deploy step — never at boot.
  • Document root pointed at public/, so .env, storage/ and vendor/ are unreachable.
  • storage/ writable by the web server user; chmod 750 is usually right.
  • HTTPS terminated, and TRUSTED_PROXIES set if you are behind a load balancer.
  • composer install --no-dev --optimize-autoloader.

FrankenPHP (worker mode)

The fastest option, and the one that shares its process model with ./orbit serve.

Output
# Caddyfile
example.test {
    root * /srv/app/public
    encode zstd gzip

    php_server {
        worker /srv/app/public/index.php
    }
}
Shell
$ frankenphp run --config /etc/caddy/Caddyfile

public/index.php detects FrankenPHP and uses the worker adapter, which boots the application once and then serves requests in a loop, collecting cycles between them.

Deploying a worker needs a restart

The application is booted once per worker process, so new code is not picked up until the workers restart. Reload FrankenPHP as part of your deploy, after migrations have run.

nginx + PHP-FPM

Output
server {
    listen 443 ssl http2;
    server_name example.test;
    root /srv/app/public;
    index index.php;

    # Real files first; everything else goes to the front controller.
    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param HTTPS on;
    }

    # Dotfiles are never served. The framework refuses them too — two
    # independent protections, because this line gets lost in a refactor.
    location ~ /\. {
        deny all;
    }

    client_max_body_size 8m;
}

Static files are served by nginx directly, before PHP is invoked, so ServeStaticFiles never runs here. That is the intended arrangement — it is considerably faster.

Apache

public/.htaccess ships with the framework:

Output
<IfModule mod_rewrite.c>
    RewriteEngine On

    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [L]
</IfModule>

<FilesMatch "^\.">
    Require all denied
</FilesMatch>

The virtual host needs AllowOverride All, or the rewrite rules are ignored and every URL 404s:

Output
<VirtualHost *:443>
    ServerName example.test
    DocumentRoot /srv/app/public

    <Directory /srv/app/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Configuration in production

The real environment wins over .env, so inject settings the way your platform prefers:

Output
# systemd unit
[Service]
Environment=APP_DEBUG=false
Environment=APP_URL=https://example.test
Environment=DB_DATABASE=/var/lib/app/app.sqlite
EnvironmentFile=/etc/app/secrets.env
Output
# docker-compose
services:
  app:
    environment:
      APP_DEBUG: "false"
      APP_URL: "https://example.test"
    secrets:
      - db_password

A stale .env left on a server cannot override these, which is the point of that rule.

Behind a load balancer

.env
TRUSTED_PROXIES=10.0.0.1,10.0.0.2

X-Forwarded-Proto is believed only from these addresses. Without the list it is ignored entirely — otherwise anyone could claim their plaintext request was HTTPS and unlock Secure-only cookies.

A deploy sequence

Shell
$ git pull
$ composer install --no-dev --optimize-autoloader
$ ./orbit migrate                      # before the new code starts serving
$ systemctl reload frankenphp          # or php8.3-fpm

Migrations first, so the new schema exists before any process that expects it starts serving. Run them from exactly one host — the ledger prevents double-application, but two concurrent runs can still race.

Housekeeping

Shell
# Expired sessions and stale login attempts
0 3 * * *  cd /srv/app && php orbit sessions:gc

Neither is required for correctness — expired sessions are refused on read, and old attempts fall outside the throttle window — but both directories otherwise grow forever.

Which target should you choose?

SituationChoice
New deployment, containers, want speedFrankenPHP. Boots once, same model as your dev server.
Existing nginx infrastructurenginx + FPM. Boring, well understood, per-request isolation.
Shared or legacy hostingApache. Works with .htaccess and no root access.
Anything at allNot ./orbit serve. It serves connections sequentially.

Because the application is identical across all of them, moving later is a configuration change rather than a rewrite — which is the whole point of the constraint the framework is built around.