
Deploy Laravel on a VPS with Docker, Octane and FrankenPHP without dropping a request. Health checks, one image for web/queue/scheduler, migration races, and a copy-paste deploy script.
I wrote Next.js Zero Downtime Deployment with Docker Rollout a few weeks ago, and the question I kept getting back was some version of "does this work for Laravel?"
Mostly yes. The proxy trick is identical. But Laravel on Octane has three moving parts that Next.js simply doesn't have — queue workers, a scheduler, and migrations — and each one has its own way of ruining a deploy that looked fine in the logs.
The advice you'll find for Laravel on Docker is almost always this:
git pull && docker compose up -d --build && sleep 10That sleep is doing a lot of load-bearing work, and it is not doing it well. Let's build something better.
Under PHP-FPM, every request boots the framework from scratch. It's slow, but it means a container is useful the instant the process starts.
Octane boots your application once and holds it in memory. That's the entire point — and it's why docker compose up -d --build is worse here than you'd expect:
sleep 10 is a guess about a number you have never measured.SIGTERM, waits 10 seconds by default, then SIGKILLs. A job that takes 30 seconds gets shot halfway through, and depending on your driver it either vanishes or gets retried from the top.The fix is the same shape as before: never let the number of healthy containers reach zero, and be deliberate about the pieces that can't be load-balanced.
docker rollout installed — see Step 6 of the Next.js post if you haven'tcomposer require laravel/octane
php artisan octane:install --server=frankenphpThat writes config/octane.php. We won't run the downloaded binary — the official dunglas/frankenphp image is a better base for production and gives us PHP extensions that static builds don't include.
Laravel 11 and up ship a health endpoint. Check bootstrap/app.php:
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)That's all you need. /up boots the framework and returns 200, which is exactly the question a rollout is asking: is this container able to serve a request yet?
Resist the urge to make
/upcheck your database or Redis. If MySQL blips for two seconds during a deploy, you want the rollout to succeed and the app to keep serving pages — not every container to fail its health check and roll back. Monitor dependencies separately.
Multi-stage: Composer dependencies, then frontend assets, then a lean runtime.
# syntax=docker/dockerfile:1
# --- PHP dependencies ---
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# --no-scripts: artisan isn't here yet, post-install hooks would fail.
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# --- Frontend assets ---
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# --- Runtime ---
FROM dunglas/frankenphp:1-php8.3
# pcntl is required by Octane. The rest are the usual Laravel suspects —
# trim this to what you actually use.
RUN install-php-extensions \
pcntl opcache pdo_mysql redis intl zip bcmath gd
WORKDIR /app
COPY --from=vendor /app/vendor ./vendor
COPY . .
COPY --from=assets /app/public/build ./public/build
# Now that artisan exists, finish the autoloader and run the scripts we skipped.
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative
# Views don't depend on env vars, so bake them. Config and routes are cached
# at container start instead — see the entrypoint for why.
RUN php artisan view:cache
# In a container the code never changes, so opcache never needs to stat files.
# This is a genuinely free speedup.
RUN printf '%s\n' \
'opcache.enable=1' \
'opcache.validate_timestamps=0' \
'opcache.memory_consumption=256' \
'opcache.max_accelerated_files=20000' \
'opcache.interned_strings_buffer=32' \
> /usr/local/etc/php/conf.d/opcache.ini
RUN chown -R www-data:www-data storage bootstrap/cache
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD curl -fsS http://127.0.0.1:8000/up || exit 1
ENTRYPOINT ["entrypoint"]Note --start-period=30s. Octane's first boot is slower than a Next.js server's — give it room, or your first rollout will time out and you'll blame the wrong thing.
Your web container, queue worker and scheduler all run the same code. Building three images is a waste and, worse, lets them drift apart. Use one image and branch on an environment variable.
#!/bin/sh
set -e
# Config and routes are cached HERE, not in the Dockerfile.
# `php artisan config:cache` freezes whatever env() returns at the moment it
# runs. Bake it into the image and every container inherits the build
# machine's environment — which is empty. Cache it once the real env exists.
php artisan config:cache
php artisan route:cache
php artisan event:cache
case "$CONTAINER_ROLE" in
queue)
echo "Starting queue worker..."
# --max-time recycles the worker hourly so long-lived Octane-style memory
# growth doesn't accumulate forever.
exec php artisan queue:work --tries=3 --max-time=3600 --sleep=1
;;
scheduler)
echo "Starting scheduler..."
# schedule:work is a long-running process. No crontab in the container.
exec php artisan schedule:work
;;
*)
echo "Starting Octane..."
exec php artisan octane:start \
--server=frankenphp \
--host=0.0.0.0 \
--port=8000 \
--workers="${OCTANE_WORKERS:-4}" \
--max-requests="${OCTANE_MAX_REQUESTS:-500}"
;;
esacexec matters more than it looks. Without it, PHP runs as a child of the shell, and the shell doesn't forward SIGTERM. Docker's stop signal never reaches your queue worker, it gets SIGKILLed after the grace period, and you lose the job it was working on. One keyword, and it's the difference between graceful shutdown and data loss.
services:
app:
image: laravel-app:latest
restart: unless-stopped
env_file: .env
# No `ports:` — two app containers must be able to coexist.
expose:
- "8000"
volumes:
- storage:/app/storage/app
depends_on:
- redis
queue:
image: laravel-app:latest
restart: unless-stopped
env_file: .env
environment:
CONTAINER_ROLE: queue
volumes:
- storage:/app/storage/app
# Docker's default is 10s. A job that runs longer than that gets SIGKILLed
# mid-flight. Set this to your longest realistic job.
stop_grace_period: 60s
depends_on:
- redis
scheduler:
image: laravel-app:latest
restart: unless-stopped
env_file: .env
environment:
CONTAINER_ROLE: scheduler
volumes:
- storage:/app/storage/app
depends_on:
- redis
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
proxy:
image: nginx:alpine
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
volumes:
storage:That storage volume is not optional. If you use the local filesystem disk for uploads, without a shared volume every user upload lives inside one container and disappears on the next deploy. I have watched this happen to someone else's production site and it is not a fun afternoon.
Identical to the Next.js setup, so I'll keep it short — the full explanation is in that post.
server {
listen 80;
server_name _;
resolver 127.0.0.11 valid=2s ipv6=off;
location / {
# The variable is the whole trick. A literal proxy_pass resolves once
# at startup and then keeps sending traffic to a dead container.
set $upstream http://app:8000;
proxy_pass $upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}If you terminate TLS here, set OCTANE_HTTPS=true in .env. Otherwise Laravel generates http:// links behind your HTTPS proxy and you get mixed-content warnings that are maddening to trace.
This is the Laravel-specific landmine, and it's the reason you can't just copy the Next.js script.
The obvious move is to put php artisan migrate --force in the entrypoint. Don't. During a rollout you have two app containers booting, plus a queue container and a scheduler — four processes racing to run the same migration. Laravel takes no lock. Best case one wins and the rest error out and die. Worst case two migrations partially apply and you're restoring from backup.
Run migrations once, before the rollout, in a throwaway container:
docker compose run --rm --no-deps app php artisan migrate --force--rm cleans it up, --no-deps stops Compose from starting the whole stack to run one command.
#!/bin/bash
# Zero-downtime Laravel deploy: migrate once, roll the web tier, restart workers.
if [ -z "${BASH_VERSION:-}" ]; then
exec /bin/bash "$0" "$@"
fi
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="laravel-app:latest"
PORT="8080"
HEALTH_URL="http://127.0.0.1:${PORT}/up"
[[ -f .env ]] || { echo "Missing .env"; exit 1; }
echo "==> Pulling latest code"
git pull --ff-only
echo "==> Building image"
DOCKER_BUILDKIT=1 docker build -t "$IMAGE" .
echo "==> Starting infrastructure"
docker compose up -d --remove-orphans redis proxy
# Once, in a throwaway container, before anything else picks up the new image.
echo "==> Running migrations"
docker compose run --rm --no-deps app php artisan migrate --force
echo "==> Rolling out web tier"
if [ "$(docker compose ps -q app | grep -c .)" -eq 0 ]; then
docker compose up -d app
else
docker rollout app --timeout 120 --wait-after-healthy 5
fi
# Queue and scheduler sit behind no proxy, so there is nothing to roll. Recreate
# them and let stop_grace_period drain in-flight jobs. Do this AFTER the web tier
# so new jobs are already being dispatched by new code.
echo "==> Restarting workers"
docker compose up -d --force-recreate queue scheduler
echo "==> Waiting for health"
for i in $(seq 1 30); do
if curl -sf --max-time 2 "$HEALTH_URL" >/dev/null; then
echo "Healthy"
break
fi
sleep 2
done
echo "==> Cleaning up"
docker container prune -f >/dev/null 2>&1 || true
docker image prune -f >/dev/null 2>&1 || true
docker builder prune -f --filter until=168h >/dev/null 2>&1 || true
echo "Done -> ${HEALTH_URL}"
docker compose pschmod +x deploy.sh
./deploy.shoctane:reload?Every Octane deployment guide tells you to run php artisan octane:reload after deploying, and for a traditional server that is exactly right — the code on disk changed, so you tell the running workers to pick it up.
In a container, the code on disk cannot change. It's baked into an immutable image. There is nothing new for the workers to reload; you replace the whole container instead, and docker rollout makes that seamless. Running octane:reload here would just cycle your workers for no reason.
Keep octane:reload in your toolbox for a bare-metal or Forge-style deploy. In this setup it's a no-op.
exec in the entrypoint is not optional. Without it the shell owns PID 1, never forwards SIGTERM, and your queue workers get SIGKILLed with a job in hand.config:cache at build time. env() returns null for anything outside a config file once config is cached, so baking it in freezes the build machine's empty environment into the image. Cache at container start.stop_grace_period to your longest realistic job or you will lose work on every deploy.migrate calls, no lock. Run it once in a docker compose run --rm container first.opcache.validate_timestamps=0 in a dev container will convince you your code changes aren't saving. It belongs in production images only.--max-requests papers over it; fixing the code is better.app. Two containers can't bind the same port, and doubling containers is the entire mechanism.OCTANE_HTTPS=true when TLS terminates at the proxy. Otherwise every generated URL is http:// and you'll chase mixed-content warnings for an hour.The Next.js version of this needed three things: a health check, no host port on the app, and nginx re-resolving DNS. Laravel needs those same three, plus a fourth that's easy to miss — being deliberate about the processes that aren't behind the proxy.
Your web tier rolls. Your queue and scheduler don't; they get recreated, and they need a real grace period to finish what they're holding. Migrations run once, before any of it, in a container that exists only for that job.
Get that separation right and ./deploy.sh stops being something you schedule for 2am. Which, given that Octane's whole promise is speed, feels like the right place to end up.