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.

One image: web, worker, scheduler and a one-off migrate job
One image: web, worker, scheduler and a one-off migrate job
RoleCommandLifetime
webphp artisan octane:frankenphp --host=0.0.0.0 --port=8000long-running, scaled by replicas
workerphp artisan queue:work --tries=3 --max-time=3600long-running, restarts itself every hour
schedulerphp artisan schedule:workexactly one instance
migratephp artisan migrate --force --isolatedone-off, exits

💡 schedule:work replaces 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.

dockerfile
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:cache

The second half sets production defaults, drops root and declares the start command and the health check: