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
Source: Laravel — Sail Installation
-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
Source: Laravel — Sail Installation
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)
Source: Docker — docker image history
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:
Source: Docker — Compose depends_on
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
Source: Laravel — The Health Route
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 tinkerfailed in the container withWriting to directory /config/psysh is not allowed, because the image runs aswww-datawith no writable home directory.-e HOME=/tmp -e XDG_CONFIG_HOME=/tmpondocker compose execfixed 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 optimizeat 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.ymland thedocker-composebinary are nowcompose.yamlanddocker compose, which Sail generates. -
Images that run
php artisan servein production are a misreading of Sail. Production uses FrankenPHP, or PHP-FPM with Nginx. -
APP_KEYin the image or in a committed.envis replaced by run-time environment variables or secrets.