Docker and Sail

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/sail 1.68.0 and FrankenPHP 1.13.0 (PHP 8.5.11). Laravel uses containers in two places: Sail gives every developer the same PHP, database and services on any machine, and a production image packages one release for any container platform. Docker itself, images, layers, Compose and multi-stage builds, is covered on Docker.

Sail for development

composer require --dev laravel/sail and php artisan sail:install --with=pgsql,redis,mailpit,meilisearch write a compose.yaml with one service per choice and rewrite .env so the application finds them by service name. In Bookshelf the command wrote 105 lines of Compose. The application service builds from a Dockerfile inside the package, for the newest runtime (8.5), and mounts the project:

services:
    laravel.test:
        build:
            context: './vendor/laravel/sail/runtimes/8.5'
            dockerfile: Dockerfile
            args:
                WWWGROUP: '${WWWGROUP}'
        image: 'sail-8.5/app'
        extra_hosts:
            - 'host.docker.internal:host-gateway'
        ports:
            - '${APP_PORT:-80}:80'
            - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
        environment:
            WWWUSER: '${WWWUSER}'
            LARAVEL_SAIL: 1
            XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
            XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
        volumes:
            - '.:/var/www/html'
        depends_on: [pgsql, redis, mailpit, meilisearch]
    pgsql:
        image: 'postgres:18-alpine'
        ports:
            - '${FORWARD_DB_PORT:-5432}:5432'
    # redis:alpine, axllent/mailpit:latest and getmeili/meilisearch:latest follow, each with a health check and a volume
-DB_CONNECTION=sqlite
+DB_CONNECTION=pgsql
+DB_HOST=pgsql
+DB_PORT=5432
+DB_DATABASE=laravel
+DB_USERNAME=sail
+DB_PASSWORD=…
-MAIL_HOST=172.18.0.6
+MAIL_HOST=mailpit
+SCOUT_DRIVER=meilisearch
+MEILISEARCH_HOST=http://meilisearch:7700
-# PHP_CLI_SERVER_WORKERS=4
+PHP_CLI_SERVER_WORKERS=4

sail:install overwrites .env values without asking. Bookshelf’s SQLite and Mailpit settings were replaced, and the change had to be reverted from a backup. Commit .env.example first, or run the command on a fresh clone. The generated password is fine for a local container and wrong anywhere else.

The services themselves were not started in this environment, because the Sail runtime image builds from Ubuntu with packages from the internet, and docker build had no network here. On a normal machine the daily commands are:

$ ./vendor/bin/sail up -d                 # build on first run, then start everything
$ ./vendor/bin/sail artisan migrate       # any artisan command inside the container
$ ./vendor/bin/sail composer require …    # and composer, npm, php, node
$ ./vendor/bin/sail test --parallel       # php artisan test inside the container
$ ./vendor/bin/sail shell                 # a shell as the application user
$ SAIL_XDEBUG_MODE=develop,debug ./vendor/bin/sail up -d   # Xdebug on, connecting to host.docker.internal
$ ./vendor/bin/sail artisan sail:publish  # copy the runtime Dockerfiles into docker/ to customise them

Sail is for development only. The image runs php artisan serve with the project bind-mounted, which is the opposite of a production image. Laravel Herd (macOS and Windows) is the native alternative without containers. Homestead (a Vagrant VM) and Valet (macOS, Nginx through Homebrew) still exist but are no longer what the documentation recommends.

A production image

A production image contains one release, built once and run unchanged in every environment. The multi-stage layout keeps build tools out of the final image:

# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
RUN npm ci
COPY resources resources
RUN npm run build

FROM dunglas/frankenphp:php8.5 AS app
RUN install-php-extensions pdo_pgsql redis intl gd pcntl opcache
ENV APP_ENV=production APP_DEBUG=false LOG_CHANNEL=stderr SERVER_NAME=:8080
WORKDIR /app
COPY --chown=www-data:www-data --from=vendor /app /app
COPY --chown=www-data:www-data --from=assets /app/public/build /app/public/build
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
USER www-data
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s CMD curl -fsS -H 'Host: bookshelf.test' http://127.0.0.1:8080/up || exit 1
CMD ["sh", "-c", "php artisan optimize && exec frankenphp php-server --root public --listen :8080"]

docker build in this environment had no network (curl to Packagist returned status 000 inside a build step), so composer install, npm ci and install-php-extensions could not run there. The image that was built and run used the same final stage with vendor/ and public/build/ prepared beforehand (composer install --no-dev --optimize-autoloader on a copy of the project), and without extra extensions:

$ docker build -q -t bookshelf:1.0 .
build 31 s
bookshelf:1.0 1.49GB
$ docker history bookshelf:1.0
549MB   COPY --chown=www-data:www-data . /app
152kB   RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" …
872MB   (base image dunglas/frankenphp:php8.5)

