Skip to guide
Help center

How to deploy a PHP application with FrankenPHP

Package a Laravel application with FrankenPHP, connect it to PostgreSQL, run its migrations and deploy it from a Git repository.

Prerequisites

This guide deploys a Laravel application with a PostgreSQL managed service. The image runs FrankenPHP, which serves PHP and static files in one process, without a separate web server or PHP-FPM.

You need a ready cluster and a project assigned to it (see projects and clusters), Git, PHP 8.3 or later with Composer, Node.js with npm and a public repository. Docker is optional, to test the image locally.

Create the Laravel application

composer create-project laravel/laravel hello-tenkai
cd hello-tenkai
npm install

Commit package-lock.json: the Dockerfile installs from it.

Prepare Laravel for Tenkai

Tenkai terminates HTTPS and forwards plain HTTP. Trust the forwarded headers in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '*');
})

Without this, Laravel generates http:// URLs and browsers block the stylesheets and scripts as mixed content.

Laravel's pgsql connection reads DB_URL, so you will name the PostgreSQL binding variable DB_URL.

Add a start script

Create docker/start.sh and make it executable with chmod +x docker/start.sh:

#!/bin/sh
set -e

php artisan migrate --force
php artisan optimize

exec "$@"

It runs each time a release starts.

  • migrate --force runs while the previous release still serves requests, so a migration must not break the previous code. For example, add a column in one deployment and remove the old one in a later one.
  • optimize runs at startup, not in the build, because the configuration cache stores your variables; a cache built in the image would hold empty values.
  • exec lets FrankenPHP receive SIGTERM directly and stop gracefully.

Add a Dockerfile

Create Dockerfile at the repository root:

FROM dunglas/frankenphp:1-php8.5-trixie AS base
WORKDIR /app
RUN install-php-extensions pdo_pgsql intl

FROM base AS vendor
RUN install-php-extensions @composer zip
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative

FROM node:24-trixie-slim AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM base AS runtime
ENV APP_ENV=production \
	APP_DEBUG=false \
	LOG_CHANNEL=stderr \
	DB_CONNECTION=pgsql \
	QUEUE_CONNECTION=sync \
	SERVER_NAME=:8080
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=vendor /app ./
COPY --from=assets /app/public/build ./public/build
RUN setcap -r /usr/local/bin/frankenphp \
	&& chown -R www-data:www-data storage bootstrap/cache /config/caddy /data/caddy
USER www-data
EXPOSE 8080
ENTRYPOINT ["docker/start.sh"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]

Add the PHP extensions your application needs to the install-php-extensions line of base.

SERVER_NAME=:8080 matters most. By default FrankenPHP listens on ports 80 and 443 and requests its own certificates. Tenkai already provides HTTPS and sends requests to the port of the web application, 8080 by default, so FrankenPHP must listen there in plain HTTP, which also lets it run as the unprivileged www-data user. If you set another Port, change SERVER_NAME with it.

LOG_CHANNEL=stderr sends Laravel's logs to the Logs tab. QUEUE_CONNECTION=sync is explained in Keep application files disposable.

Exclude local files from the image

Create .dockerignore beside the Dockerfile:

.git
.env
.env.*
node_modules
vendor
public/build
public/hot
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
database/*.sqlite
bootstrap/cache/*.php

Without it, COPY . . puts your local .env, with its APP_KEY and passwords, in the image, and copies your local vendor and caches over the built ones.

Test the image locally

Optionally, run the image with a PostgreSQL container, using php artisan key:generate --show for APP_KEY:

docker build -t hello-tenkai .
docker network create hello-tenkai
docker run -d --name hello-tenkai-db --network hello-tenkai \
	-e POSTGRES_USER=tenkai -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app postgres:18
for attempt in $(seq 30); do
	docker exec hello-tenkai-db pg_isready -h 127.0.0.1 -U tenkai -d app >/dev/null && break
	sleep 1
done
docker run --rm --name hello-tenkai --network hello-tenkai -p 8080:8080 \
	-e APP_KEY='base64:...' \
	-e DB_URL='postgresql://tenkai:secret@hello-tenkai-db:5432/app' \
	hello-tenkai

The loop waits for PostgreSQL to accept connections, since the start script stops if the migrations cannot connect. Visit http://localhost:8080 and expect the Laravel welcome page. Remove the two containers and the network when you are done.

Create the resources

Commit and push your changes. In the project:

  1. Create a PostgreSQL managed service and wait until it is running.
  2. Choose Add resource, then Application. Choose Web application and Dockerfile-backed Git repository as the build source.
  3. Enter your public repository URL, set the build directory / context to ., the Dockerfile path to Dockerfile and the Revision to your branch. Create the resource.
  4. In the Variables tab of the application, add APP_KEY as a Sensitive variable, with the value of php artisan key:generate --show. Add APP_URL with the address of the default route, or of your custom domain.
  5. Bind the PostgreSQL service to the application, and rename its variable from DATABASE_URL to DB_URL.

If the dialog offers only Container image, an owner or an admin must turn Git builds on for the cluster.

For private code, build the image in GitHub Actions and create the application from that image instead.

Never change APP_KEY after the first deployment: a new key logs out every user and makes encrypted values unreadable.

Deploy and verify

Choose Deploy. Once the release is active, open its default route and expect the styled Laravel welcome page over HTTPS.

If the build fails, read the deployment Details. Otherwise, read the Logs tab:

  • The release does not start and the migrations report Connection refused: the application has no DB_URL. Check the binding, then deploy again.
  • Every page returns an error and the logs say No application encryption key has been specified: add APP_KEY, then deploy again.
  • The page has no styles: check the trustProxies change.

Troubleshooting lists the other checks.

Keep application files disposable

Each release replaces the container and loses the files written in storage. Sessions and the cache already live in PostgreSQL by default. Put uploads on an S3-compatible bucket with Laravel's s3 disk, not the local or public disk.

QUEUE_CONNECTION=sync runs queued jobs during the request, so they do not wait forever in the jobs table. To run them in the background, set QUEUE_CONNECTION=database and create a worker from the same repository with the entrypoint php artisan queue:work.

For the Laravel scheduler, create a scheduled job from the same repository with the schedule * * * * * and the entrypoint php artisan schedule:run.

Give the worker and the scheduled job the same variables and database as the application.