The simplest Laravel Docker setup combines Laravel Sail for local development with a purpose-built production image for deployment. Sail gives every developer the same PHP, MySQL, Redis, and Mailpit stack with just a few commands. Production needs something different: a small, immutable image with your code and dependencies baked in, no development tools, and secrets supplied at runtime.

Here’s a common mistake: a team uses Sail locally, loves it, and pushes the same compose.yaml to a production server. It works until traffic arrives. Sail runs the app with php artisan serve, mounts code from the host, and includes development tooling. It was never designed for production.

This guide covers both parts: getting started with Laravel Sail, then dockerizing Laravel for production with a multi-stage Dockerfile, queue workers, and a Laravel Docker Compose file.

Key Takeaways

  • Sail is Laravel’s official Docker development environment: a compose.yaml file and a sail CLI, supported on macOS, Linux, and Windows via WSL2.

  • Sail is for development only. It serves the app with php artisan serve and mounts source code from the host.

  • Production images should use multiple stages: build assets and install dependencies in earlier stages, then ship only what’s needed in the final image.

  • One image can serve multiple roles. The same image can run the web server, queue workers, and scheduler with different commands.

  • Never bake secrets into images. Pass .env values as runtime environment variables or orchestrator secrets.

The developers in this article's examples are illustrative composites based on common scenarios.

Laravel Sail vs. Herd vs. Docker in Production

Before setting anything up, choose the right tool for each job:

Laravel Sail

Laravel Herd

Production Docker image

Purpose

Local development

Local development

Running in production

Runs on

Docker (macOS, Linux, Windows via WSL2)

Native macOS and Windows

Any container platform

Services

MySQL, Redis, Mailpit, and more through compose.yaml

PHP, Nginx, and Node built in; databases, Redis, and more with Herd Pro

Managed databases and separate containers

Matches production OS

Yes, through Linux containers

No

Yes, it is the production environment

Best for

Teams that want identical environments

Fast, simple local setup

Kubernetes, ECS, Fly.io, Render, and similar platforms

If you work alone on a Mac or Windows machine and want to avoid Docker overhead, our Laravel Herd guide offers a faster route. Choose Sail when you want every teammate and your CI pipeline to run the same Linux-based stack.

Part 1: Laravel Sail for Local Development

Install Sail

New Laravel applications include Sail. For an existing app, install it and publish the compose.yaml file using the Sail documentation:

composer require laravel/sail --dev
php artisan sail:install        # choose services: mysql, redis, meilisearch...
./vendor/bin/sail up -d         # start containers in the background

Add a shell alias so you can type sail instead of ./vendor/bin/sail:

alias sail='sh $([ -f sail ] && echo sail || echo vendor/bin/sail)'

Your app should now be available at http://localhost. On Windows, run these commands inside WSL2 rather than PowerShell.

Everyday Sail Commands

Inside Sail, prefix your usual tools with sail:

sail artisan migrate
sail composer require laravel/sanctum
sail npm run dev
sail test                 # runs Pest or PHPUnit against a separate "testing" database
sail shell                # bash inside the app container
sail stop

Useful Sail Services

Run php artisan sail:add to add services later. Here are some useful options:

  • MySQL or PostgreSQL with persistent volumes, plus a dedicated testing database

  • Redis or Valkey for caching and queues

  • Meilisearch or Typesense for Laravel Scout

  • Mailpit to catch outgoing email at http://localhost:8025

  • RustFS for local S3-compatible file storage

  • Selenium for Laravel Dusk browser tests

Change PHP or Node Versions

Sail supports PHP 8.0 through 8.5, with 8.5 as the default, and installs Node 24 by default. To pin PHP 8.4, change the build context of the laravel.test service in compose.yaml, then rebuild:

laravel.test:
    build:
        context: ./vendor/laravel/sail/runtimes/8.4
sail build --no-cache && sail up -d

Match the PHP version to the one you use in production so that “works on my machine” means something.

Two more features are worth knowing about. sail share gives you a temporary public URL for testing webhooks, and setting SAIL_XDEBUG_MODE=develop,debug in .env enables Xdebug. First publish Sail’s configuration with sail artisan sail:publish, confirm the published php.ini contains xdebug.mode=${XDEBUG_MODE}, and then run sail build --no-cache.