The size has two causes. The FrankenPHP base image is 872 MB, because it includes the build tools for install-php-extensions. The application layer is 549 MB, mostly vendor/. This environment had to install packages with --prefer-source, so each package came with its tests and documentation. With --prefer-dist, the default, the archives leave those out, and an Alpine-based or -slim base and a smaller set of packages shrink it further (Image Best Practices and Security). The .dockerignore keeps .git, node_modules, tests, .env and the runtime contents of storage/ out of the context.

Web, worker and scheduler from one image

Every process of the topology on Deployment runs from the same image with a different command. Secrets such as APP_KEY come from the environment at run time, not from the image:

# compose.yaml
name: bookshelf
x-app: &app
  image: bookshelf:1.0
  env_file: runtime.env               # APP_KEY and other secrets, not baked into the image
  environment:
    APP_URL: http://bookshelf.test
    DB_CONNECTION: sqlite
    DB_DATABASE: /app/database/data/production.sqlite
    CACHE_STORE: database
    QUEUE_CONNECTION: database
    SESSION_DRIVER: database
  volumes:
    - data:/app/database/data

services:
  web:
    <<: *app
    ports: ["8090:8080"]
  worker:
    <<: *app
    command: ["php", "artisan", "queue:work", "--sleep=1", "--max-time=3600"]
    healthcheck: { disable: true }
    depends_on: { web: { condition: service_healthy } }
  scheduler:
    <<: *app
    command: ["php", "artisan", "schedule:work"]
    healthcheck: { disable: true }
    depends_on: { web: { condition: service_healthy } }

volumes:
  data:

The image’s CMD also ran php artisan migrate --force before starting the server, which is acceptable for one web container and wrong for several (see below). The stack, after docker compose up -d:

scheduler Up 15 seconds
web Up 25 seconds (healthy)
worker Up 15 seconds

web /up via port 8090: 200
web-1        |   config .............. 12.61ms DONE   (php artisan optimize at container start)
web-1        |   Creating migration table ....... 7.85ms DONE
scheduler-1  |   2026-10-05 23:02:00 Running ['artisan' catalogue:sync-all] in background  5.72ms DONE
scheduler-1  |   2026-10-05 23:02:15 Running [heartbeat] ........................ 4.26ms DONE
worker-1     |   2026-10-05 23:02:22 App\Jobs\ImportCover ........................... RUNNING
worker-1     | [2026-10-05 23:02:22] production.INFO: cover imported {"isbn":"978-0-8070-8305-4","hidden_seen":null}
worker-1     |   2026-10-05 23:02:22 App\Jobs\ImportCover ....................... 6.92ms DONE

The job was dispatched in the web container and handled by worker through the shared database queue. The logs went to stderr and appeared in docker compose logs without log files. Two problems showed up on the way:

  • php artisan tinker failed in the container with Writing to directory /config/psysh is not allowed, because the image runs as www-data with no writable home directory. -e HOME=/tmp -e XDG_CONFIG_HOME=/tmp on docker compose exec fixed it. Running as a non-root user is still right, and the same applies to any command that writes to the home directory.

  • php artisan optimize at start builds the caches for the container’s own environment, which is correct. Built into the image, the configuration cache would hold build-time values and ignore the runtime environment.

SQLite on a shared volume works for one host. Several hosts need PostgreSQL or MySQL and Redis, which is what the install-php-extensions line in the full Dockerfile is for. The image built here had none of pdo_pgsql, redis, intl, gd or pcntl.

On Kubernetes

The same image runs as several Deployments: web with an HTTP readiness probe on /up, workers and the scheduler without a Service. The scheduler is either a single-replica Deployment running schedule:work, or a CronJob that runs schedule:run every minute. Migrations run once per release in a Job or an init container, not in every pod’s start command, because several pods starting together would race on the same migration. Secrets come from a Secret, and trustProxies must match the ingress. A full application on Kubernetes, from images to Ingress and autoscaling, is worked through on Bookshelf End to End, and probes on Health Probes and Lifecycle.

Where older books and tutorials are outdated

  • Homestead with Vagrant and VirtualBox (Stauffer ch. 2) and Valet were the recommended local environments. Sail (Laravel 8) and Herd replaced them.

  • Laradock, the community Compose setup, is replaced by Sail for most projects.

  • docker-compose.yml and the docker-compose binary are now compose.yaml and docker compose, which Sail generates.

  • Images that run php artisan serve in production are a misreading of Sail. Production uses FrankenPHP, or PHP-FPM with Nginx.

  • APP_KEY in the image or in a committed .env is replaced by run-time environment variables or secrets.