Reducing Docker deploy downtime for Laravel and FrankenPHP
A little while ago I wrote about the multi-stage Dockerfile I put together for Laravel and FrankenPHP. It worked, the site went live, and I was quietly pleased with myself.
Then I started deploying to it a few times, and noticed something I hadn’t realised when I wrote it: every deploy took the site down for an uncomfortably long time. Not catastrophic - it’s a small site on my homelab, not a bank - but long enough to annoy me.
So this is the follow-up: what was actually taking so long, and what I did to speed it up.
What was actually happening
The first thing worth doing was to stop guessing and think properly about the actual sequence of what happens when I run docker compose up -d after pulling a new image, given my docker-compose.yml:
- All running containers stop. The site is now down.
- The
initcontainer is created, boots Laravel, runsphp artisan migrate --force, and exits. - The
appcontainer is created. Its entrypoint runs a recursivechownover the bind mounts, thenphp artisan optimize. - FrankenPHP finally starts and binds
:80. The site is back.
Everything between steps 1 and 4 is downtime, and it’s all strictly serial.
And there is a further wrinkle: my init, app, queue and scheduler services all run the same image, which means they all run the same entrypoint, which means all four of them were also running that recursive chown over the same two bind mounts at the same time! Four containers, two mounts, all fighting over the same disk. Ouch!
Fix 1: Bake everything that doesn’t depend on the environment into the image
The first thing to realise is that optimize is doing two quite different kinds of work:
Compiled Blade views and the event-to-listener map are derived purely from the source tree. They cannot change between building the image and running it. Config and route caching, on the other hand, read my .env, so they genuinely have to happen at runtime, on the machine, with the real environment present.
Which means half of optimize had no business being in the entrypoint at all. It belongs in the image, where it’s done once at build time and doesn’t hold up boot at all:
RUN php artisan view:cache --no-interaction \
&& php artisan event:cache --no-interaction
And in the entrypoint, php artisan optimize becomes just the two that need the environment:
php artisan config:cache && php artisan route:cache
Config first, incidentally, because route definitions can read config values.
The gotcha that caught me out the first time
Compiled Blade views normally live in storage/framework/views. But storage/ is a bind mount, because it’s where the logs and file storage need to persist. So anything I bake into /app/storage/framework/views at build time gets yanked out from under my feet by the host directory the moment the container starts. Yes, the cache is there in the image, but invisible at runtime, and everything is uncached. Second ouch!
The fix is to put the compiled views somewhere that isn’t a mount:
ENV VIEW_COMPILED_PATH=/app/bootstrap/cache/views
RUN mkdir -p "$VIEW_COMPILED_PATH" \
&& php artisan view:cache --no-interaction \
&& php artisan event:cache --no-interaction
VIEW_COMPILED_PATH might be a bit obscure, but it’s an env var built into Laravel to configure exactly where the compiled views are stored.
One thing to watch: this ENV line has to come before the chown in the Dockerfile, so the compiled views end up owned by www-data along with the rest of bootstrap/cache.
Fix 2: Do the volume setup once, not four times
This is the concurrent-chown problem from earlier.
The entrypoint scaffolds Laravel’s storage directories, touches the SQLite file, and chowns it all to www-data. That work does need doing - on a fresh host those bind mounts start empty and owned by root - but it needs doing once, not simultaneously in every container.
So, it needs to go into the init server, together with the migration and caching: do it once before any of the other services start. I put the whole block behind an environment variable:
if [ "${BOOTSTRAP_VOLUMES:-0}" = "1" ]; then
mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public
mkdir -p "$(dirname "$DB_FILE")"
touch "$DB_FILE"
chown -R "$APP_USER" storage "$(dirname "$DB_FILE")" 2>/dev/null || true
chmod -R ug+rwX storage "$(dirname "$DB_FILE")" 2>/dev/null || true
fi
and set BOOTSTRAP_VOLUMES: "1" on the init service only:
# ...
init:
<<: *app-common
command: php artisan migrate --force
environment:
BOOTSTRAP_VOLUMES: "1"
# ...
Fix 3: Deploying in stages
The changes above shorten the work, but the sequence is still fundamentally “stop the old container, do some stuff, start the new one”. The other half of the problem is that docker compose up -d recreates everything at once, so the web container’s downtime includes waiting for containers that have nothing to do with serving requests.
Splitting the deploy into three commands fixes that:
docker compose pull
docker compose up -d app
docker compose up -d --no-deps queue scheduler
You might wonder why no docker compose up -d init: Because the app service depends_on: init, the init service will run and complete before the app service is recreated.
The --no-deps on the last line ensures that we are not running the init container for a second time, when we recreate the queue and scheduler containers.
The trade-off
Keeping the site up during migrations means the old code is now serving requests (and processing the queue and scheduled tasks) against the new schema. For additive migrations that’s fine. For anything destructive (dropping a column, renaming one, tightening a constraint) the old container will throw errors for the short period between init finishing and the swap.
But then this is just what modern development looks like to me: I’ve done zero-downtime deployments for years, so I’m used to planning deployment in such a way that my old code can run with new schema. When I do need to do destructive migrations I’ll have to phase them over two deployments. That is just business as usual.
Alternatively, you can always stop the cron and scheduler services before you run docker compose up -d app, to stop these running during that restart window.
Takeaway
With Docker, as with bare code deployments, it pays to think about the sequence of deployment steps. That sequence is a bit more obvious when you’re deploying the code itself rather than building an image, pulling it, and restarting containers. But fundamentally the same things matter.