Blog
Laravel Docker Setup for Local Development and Production
Laravel Docker for local Sail and production: a custom Compose file, PHP-FPM with nginx, a queue worker, the scheduler, and GitHub Actions image builds.
Muhammad Zubair Akhtar
Laravel Docker is the split I use between a laptop and a server. I am Zubair Akhtar, a senior software engineer in Lahore, Pakistan. Sail is Laravel's Compose file for local development. Production is a different artifact: a PHP-FPM image, an nginx image, a queue worker, and a scheduler, built in CI and started from those tags. I read the pages named here on 29 October 2026. I have not run these files.
If you want that split on a repository you already have, that work sits with my Laravel development services. A host that is not containerized is Deploy Laravel to a VPS without Forge. TLS in front of the published port is the nginx setup guide. JSON routes inside the image are REST API development with Laravel.
Figure 1. Sail bind-mounts the working tree. The production targets copy the code into the image.
Versions this was written against
This follows the Laravel 13.x Sail page and the deployment page. The release notes date Laravel 13 to 17 March 2026, PHP 8.3 through 8.5, bug fixes through Q3 2027, and security fixes through 17 March 2028. The deployment page's minimum is PHP 8.3.
| Piece | What I read on 29 October 2026 | Source |
|---|---|---|
| Laravel framework | v13.39.0, published 27 October 2026 | Packagist |
| Laravel skeleton | php: ^8.3, laravel/framework: ^13.17 | laravel/laravel 13.x composer.json |
| Laravel Sail | v1.69.0, published 8 October 2026, --php=8.5 | Packagist and sail:install |
| PHP | 8.5.12, dated 21 October 2026 | php.net releases JSON |
| FPM image | php:8.5.12-fpm-bookworm | docker-library official-images |
| nginx image | 1.30.6, dated 14 October 2026 | nginx:stable repo-info, NGINX_VERSION |
| Composer image | 2.10.3, also tagged 2 and latest | official-images library/composer |
| Engine | 29.9.2, 20 October 2026 | Engine release notes |
| Compose | v5.6.1, 21 October 2026 | docker/compose releases |
PHP's supported versions keep 8.5 active until 31 December 2027. PHP 8.3 is security-only until 31 December 2027. Docker Hub's PHP image says not to publish the FPM port. Desktop 4.96.2 lists Compose v5.6.1. The 4.96.0 notes listed Engine 29.9.1. I am not assigning 29.9.2 to Desktop 4.96.2. Sail's stub has no version key, which matches the Compose Specification after 2.x and 3.x were merged. The Composer image installs the phar at /usr/bin/composer. I have not run it in the FPM image.
Sail for local development
Sail's introduction calls it a light-weight CLI for Laravel's default Docker development environment: PHP, MySQL, and Redis, on macOS, Linux, and Windows via WSL2. The heart of it is compose.yaml and the sail script. php artisan sail:install publishes the file, ./vendor/bin/sail up starts it, and the docs use http://localhost.
What Sail v1.69.0 puts in the file
With --no-interaction and no --with, $defaultServices in that tag is mysql, redis, selenium, and mailpit. The interactive prompt preselects only mysql. I am reading InteractsWithDockerComposeServices.php at v1.69.0, not a laptop I installed.
compose.stub builds laravel.test from ./vendor/laravel/sail/runtimes/8.5, names it sail-8.5/app, and bind-mounts the project at /var/www/html. The 8.5 runtime is FROM ubuntu:24.04, with php8.5-cli from packages.sury.org, NODE_VERSION=24, and artisan serve on port 80. MySQL is mysql:8.4, Redis is redis:alpine, Mailpit is axllent/mailpit:latest. The Sail page sets DB_HOST to mysql and REDIS_HOST to redis. That runtime includes Xdebug and PCOV. I do not push it as a production tag.
When the Compose file should not be Sail's
I keep Sail on a Mac or on Windows with WSL2, when nobody should learn a Dockerfile on day one. I write a second file when CI should build the image the server runs. Sail bind-mounts source. Compose in production says to remove those bindings so the code stays in the container. A queue worker is not in the stub. The queues page says you can start queue:work in another terminal, and ./vendor/bin/sail artisan runs that inside laravel.test.
Production images: PHP-FPM, nginx, queue, scheduler
Multi-stage builds use more than one FROM. COPY --from copies from a stage or from another image. docker build --target stops at one stage, and BuildKit builds only stages that target depends on. ghcr.io/example/laravel below is a placeholder. I have not built this file.
# syntax=docker/dockerfile:1
FROM php:8.5.12-fpm-bookworm AS vendor
COPY --from=composer:2.10.3 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
FROM php:8.5.12-fpm-bookworm AS app
WORKDIR /var/www/html
RUN docker-php-ext-install -j"$(nproc)" pdo_mysql
COPY --from=vendor /app/vendor ./vendor
COPY . .
RUN mkdir -p storage/framework/cache storage/framework/sessions \
storage/framework/views storage/logs bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
FROM nginx:1.30.6 AS web
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=app /var/www/html/public /var/www/html/public
RUN ln -sfn /var/www/html/storage/app/public /var/www/html/public/storage
Composer runs on the FPM image's PHP, which satisfies php: ^8.3. --no-dev and --optimize-autoloader are the flags on the Composer install command. --no-scripts skips discovery before the app files exist. The deployment page lists Ctype, cURL, DOM, Fileinfo, Filter, Hash, Mbstring, OpenSSL, PCRE, PDO, Session, Tokenizer, and XML. pdo_mysql is the MySQL driver, not its own bullet. docker-php-ext-install is the image's helper. If the build stops on a missing header, the log names the package. I have not seen that log.
I am not enabling OPcache. The deployment page does not require it. If you do, opcache.validate_timestamps is why FPM can keep old bytecode until the workers restart. The image leaves the master as root and the pool as www-data. I am not setting USER. chown is there so the pool can write storage and bootstrap/cache. I have not checked that image's uid.
Docker's volumes reference says content at the target is copied into a mounted volume when the volume is empty. volume-nocopy turns that off. A later start keeps the volume, including uploads. I have not mounted it.
nginx in front of FPM
The 8.5/bookworm/fpm Dockerfile on master of docker-library/php comments out listen = 127.0.0.1:9000, sets listen = 9000, and EXPOSE 9000. I read that file on 29 October 2026. Compose uses app:9000 on the project network. Do not map 9000 to the host.
The deployment page executes only index.php, returns 404 for other .php URLs, and sets SCRIPT_FILENAME to $realpath_root$fastcgi_script_name. Its socket is unix:/var/run/php/php8.3-fpm.sock. SCRIPT_FILENAME is a path FPM opens, so both images need /var/www/html/public/index.php from this Dockerfile. I send that path literally, because $realpath_root is resolved on the nginx disk.
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_hide_header X-Powered-By;
}
location ~ \.php$ { return 404; }
}
TLS stays on the host, in the nginx setup guide. The host proxies HTTP to the published port. Container nginx speaks FastCGI to app:9000. The web stage's symlink is storage:link without the Artisan command: nginx only sees uploads if the storage volume is mounted at /var/www/html/storage in that container too. I have not uploaded a file through it.
Queue worker and scheduler
The queues page says queue:work keeps the booted code until the process restarts. Its Supervisor example uses --sleep=3 --tries=3 --max-time=3600. Compose's production page names restart: always for a container that should come back. That is the process monitor in this file. It is not Supervisor. user: www-data is the example's user line, applied to the services that are not the FPM master.
The scheduler page's production entry is cron calling schedule:run every minute. schedule:work is the local foreground command. This image has no cron, so the service uses schedule:work. Sub-minute tasks keep the code schedule:run started with until the minute ends, and that page's stop is schedule:interrupt. I have not timed it.
services:
app:
image: ghcr.io/example/laravel:${TAG}
restart: always
env_file: .env
command: ["sh", "-c", "php artisan optimize && exec php-fpm"]
volumes: ["storage:/var/www/html/storage"]
web:
image: ghcr.io/example/laravel-web:${TAG}
restart: always
ports: ["8080:80"]
depends_on: [app]
volumes: ["storage:/var/www/html/storage:ro"]
queue:
image: ghcr.io/example/laravel:${TAG}
restart: always
user: www-data
env_file: .env
command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
volumes: ["storage:/var/www/html/storage"]
scheduler:
image: ghcr.io/example/laravel:${TAG}
restart: always
user: www-data
env_file: .env
command: ["php", "artisan", "schedule:work"]
volumes: ["storage:/var/www/html/storage"]
volumes:
storage: {}
optimize runs when env_file is loaded. After config:cache, env() outside config returns null, which is why the deployment page limits env() to config files. Leave migrate --force out of this command and run it once before traffic moves. The VPS guide is the note on a migration the previous tag can still boot. queue:restart is only for workers you do not replace, and the array cache driver never delivers it. APP_DEBUG stays false. /up is the health route I would request on port 8080. I have not.
GitHub Actions builds and pushes the image
build-push-action v7.4.0 (15 September 2026) uses the Git context, so this workflow does not check the repo out. login-action v4.7.0 (23 October 2026) logs in to GHCR with github.actor and secrets.GITHUB_TOKEN. setup-buildx-action v4.4.1 was published 16 September 2026. GitHub's publishing page sets contents: read and packages: write. It also sets attestation permissions. I am not adding those steps.
name: publish-image
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
push: true
target: app
tags: ghcr.io/example/laravel:${{ github.sha }}
- uses: docker/build-push-action@v7
with:
push: true
target: web
tags: ghcr.io/example/laravel-web:${{ github.sha }}
Replace the image name. I have not run this workflow. A green run stores two tags. It does not request /up. The web target still builds the PHP stage.
Figure 2. The action stops at the registry. The pull, the recreate, and /up are the deploy.
Deployment Laravel still happens after the push
The tag is the build. Deployment Laravel, on the deployment page, is the host's next step: optimize, a migration you run once, and a new worker process. This file does not replace the VPS release script. Use that script for host PHP-FPM and a current symlink. Use this page when the deploy unit is an image tag.
Sail publishes 3306 and 6379 for a laptop client. I am not publishing those ports or adding a database service. The deployment page treats the database as part of the server you already have. Leave APP_DEBUG false and read storage/logs on the volume. Do not retag sail-8.5/app. FrankenPHP is the other server that page names, with frankenphp php-server -r public/. This file keeps the nginx example and replaces the socket with app:9000.
Build the targets before you call the image done
Point env_file at a file with APP_DEBUG=false and run docker build --target app and --target web. I do that as Laravel development services from Lahore when the app should ship as a tag. Bring the PHP line in composer.json and whether the worker is queue:work or Horizon. I will not call a staging host done until /up returns 200 after a second tag.
