A multi-stage Dockerfile for Laravel and FrankenPHP
I recently rewrote an old website that I originally wrote 15+ years ago. Whilst I’m still running the site as a favour to old friends, I had sorely neglected it, making bug fixes incredibly difficult: It was some framework-less hot mess running on PHP 7.4, which I was really struggling to maintain.
So I decided to rewrite it in PHP 8.5 using Laravel 13, and to run it on my home lab where everything else I run lives in docker containers managed by docker compose.
So I had to learn to write my first Dockerfile. I mostly used Claude to write it for me, and then tried to figure out what each step does and why.
This post outlines what I learned, mostly as a note to self, so my learnings don’t get lost to time.
Starting point: What do I need?
I have so far been a consumer of docker only: I would get a docker-compose.yml, adjust it marginally to my need with some trial and error, and run it. So I first had to figure out what I need:
- Dockerfile The Dockerfile tells docker how to build the image: Which files and software to include, in what order, etc.
- Entrypoint The entrypoint is a (bash) script that runs after the image has been started, and as such is responsible for actually starting the app.
docker-compose.ymlThese I had seen many times before. Basically defines how the image(s) fit together, what ports they expose their service(s) on, what directories from the host they have access to etc.
Architecture
For my Laravel 13 project I need the following in my architecture:
- A web server and PHP runtime. I decided on Caddy and FrankenPHP, as that’s the way the cool kids run these things now. Conveniently, FrankenPHP provides docker images that can be used as a base too
- A queue worker: Based on the same image, but running as a separate service.
- A cron worker: Same story.
One thing I learned in the process, but hadn’t planned out, is that I’d also want an init service that runs migrations and optimisations once at startup before the other services start.
The Dockerfile
With all of that out of the way, let’s start writing the Dockerfile.
Fundamentally, we are building the image in four discrete steps. Each step defines a slightly different image, and the final image, which we’ll push to production, only keeps what it needs from the others.
This keeps our runtime image really as small as possible, and avoids the inclusion of build tools such as git and npm in the final build.
We’ll place this file in our git repo’s root directory, and call it Dockerfile (no file ending).
Stage 1: The base build
This is really the shared foundation that everything builds on:
# syntax=docker/dockerfile:1
FROM dunglas/frankenphp:php8.5 AS base
WORKDIR /app
RUN install-php-extensions \
pdo_sqlite \
mbstring \
intl \
zip \
gd \
xml \
opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini
# syntax=docker/dockerfile:1: We tell Docker to use version 1 Dockerfile syntax and features. Standard boilerplate.FROM dunglas/frankenphp:php8.5 AS base: We are setting up an image (calling itbaseto reference it later) based on the official FrankenPHP image. It bundles FrankenPHP, PHP 8.5, and Caddy.WORKDIR /app: sets/appas working directory where all later commands run fromRUN install-php-extensions ...: Install the PHP extensions our app needs.COPY --from=composer:2 /usr/bin/composer /usr/bin/composer: This I thought was a pretty cool trick! This line grabs the composer binary from the officialcomposer:2image and just drops it in here, so we don’t need to install it manually.COPY docker/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini: This copies some opcache tuning in: as the code inside the image is immutable, we can instruct opcache to be really aggressive with its caching. Here is the content of my opcache.ini:
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.jit=tracing
opcache.jit_buffer_size=64M
And with that our base image is complete: it contains all the binaries that our final app image needs.
Stage 2: PHP dependencies
This image will install PHP dependencies into the vendor/ folder:
FROM base AS vendor
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist \
--optimize-autoloader --no-scripts
FROM base AS vendor: build on the base, so it already has PHP + Composer.RUN apt-get ...: installsgitandunzipas they are required by composer, then deletes the package manager cache to keep the image small.COPY composer.json composer.lock ./: From our repo, copy just these two files: It’s all composer needsRUN composer install ...: Install production dependencies. The--no-scriptsflag skips Laravel’s package discovery. We can’t run this, as that would require booting the app, which isn’t in the image, because we only copied the composer.json and lock files.
Stage 3: Asset compilation
This will build the frontend assets. This stage doesn’t use our base image or PHP. It just uses Node (which our production image won’t need):
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
COPY resources ./resources
COPY public ./public
COPY vite.config.js tailwind.config.js postcss.config.js ./
COPY --from=vendor /app/vendor ./vendor
RUN npm ci
RUN npm run build
FROM node:22-alpine AS assets: This time we are starting from a separate lightweight Node 22 image.COPY ...: We’ll again need to copy some stuff so that Node can build:- package json and lock files to define dependencies
resourcesfolder as that’s where our JS and CSS dependencies live- vite, tailwind, and postcss configs, as these define the configs for our build
- The
vendorfolder from thevendorimage (built in step 2): This was a gotcha: Some Laravel dependencies ship their own JS/CSS so we need this here
RUN...: Install dependencies and build assets. The built assets will end up inpublic/build(or wherever else yourvite.config.jsinstructs them to go).
Stage 4: The app image
Finally we are getting there. This is going to be the image that actually gets shipped and run in production. It primarily cherry picks parts from the preceding steps:
FROM base AS app
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
RUN composer dump-autoload --no-dev --optimize
RUN chown -R www-data:www-data /app/bootstrap/cache /app/storage /config
ENV SERVER_NAME=":80"
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD php -r "exit(@file_get_contents('http://127.0.0.1:80/up') === false ? 1 : 0);"
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
ENTRYPOINT ["entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
FROM base AS app: We start again with the base image that has FrankenPHP + composerCOPY FROM vendor ...,COPY FROM assets: Copy the installed dependencies and assets.COPY . .: Copy the application code. We’ll define a separate.dockerignorefile that will ignore thevendorfolder so it shouldn’t override things.RUN composer dump-autoload ...: As we now have both dependencies and application code the app will boot cleanly, so we can dump the auto loader, and run laravel’s package discovery.
Our app image is now fundamentally ready, but we need to wire it up to actually run something with the stuff we just built, and that’s the rest of these files:
RUN chown -R www-data:www-datawe change the owner of all the generated files - don’t want to run the app asrootany more than we’d do in a bare metal install.ENV SERVER_NAME=":80": Tells FrankenPHP to serve plain HTTP on port 80. We’ll have an upstream proxy in front of this server, as it lives in my home lab.EXPOSE 80: Documents that the container listens on port 80.HEALTHCHECK ...: Defines a healthcheck — PHP fetches Laravel’s/uproute. Docker reports the result as the container’s health status (visible indocker ps).COPY docker/entrypoint.sh...,RUN chmod +x...: Copy the entry point and make executable. We’ll get back to that file later, but this is the script that will run when the container starts up.ENTRYPOINT ["entrypoint"]: Tells docker to actually run the entry point script on startup.CMD ["frankenphp", "run", "--config", ...]: The command the entrypoint hands off to: start the FrankenPHP web server with its bundled Caddyfile config.
Result
We’ll end up with a final app image that contains only PHP, application code, dependencies, and compiled assets. All the build machinery (Node, npm, git) live in throwaway stages that never ship, which makes the app image as small as it can be, and reduces attack surface.
The Docker ignore file
I mentioned this earlier. Think of it as ‘gitignore for docker’: There will be plenty of stuff in your git repo, or generated during the build process that you don’t want in the docker image. Here is mine, stored at .dockerignore in the repo root:
.git
.gitignore
node_modules
vendor
public/build
# Secrets + local state never go in the image
.env
.env.*
data
*.sqlite
# bootstrap caches. Laravel regenerates them at runtime.
bootstrap/cache/*.php
# Ephemeral storage contents (recreated by the entrypoint)
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
# Docker + dev files (note: docker/entrypoint.sh is intentionally NOT listed
# here — the Dockerfile COPYs it into the image, so it must stay in the context)
Dockerfile
docker-compose.yml
.dockerignore
README.md
tests
phpunit.xml
The Entrypoint
Let’s have a look at the entry point script. In a nutshell the entry point:
- Ensures Laravel’s required directories (
./storage/*,bootstrap/cache) as well as the SQLite database file exist (needed because./storageand./dataare bind mounts that start empty on a fresh host). - Creates the
public/storagesymlink. - Fixes permissions.
- Runs
php artisan optimize. - Hands off to whatever command that service specified (or the default CMD).
#!/bin/sh
set -e
# The container starts as root so this script can fix ownership on the
# bind-mounted volumes, then drops to this unprivileged user to run the app.
APP_USER="${APP_USER:-www-data}"
DB_FILE="${DB_DATABASE:-/data/database.sqlite}"
# Laravel's storage skeleton must exist. storage/ is a bind mount, so on a
# fresh host it starts empty and these subdirs won't be there.
mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public \
bootstrap/cache
# Ensure the SQLite database file exists before anything opens it.
mkdir -p "$(dirname "$DB_FILE")"
touch "$DB_FILE"
# public/storage -> storage/app/public. Created here rather than via
# `artisan storage:link` so it exists in EVERY container (incl. app) without
# booting the framework, and so it survives in the app container's own fs.
ln -sfn /app/storage/app/public /app/public/storage
# Hand ownership of everything the app writes to the unprivileged runtime user.
# Done here (as root) because ./storage and /data are bind mounts that can start
# owned by the host's root on a fresh deploy.
chown -R "$APP_USER" storage bootstrap/cache "$(dirname "$DB_FILE")" /config 2>/dev/null || true
chmod -R ug+rwX storage bootstrap/cache "$(dirname "$DB_FILE")" 2>/dev/null || true
# Optimise Laravel's caches. Best-effort: a failure must never stop the container booting,
# because the app runs fine uncached and caching is purely an optimisation.
# Run as the app user so the cache files are owned by it, not root.
setpriv --reuid="$APP_USER" --regid="$APP_USER" --init-groups \
php artisan optimize || echo "entrypoint: optimize failed, continuing uncached" >&2
# Drop root and exec the real command (CMD or the service's command) as the
# unprivileged user.
exec setpriv --reuid="$APP_USER" --regid="$APP_USER" --init-groups "$@"
Docker Compose services
With the app image and entry point ready, it’s time to define our docker compose services. We’ll need four of them. Each one inheriting a shared block:
x-app: &app-common
image: code.thms.uk/michael/...
env_file: .env
volumes:
- ./data:/data # the SQLite database file lives here
- ./storage:/app/storage # logs + any file-based app storage
restart: unless-stopped
x-app: &app-common: Is a yaml anchor, not a Docker feature.x-appis an “extension field” (thex-prefix tells Docker to ignore it as a service), and&app-commongives the block a label. Each service pulls this block in with<<: *app-common, so we define the common settings once instead of repeating them four times.image: code.thms.uk/michael/...: The image to use. I’ve uploaded my image to my Forgejo repo, where it is rebuilt automatically every time I push tomain. I will write a separate blog about this.env_file: .env: Load environment variables from the.envfile. Ensure that you define the database in there with
DB_CONNECTION: sqlite
DB_DATABASE: /data/database.sqlite
volumes:: bind mounts that connect host folders to container folders, so data survives container restarts/rebuilds.restart: unless-stopped: if the container’s process exits, Docker restarts it automatically.
Each of the following four services will inherit the &app-common stuff.
The init service
This service will run once on start, migrate the database, then exit. restart: "no" overrides the shared unless-stopped because this is a one-shot task. We disable healthchecks for the same reason.
services:
init:
<<: *app-common
command: php artisan migrate --force
restart: "no"
healthcheck:
disable: true
The web server
The next service is our web server. We use the depends_on: directive to start this service only after migrations are done. Then we map the host’s port 8080 to port 80 in the container. As such we’ll point our reverse proxy at port 8080 on this server.
app:
<<: *app-common
depends_on:
init:
condition: service_completed_successfully
ports:
- "8080:80"
The cron and queue worker
These are really both the same: We override the command for each, disable healthchecks (no web server is running in here), and again make sure they only start after migrations are complete using depends_on:
scheduler:
<<: *app-common
command: php artisan schedule:work
depends_on:
init:
condition: service_completed_successfully
healthcheck:
disable: true
queue:
<<: *app-common
command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
depends_on:
init:
condition: service_completed_successfully
healthcheck:
disable: true
Deploying
To deploy this to production we now need to
- Build the image and push it to our code repository.
- Copy the
docker-compose.ymlfile to our host. - Create a
.envfile and put it alongside thedocker-compose.yml. - Run
docker compose up -don the host.
Updates are as simple as
- Re-building and re-pushing the image
- Running
docker compose pull && docker compose up -don the host.
We’ll be automating the building and pushing using Forgejo actions, but that’s for another day.