You already know how to develop in Docker with Laravel Sail, and the Docker course shows a php-fpm + Nginx image and workers in containers. The VPS, Supervisor and Forge routes are in the VPS deploy, Supervisor and cron and Forge/Envoyer/CI topics. This topic adds only the Laravel-specific production container: an Octane image on FrankenPHP, what runs at build and at start, migrations as a deploy step, health checks and one compose.yaml for everything.
One image, several roles
A Laravel release is one image, started with different commands. The web server, the queue worker, the scheduler and the migration are all php artisan ... on the same code, so they must not drift apart.
| Role | Command | Lifetime |
|---|---|---|
web | php artisan octane:frankenphp --host=0.0.0.0 --port=8000 | long-running, scaled by replicas |
worker | php artisan queue:work --tries=3 --max-time=3600 | long-running, restarts itself every hour |
scheduler | php artisan schedule:work | exactly one instance |
migrate | php artisan migrate --force --isolated | one-off, exits |
💡
schedule:workreplaces the* * * * *cron line inside a container; run one scheduler, otherwise tasks fire twice. Worker tuning is in queues and Redis.
The production Dockerfile
Three decisions shape the file: a separate Node stage for Vite assets (Node never reaches production), dependencies installed before the code (a cached layer), and a non-root user at the end. It assumes composer require laravel/octane and php artisan octane:install --server=frankenphp were already done in the project.
FROM node:22-alpine AS assets
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM dunglas/frankenphp:1-php8.4
RUN install-php-extensions pcntl pdo_mysql redis opcache bcmath
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader
COPY . .
COPY --from=assets /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \
&& php artisan package:discover \
&& php artisan route:cache \
&& php artisan view:cache \
&& php artisan event:cacheThe second half sets production defaults, drops root and declares the start command and the health check: