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 --forceruns 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.optimizeruns at startup, not in the build, because the configuration cache stores your variables; a cache built in the image would hold empty values.execlets FrankenPHP receiveSIGTERMdirectly 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:
- Create a PostgreSQL managed service and wait until it is running.
- Choose Add resource, then Application. Choose Web application and Dockerfile-backed Git repository as the build source.
- Enter your public repository URL, set the build directory / context to
., the Dockerfile path toDockerfileand the Revision to your branch. Create the resource. - In the Variables tab of the application, add
APP_KEYas a Sensitive variable, with the value ofphp artisan key:generate --show. AddAPP_URLwith the address of the default route, or of your custom domain. - Bind the PostgreSQL service to the application, and rename its variable from
DATABASE_URLtoDB_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 noDB_URL. Check the binding, then deploy again. - Every page returns an error and the logs say
No application encryption key has been specified: addAPP_KEY, then deploy again. - The page has no styles: check the
trustProxieschange.
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.