Deploying Laravel on a Single-Process Container With FrankenPHP

·5 min read·Updated
phplaravelfrankenphpdockerdeploymenthow-to

The awkward part of deploying PHP to a container platform isn't PHP — it's that the traditional stack is two processes, nginx and PHP-FPM talking over FastCGI, while a container platform runs and supervises one. The usual workaround is a process manager inside the container, which puts a second supervisor underneath the platform's own and hides the exit codes you need for debugging. FrankenPHP removes the problem instead of managing it: it "replaces both your web server (Nginx, Apache) and PHP-FPM with a single binary" (FrankenPHP docs), so one process serves HTTP and runs your code. That's exactly the shape this platform wants.

Why the classic stack fights the platform

Run nginx and PHP-FPM in one container and you need something to start and watch both — supervisord, or a shell script juggling background jobs. That creates two problems here:

  • Two supervisors disagree. The platform already restarts your process when it exits. A supervisor inside the container means the outer one sees a healthy wrapper while the thing that matters may be dead.
  • Exit codes disappear. The wrapper's exit status replaces your app's, and the exit code is the most useful diagnostic you have when something restarts unexpectedly.

It's the same reason PM2 is a bad idea for Node here. One process per container, supervised by the platform, is the model — and PHP has a modern way to fit it.

The Dockerfile

PHP isn't one of the runtimes the zip flow's build agent detects, so this is a Dockerfile deploy — push it by git and the platform builds it.

FROM dunglas/frankenphp:php8.3

RUN install-php-extensions pdo_pgsql opcache intl zip

WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction

COPY . .
RUN php artisan config:cache && php artisan route:cache

ENV SERVER_NAME=:8080
EXPOSE 8080

Then bind container port 8080 as HTTPS in the panel, and TLS is terminated at the edge with an automatic certificate.

Turn off Caddy's own HTTPS

This is the config detail that catches people, and it's specific to running FrankenPHP behind a TLS-terminating edge.

FrankenPHP is built on Caddy, which tries to obtain and serve its own certificates by default. Here that's both unnecessary and actively unhelpful — the platform already handles TLS, and your app should speak plain HTTP on its port.

Setting SERVER_NAME to a bare :8080 (no hostname) tells Caddy to serve HTTP on that port without attempting certificate provisioning. If you see your app trying to reach an ACME endpoint at startup, that's what's happening.

Laravel writes to disk — plan for it

Laravel's storage/ directory is where the container model bites. Logs, compiled views, file-based sessions and cache, and any uploads all land there, and anything outside a volume is discarded when the container is recreated — which happens on every redeploy.

Two sane approaches:

  • Move it out of the filesystem. Sessions and cache to your database or an external store; logs to stderr so they appear in the log stream rather than a file nobody can see.
  • Mount a volume at storage/app for genuine user uploads, which are the one thing that really has to persist.

Setting LOG_CHANNEL=stderr is the single highest-value change — a Laravel app logging to storage/logs/laravel.log in a container is writing a diagnostic file that vanishes exactly when you'd want to read it.

Worker mode, and its one condition

FrankenPHP's worker mode "boots your application once and keeps it in memory" between requests rather than bootstrapping per request, which is a substantial speed-up for a framework as boot-heavy as Laravel.

The condition comes straight from their documentation: "Worker mode keeps your application in memory between requests. Make sure your code does not rely on global state being reset between requests." That's a real constraint — static properties, singletons holding request data, and container bindings mutated per request all become cross-request leaks.

Sensible order: get it running in classic mode first, confirm it works, then enable worker mode and test properly. It's an optimisation, not a prerequisite.

Watch the build, not just the app

A Laravel deploy usually runs composer install and often a Vite asset build. Both are memory-hungry, and the build container has a smaller memory limit than your app — a Node-based asset build is the likeliest thing to hit it.

If the build stops partway through with no clear error, that's the signature. Cap Node's heap, or build assets in CI and ship them rather than compiling on the platform.

Sizing

A small Laravel app with OPcache enabled is a reasonable container workload, though heavier than a Python or Go service — PHP plus a framework has a real floor. Start at a Hobby preset rather than the smallest one, and step up if you see out-of-memory kills.

Worker mode changes the profile: higher steady memory (the app stays resident) but much less per-request work.

FAQ

Can I deploy PHP by uploading a zip?

No. The build agent detects Python, Node.js, and prebuilt .NET. PHP needs a Dockerfile.

Do I need nginx and PHP-FPM?

Not with FrankenPHP, which replaces both with a single binary. That matters here because the platform supervises one process per app.

Why is my app trying to get a TLS certificate?

Caddy attempts automatic HTTPS by default. Set SERVER_NAME to a bare port like :8080 so it serves plain HTTP — the platform terminates TLS for you.

Where do Laravel's logs go?

Set LOG_CHANNEL=stderr so they reach the log stream. A file under storage/logs disappears when the container is recreated.

Do I need a volume?

For user uploads, yes. Sessions and cache are better moved to a database or external store than persisted on disk.

Should I use worker mode?

Eventually. Get it working in classic mode first, then enable it and verify your code doesn't rely on global state being reset between requests.

Wrapping up

PHP's reputation for being awkward to containerise is really a reputation for the stack being awkward — two processes, a socket between them, and a supervisor to keep both alive. Collapse that into one binary and PHP becomes an unremarkable container workload: one process, one port, logs to stdout, uploads on a volume. The remaining work is the same as any framework's — decide what has to persist, and check the build fits.