blog.thms.uk

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:

Architecture

For my Laravel 13 project I need the following in my architecture:

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
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

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

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"]

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:

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:

  1. Ensures Laravel’s required directories (./storage/*, bootstrap/cache) as well as the SQLite database file exist (needed because ./storage and ./data are bind mounts that start empty on a fresh host).
  2. Creates the public/storage symlink.
  3. Fixes permissions.
  4. Runs php artisan optimize.
  5. 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
    DB_CONNECTION: sqlite
    DB_DATABASE: /data/database.sqlite

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

  1. Build the image and push it to our code repository.
  2. Copy the docker-compose.yml file to our host.
  3. Create a .env file and put it alongside the docker-compose.yml.
  4. Run docker compose up -d on the host.

Updates are as simple as

  1. Re-building and re-pushing the image
  2. Running docker compose pull && docker compose up -d on the host.

We’ll be automating the building and pushing using Forgejo actions, but that’s for another day.