Rafael leads a four-person agency in Lisbon. Every new hire used to lose a day installing PHP extensions and matching MySQL versions. After moving every client project to Sail and pinning the PHP version to production, onboarding became “install Docker, clone, sail up,” and environment-related bugs largely disappeared.

Skip the setup entirely? StackMivo generates a complete Laravel app, including models, migrations, authentication, and tests, from a plain-English description. It’s ready to run locally or deploy.

Part 2: Dockerize Laravel for Production

A production image has different goals: it should be small, secure, quick to start, and identical across servers. Here’s a multi-stage Dockerfile using FrankenPHP’s official image, which the Laravel Octane documentation describes as suitable for local development and production.

This example assumes you’ve installed Octane:

composer require laravel/octane
php artisan octane:install --server=frankenphp

Create a Multi-Stage Dockerfile

# syntax=docker/dockerfile:1

# ---- Stage 1: build front-end assets ----
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Stage 2: production image ----
# Pin an exact tag from Docker Hub; match your PHP version (and your Sail runtime)
FROM dunglas/frankenphp:1.13.1-php8.4-trixie

RUN install-php-extensions pcntl pdo_mysql redis intl zip opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

# Install PHP dependencies first so this layer is cached between builds
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction

COPY . .
COPY --from=assets /app/public/build ./public/build

RUN composer dump-autoload --optimize --no-dev \
    && chown -R www-data:www-data storage bootstrap/cache /config/caddy /data/caddy

# Entrypoint caches config at startup, then runs the container's command
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# Don't run as root
USER www-data

EXPOSE 8000
ENTRYPOINT ["entrypoint.sh"]
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000"]

Create the Entrypoint Script

The entrypoint script is small. Save it with LF line endings rather than Windows CRLF, or the container may fail to start.

#!/bin/sh
# docker/entrypoint.sh
set -e
php artisan optimize   # cache config, routes, views and events with runtime env values
exec "$@"              # hand off to the CMD (Octane, queue worker, scheduler...)

Add a .dockerignore File

Use .dockerignore to keep secrets and unnecessary files out of the image:

