Octane
Laravel Octane boots your application once and keeps it in memory between requests. Run it on the frankenphp variation, which keeps the image's security headers, trusted proxies, SSL modes, and health check. Use Swoole on the cli image only when you need a Swoole-only feature.
Choose an Octane server
| Server | How to run it | What you get |
|---|---|---|
| FrankenPHP | Built into the frankenphp variation | Nothing to install. The image's Caddyfile and every CADDY_* variable work. |
| Swoole or Open Swoole | cli plus the extension | Four Octane features FrankenPHP lacks. You add the HTTP hardening yourself. |
| RoadRunner | cli plus the rr binary | A Go server with its own .rr.yaml. You add the HTTP hardening yourself. |
fpm, fpm-nginx, or fpm-apache variations.Run Octane on FrankenPHP
These steps assume a Laravel application in the current directory. To create a new one with Docker, see Spin.
Point the service at Octane
services:
php:
image: serversideup/php:8.5-frankenphp
ports:
- "80:8080"
volumes:
- .:/var/www/html/
command: ["php", "artisan", "octane:start", "--server=frankenphp", "--port=8080", "--caddyfile=/etc/frankenphp/Caddyfile"]
--server=frankenphpstarts FrankenPHP in worker mode through Octane.--port=8080must matchCADDY_HTTP_PORT(default8080). The exec form (the JSON array) doesn't expand variables, so write the number.--caddyfile=/etc/frankenphp/Caddyfileuses the image's Caddyfile. Without it, Octane uses its own minimal Caddyfile, and you lose trusted proxies, security headers, asset caching, SSL modes, the health check endpoint, and mostCADDY_*variables.
Octane sets LARAVEL_OCTANE=1 when it starts FrankenPHP. The Caddyfile then loads frankenphp-worker.php as a worker and routes every request to it instead of index.php. It also turns on the Caddy admin API on localhost, which octane:status, octane:reload, and octane:stop need. Everything else matches classic mode.
Install Octane
docker compose run --rm php composer require laravel/octane
docker compose run --rm php php artisan octane:install --server=frankenphp
The install command publishes config/octane.php, sets OCTANE_SERVER=frankenphp in .env, and creates public/frankenphp-worker.php, the script FrankenPHP keeps in memory.
It also adds frankenphp-worker.php to .gitignore. Remove that line and commit the file. Every image you build then contains it. Otherwise octane:start has to recreate it at startup, which only works if public is writable.
Start the application
docker compose up
Your application answers at http://localhost, served by Octane.
Logging
Octane relays only FrankenPHP's stderr and only parses JSON. The defaults satisfy both: CADDY_LOG_OUTPUT is stderr, and with CADDY_LOG_FORMAT=auto, Caddy writes JSON when stderr is not a terminal, which is always true under Octane.
CADDY_LOG_OUTPUT and CADDY_LOG_FORMAT at their defaults. With CADDY_LOG_OUTPUT=stdout, Octane discards your logs. With CADDY_LOG_FORMAT=console, Octane can't parse the lines and wraps each one, warnings and errors included, in an INFO message.Octane also sets the log level. It sets CADDY_SERVER_LOG_LEVEL to INFO when APP_ENV is local and to WARN everywhere else, which overrides LOG_OUTPUT_LEVEL. Caddy logs requests at INFO. What you see depends on the environment:
- In
local, Octane prints one line per request with the method, path, status, and duration. - Everywhere else, it prints only Caddy's warnings and errors.
- With
--log-level=INFOonoctane:start, it passes Caddy's JSON through untouched: one object per request, ready for a log collector.
PHP settings still apply
FrankenPHP reads the same php.ini files from /usr/local/etc/php/conf.d/ in every mode. All PHP_* variables and any .ini files you mount there work as they do in classic mode. Set PHP_OPCACHE_ENABLE=1 in production; see Production performance tuning.
Octane options
Octane passes its octane:start options to FrankenPHP as environment variables. With the image's Caddyfile:
| Option | Behavior |
|---|---|
--port | Match CADDY_HTTP_PORT, which is the port Caddy listens on. Octane also derives the admin port from it: --port=8080 puts the admin API on 2099. |
--host | Not used. The container listens on all interfaces. |
--workers | Sets the number of PHP threads for the worker. Defaults to twice the number of CPUs. |
--max-requests | Restarts a worker after this many requests (default 500) to limit memory leaks. |
--watch | Uses FrankenPHP's file watcher, built into the image, with the watch paths from config/octane.php. No Node or Chokidar needed. --poll has no effect. |
--admin-port | Used by octane:status, octane:reload, and octane:stop. CADDY_ADMIN is ignored. The admin API listens on localhost only. |
--log-level | Sets CADDY_SERVER_LOG_LEVEL. See Logging. |
--https, --http-redirect | Not used. Configure HTTPS with SSL_MODE and CADDY_AUTO_HTTPS, as in classic mode. |
localhost, even though the hidden octane:frankenphp command accepts --admin-host. The Caddy admin API has no authentication and can reconfigure the web server. The image lists port 2019 in EXPOSE, so with --admin-host=0.0.0.0 --admin-port=2019, docker run -P publishes it to the host.The max_execution_time setting in config/octane.php works as Laravel documents, because Octane passes it to the worker script.
Variables Octane replaces
Octane sets these for its own Caddyfile. Any value you set is replaced while Octane runs:
CADDY_SERVER_EXTRA_DIRECTIVEScarries themercurearray fromconfig/octane.php. Put your own directives in a.caddyfilemounted into/etc/frankenphp/caddyfile-server.d/.CADDY_GLOBAL_OPTIONSis ignored under Octane, because Octane sets it toauto_https disable_redirects, which would overrideCADDY_AUTO_HTTPS. Put global options in/etc/frankenphp/caddyfile-global.d/.CADDY_SERVER_LOGGERisn't read, because Caddy already writes JSON under Octane.
See Adding your own Caddyfile rules for the folder layout.
caddy.env in config/octane.php. This keeps request logs on in every environment without --log-level:'caddy' => [
'env' => [
'CADDY_SERVER_LOG_LEVEL' => 'INFO',
],
],
The Caddyfile loads frankenphp-worker.php from CADDY_SERVER_ROOT, not from the APP_PUBLIC_PATH that Octane sets. If you changed APP_BASE_DIR, set CADDY_SERVER_ROOT to match, as in classic mode.
Mercure
Turn on FrankenPHP's Mercure hub with the MERCURE_* variables, as in classic mode. Leave the mercure array out of config/octane.php.
FrankenPHP's Laravel docs use that array instead. Octane passes it in CADDY_SERVER_EXTRA_DIRECTIVES, which lands in every site block. With SSL_MODE=mixed or full there is more than one site block, which means more than one unnamed hub. Mercure 1.0 refuses to start in that case. The MERCURE_* variables name the hub, and every site block shares it.
Run Octane on Swoole or RoadRunner
Run these servers from the cli image, which has no web server, so the HTTP protections are yours to add.
Swoole-only Octane features
Swoole and Open Swoole support four Octane features that FrankenPHP and RoadRunner don't:
- Concurrent tasks with
Octane::concurrently(), run by Swoole's task workers. Set how many with--task-workersonoctane:start. - Ticks and intervals that run a callback every N seconds.
- The Octane cache driver, stored in shared memory and flushed when the server restarts.
- Tables that share structured data between workers, also flushed on restart.
What the cli image does and doesn't give you
The cli image has what Octane needs on the PHP side: the pcntl extension, Composer, install-php-extensions, the startup scripts (including Laravel automations), the PHP_* variables, and the unprivileged www-data user.
It has no web server. In the frankenphp variation, the image's Caddyfile handles the HTTP layer. Swoole's HTTP server is the PHP process itself, with no place for caching rules or path blocking. RoadRunner has a config file that nothing in the image fills in. These protections move to you:
| Protection | frankenphp | cli + Swoole or RoadRunner | Where to add it |
|---|---|---|---|
Blocking .env, .git/, *.bak, *.log, *.sql, *.ini, and dotfiles | ✅ | ❌ | Reverse proxy |
Cache-Control: immutable on static assets | ✅ | ❌ | Reverse proxy |
Access-Control-Allow-Origin on fonts | ✅ | ❌ | Reverse proxy |
X-Frame-Options, X-Content-Type-Options, Referrer-Policy | ✅ | ❌ | Laravel middleware or reverse proxy |
Removing the Server and X-Powered-By headers | ✅ | ❌ | Reverse proxy |
Trusted proxies through TRUSTED_PROXY | ✅ | ❌ | Laravel's TrustProxies middleware |
SSL_MODE, automatic HTTPS, and ACME certificates | ✅ | ❌ | Reverse proxy |
The /healthcheck endpoint and HEALTHCHECK_PATH | ✅ | ❌ | healthcheck-octane or a Laravel route |
Access logs with the authorization parameter redacted | ✅ | ❌ | Reverse proxy |
403:- Swoole serves every non-PHP file in
publicas it is, including a stray.env,.gitdirectory, database dump, or*.bak. It refuses.phpfiles, so an uploaded PHP file returns404instead of running. - RoadRunner, as Octane starts it, serves everything in
public, including.phpfiles as plain-text source. See Lock down RoadRunner's static files.
Run Octane on Swoole
Swoole is a PHP extension. Install it in your own Dockerfile. For Open Swoole, install openswoole instead; everything else is the same.
FROM serversideup/php:8.5-cli
USER root
RUN install-php-extensions swoole
USER www-data
Install Octane and point it at Swoole:
docker compose run --rm php composer require laravel/octane
docker compose run --rm php php artisan octane:install --server=swoole
Start it from your compose file:
services:
php:
build:
context: .
ports:
- "80:8000"
volumes:
- .:/var/www/html/
command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
healthcheck:
test: ["CMD", "healthcheck-octane"]
start_period: 10s
Octane binds to 127.0.0.1 by default, which Docker's published port can't reach. Always pass --host=0.0.0.0 in a container. Port 8000 is Octane's default. It is above 1024, which lets www-data bind it.
Run Octane on RoadRunner
RoadRunner needs the rr binary and the sockets extension, which the cli image doesn't include:
FROM serversideup/php:8.5-cli
USER root
RUN install-php-extensions sockets
# TARGETARCH is set by BuildKit, so this works on amd64 and arm64
ARG TARGETARCH
ARG ROADRUNNER_VERSION=2025.1.15
RUN docker-php-serversideup-download \
"https://github.com/roadrunner-server/roadrunner/releases/download/v${ROADRUNNER_VERSION}/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}.tar.gz" \
/tmp/roadrunner.tar.gz && \
tar -xzf /tmp/roadrunner.tar.gz -C /tmp && \
mv "/tmp/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}/rr" /usr/local/bin/rr && \
chmod +x /usr/local/bin/rr && \
rm -rf /tmp/roadrunner*
USER www-data
spiral/roadrunner-cli and ./vendor/bin/rr get-binary. On Laravel 13 that fails: spiral/roadrunner-cli requires symfony/console 7 or older, Laravel 13 ships version 8, and php artisan octane:install --server=roadrunner calls ./vendor/bin/rr. Installing the binary in the image avoids both, pins the version, and matches each image's architecture. Skip octane:install and set OCTANE_SERVER=roadrunner in .env.Add the PHP side, then start the server:
docker compose run --rm php composer require laravel/octane spiral/roadrunner-http
services:
php:
build:
context: .
ports:
- "80:8000"
volumes:
- .:/var/www/html/
command: ["php", "artisan", "octane:start", "--server=roadrunner", "--host=0.0.0.0", "--port=8000", "--rpc-port=6001", "--rr-config=/var/www/html/.rr.yaml"]
healthcheck:
test: ["CMD", "healthcheck-octane"]
start_period: 10s
Lock down RoadRunner's static files
Create .rr.yaml in your project root. --rr-config in the command above points RoadRunner at it:
version: "3"
http:
static:
forbid: [".php", ".env", ".bak", ".log", ".sql", ".ini", ".git"]
With this file, /.env, /uploaded.php, and /config.bak return 404 and your real assets still load. It covers less than a reverse proxy does.
Put NGINX in front for static files and TLS
Laravel recommends running Octane behind NGINX or Apache in production, to serve static assets and terminate TLS. Add the protections from the table above there too.
services:
php:
build:
context: .
volumes:
- .:/var/www/html/
command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
web:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- .:/var/www/html/:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
Start from Laravel's NGINX configuration for Octane and set proxy_pass to http://php:8000 instead of http://127.0.0.1:8000, because Octane runs in its own container.
nginx image, not fpm-nginx. The NGINX in fpm-nginx passes requests to PHP-FPM over FastCGI and isn't set up to proxy to Octane.Turn on OPcache
Octane on Swoole or RoadRunner is a CLI process, so OPcache needs both PHP_OPCACHE_ENABLE=1 and PHP_OPCACHE_ENABLE_CLI=1. The image sets PHP_OPCACHE_ENABLE_CLI=1 already, but PHP_OPCACHE_ENABLE defaults to 0, so set it in production. See Production performance tuning for the other settings.
File watching needs Node and Chokidar
Outside FrankenPHP, Octane's --watch flag needs Node and Chokidar. Neither ships in the cli image. Add Node to your development image and install Chokidar in your project:
npm install --save-dev chokidar
Health checks
On FrankenPHP, the image's health check keeps working, because the Caddyfile still answers /healthcheck. Swoole and RoadRunner have no /healthcheck endpoint. On any server, healthcheck-octane checks Octane itself, as the Swoole and RoadRunner examples do. See Laravel health checks.
Code that stays in memory
Code that worked under PHP-FPM can misbehave once it stays in memory:
- Injecting the request or container into long-lived objects can leak data between requests. See dependency injection and Octane.
- Static arrays that grow on every request leak memory. See managing memory leaks.
- New code needs
octane:reloador a new container. In production, replace the container.octane:reloadalso works in place, because the Caddyfile enables the admin API.