.env
.env.*
!.env.example
.git
node_modules
vendor
frankenphp
bootstrap/cache/*.php
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
public/build

A few important points:

  • Multi-stage builds keep Node out of production. Only the compiled public/build directory is copied into the final image.

  • If your Vite build needs PHP, such as when the Wayfinder plugin in Laravel’s starter kits generates route files with Artisan, build assets in a stage that includes both PHP and Node.

  • Prefer PHP-FPM and Nginx? That works, too. Use a PHP-FPM base image with Nginx in a separate container or a combined community image. The multi-stage approach remains the same.

  • Read the FrankenPHP Docker documentation for image variants and additional extensions.

  • Pin versions. Use exact image tags and commit composer.lock and package-lock.json so builds are reproducible.

Configuration and Secrets at Runtime

Never copy .env into the image. The same image should run in staging and production with different environment variables.

  • Supply APP_KEY, database passwords, and API keys through your platform’s secrets manager rather than storing them in plain-text files in Git.

  • Set APP_ENV=production and APP_DEBUG=false.

  • Run php artisan optimize when the container starts, not during the build, because cached configuration captures environment values. The entrypoint script above does this for each container role.

  • Send logs to standard error with LOG_CHANNEL=stderr so your platform can collect them.

Run Migrations Once, Not in Every Container

If ten containers start and each runs migrate, they can race each other. Run migrations as a one-off release task in your deployment pipeline. For example:

docker run --rm your-image php artisan migrate --force

Run this before rolling out the new containers. Keep migrations backward compatible, as explained in our zero-downtime deployment guide.

A Laravel Docker Compose File for Production

The same image can run every role. Only the command changes, so the entrypoint still caches configuration for each role.

# compose.prod.yaml
services:
  app:
    image: registry.example.com/your-app:${TAG}
    env_file: .env.production   # kept on the server, never in Git
    ports:
      - "8000:8000"
    restart: unless-stopped

  queue:
    image: registry.example.com/your-app:${TAG}
    env_file: .env.production
    command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600"]
    restart: unless-stopped

  scheduler:
    image: registry.example.com/your-app:${TAG}
    env_file: .env.production
    command: ["php", "artisan", "schedule:work"]
    restart: unless-stopped

A few things to note:

  • Databases and Redis are usually managed services in production, so they aren’t included in this file. If you self-host them, add them with persistent volumes and backups.

  • Queue workers restart cleanly. The --max-time option makes each worker exit after an hour, helping prevent memory usage from growing indefinitely. Docker restarts it, and deploying a new image tag replaces workers with fresh code. Our Laravel queues guide covers worker tuning.

  • The scheduler. Laravel’s documentation presents schedule:work as a local command. It can run in a dedicated container, but many teams prefer a cron process inside the container that runs schedule:run every minute. Either way, run only one scheduler.

  • Use a reverse proxy such as Traefik, Caddy, Nginx, or your platform’s load balancer for TLS.

  • Configure health checks. Point your platform’s health check at Laravel’s built-in /up route so unhealthy containers can be replaced automatically.

Mei runs a SaaS for veterinary clinics in Singapore. Her app initially ran on a single VPS, with Supervisor managing workers. When she moved to containers, one image handled the web, queue, and scheduler roles. Scaling for a busy Monday became a matter of setting the queue replicas to four, and every environment, from her laptop and CI build to production, ran the same image.

Building and Shipping Laravel Docker Images in CI

Your CI pipeline should build the image once, test it, push it to a registry, and then deploy that exact tag.

  1. Run tests inside the image or in a Sail-like service setup.

  2. Build the image: docker build -t registry.example.com/your-app:$GIT_SHA .

  3. Push it to your registry, such as GitHub Container Registry, ECR, or Docker Hub.

  4. Run migrations as a one-off task.

  5. Roll out the new containers and let health checks control when traffic is sent to them.

Our Laravel CI/CD pipeline guide shows a full GitHub Actions workflow you can adapt to these steps.

Do You Need Docker in Production at All?

Not always. Docker is useful for teams already using Kubernetes or ECS, apps with unusual system dependencies, and agencies standardizing deployments across many client apps. But it also adds image builds, registries, and orchestration to your workload.

  • Laravel Cloud builds and runs your app without requiring you to write a Dockerfile.

  • Laravel Forge provisions a regular VPS and supports zero-downtime deployments.

  • Containers make sense when you need portability across cloud providers or fine-grained scaling.

If you run Octane in production, see our Octane performance guide for advice on worker tuning and memory leak pitfalls.

Build the app, not the plumbing. Describe your Laravel app in plain English, and StackMivo generates it for you. You can then deploy to Laravel Cloud, Forge, or your own server. Start free with StackMivo →

Laravel Docker Checklist

  • Sail uses the same PHP version as production.

  • The multi-stage Dockerfile excludes Node, development dependencies, and .env from the final image.

  • .dockerignore excludes .env*, .git, vendor, node_modules, and cached configuration.

  • The container runs as a non-root user.

  • Secrets are supplied at runtime through a secrets manager.

  • php artisan optimize runs when the container starts.

  • Migrations run once per release, not in every container.

  • Web, queue, and scheduler containers use the same image.

  • Logs go to standard error, and health checks target /up.

  • Images are tagged by commit SHA and built in CI.

Frequently Asked Questions

Is Laravel Sail Good for Production?

No. Sail is a development environment. It serves your app with php artisan serve and mounts your code from the host. Use a dedicated production image instead.

How Do I Dockerize a Laravel App?

Create a multi-stage Dockerfile that builds front-end assets, installs Composer dependencies without development packages, copies your code, and runs the app with PHP-FPM or Octane. Pass configuration through runtime environment variables rather than copying .env into the image.

Does Laravel Sail Work on Windows?

Yes, through WSL2. Install Docker Desktop with the WSL2 backend and run Sail commands inside your Linux distribution.

Should I Use Sail or Herd?

Use Herd for a fast native setup on macOS or Windows. Choose Sail when you want every developer and your CI pipeline to use the same Linux-based Docker environment.

How Do I Run Queue Workers in Docker?

Run a separate container from the same image with php artisan queue:work as its command. Use --max-time or --max-jobs so workers restart regularly, and deploy new image tags to load updated code.

Conclusion

Laravel Docker works best when you treat development and production as two separate environments. Sail gives your team an identical, disposable development setup in minutes. A lean, multi-stage production image provides a portable, reproducible deployment that runs the web server, queue workers, and scheduler from the same build.

Keep secrets out of images, run migrations once, and pin your versions. With those practices in place, containers become a reliable part of your deployment process rather than a source of surprises.

Generate your next Laravel app with StackMivo, free to start →