Most tutorials that teach you how to deploy Laravel with Docker are frozen in 2023: they still use the old docker-compose v1 syntax, pin PHP 8.2, and show a dev-only setup with bind-mounted code that would never survive production. In this guide you will build a production-ready Docker setup for a real Laravel app from scratch: a multi-stage Dockerfile on PHP 8.4, a separate Nginx container, MySQL and Redis services, a dedicated queue worker, the Laravel scheduler, health checks, and a repeatable deploy script you can run on any VPS. It is written for Laravel 13 (the current release, which requires PHP 8.3 or newer), but the pattern works on Laravel 12 too.
By the end, a deploy is three commands: pull, build, migrate. Nothing is copied by hand, nothing is “works on my machine”.
What You Are Building
Five containers, each with one job:
- app — your Laravel code on
php:8.4-fpm, built with a multi-stage Dockerfile so the final image contains no compilers or dev tools. - webserver — Nginx, serving
/var/www/publicand forwarding PHP requests to the app container over FastCGI. - mysql — MySQL 8.4 with a named volume, so your data survives container restarts.
- redis — cache and queue driver.
- worker and scheduler — the same app image, started with different commands: one runs
queue:work, the other runsschedule:runevery minute.
This separation is the whole point. Docker’s own official Laravel guide recommends exactly this shape — a production image built with multi-stage builds, plus separate Compose files for development (compose.dev.yaml) and production (compose.prod.yaml) — and the queue worker and scheduler live in their own containers rather than being bolted onto the web container.
Why not just use Laravel Sail?
Laravel Sail is Laravel’s official Docker setup and it is excellent — for local development. You install it with composer require laravel/sail --dev, run php artisan sail:install, and start everything with ./vendor/bin/sail up. But Sail is deliberately a dev tool: it bind-mounts your code, ships dev dependencies, and is not hardened for production. Production deployments need a custom Dockerfile with no bind mounts, cached config, an OPcache-tuned PHP, and a real process layout. Sail gets your team coding; this guide gets your app shipped.
Prerequisites
- A Laravel project (a fresh
composer create-project laravel/laravel myappworks fine, so does an existing app). - Docker Engine 24+ and Docker Compose v2 on your machine and on the server. Modern Compose is invoked as
docker compose(a space, not a hyphen); the olddocker-composebinary and theversion: '3.8'key at the top of Compose files are obsolete — if you see them in an old tutorial, that tutorial is out of date. - A VPS or any machine that can run Docker, with ports 80 and 443 open.
- Your app’s Composer dependencies installable with
composer install(Composer 2.x).
Step 1: Lay Out the Docker Files
Keep every Docker file inside a docker/ directory at the project root so the project stays clean:
myapp/
├── app/
├── bootstrap/
├── docker/
│ ├── php/
│ │ ├── Dockerfile
│ │ └── entrypoint.sh
│ └── nginx/
│ └── default.conf
├── compose.prod.yaml
├── compose.dev.yaml # optional, for local parity
└── .dockerignore
Step 2: Write a Multi-Stage Dockerfile for PHP
The Dockerfile has three stages. The vendor stage installs Composer dependencies, the assets stage builds your Vite frontend, and the final app stage copies only the finished artefacts into a slim runtime image. Nothing from the build toolchain (git, npm, compiler headers) survives into production.
Laravel 13 requires PHP 8.3 or newer, and PHP 8.4 is the safest choice right now: every mainstream package already supports it, and you avoid the occasional brand-new-release hiccup of 8.5. The extension list below covers everything Laravel needs (Ctype, cURL, DOM, Fileinfo, Mbstring, OpenSSL, PDO, Tokenizer, XML and friends) plus the drivers for MySQL queues and image handling.
# ---------- Stage 1: Composer dependencies ----------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# --no-dev and --optimize-autoloader: production only, classmap built once
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-progress
# ---------- Stage 2: frontend assets ----------
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ---------- Stage 3: the production runtime ----------
FROM php:8.4-fpm
# System libraries Laravel's extensions compile against
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl zip unzip \
libpng-dev libonig-dev libxml2-dev libzip-dev \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql mbstring zip exif pcntl bcmath gd \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Production PHP tuning: OPcache on, errors off
RUN { \
echo 'opcache.enable=1'; \
echo 'opcache.memory_consumption=256'; \
echo 'opcache.max_accelerated_files=20000'; \
echo 'opcache.validate_timestamps=0'; \
echo 'display_errors=Off'; \
echo 'log_errors=On'; \
} > /usr/local/etc/php/conf.d/production.ini
WORKDIR /var/www
# Copy the finished artefacts from the build stages
COPY --chown=www-data:www-data . /var/www
COPY --chown=www-data:www-data --from=vendor /app/vendor /var/www/vendor
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/public/build
# Laravel needs to write here at runtime
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]
Two things worth noticing. First, opcache.validate_timestamps=0 means PHP never re-checks files for changes — that is a real speed win in production, but it also means every deploy must restart the PHP container (our deploy script does that automatically). Second, the code is copied into the image, not bind-mounted. In production you never want volumes: - .:/var/www: a bind mount means whatever is on the server’s disk wins over whatever you built, which defeats the entire purpose of an image.
Step 3: Add a .dockerignore File
Without this, your build context ships .git, local .env, and node_modules into every build — slow and a leak risk if a real .env with secrets ever ends up in an image layer:
.git
.github
.env
.env.*
!/.env.example
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
.phpunit.result.cache
/tests
Step 4: Configure Nginx
Nginx serves static files directly and passes PHP to the app container at app:9000. Docker’s internal DNS resolves the service name app automatically — never use localhost for inter-container traffic, because inside a container localhost is the container itself.
server {
listen 80;
server_name example.com;
root /var/www/public;
index index.php;
# Laravel's built-in health route — handy for health checks
location /up {
try_files $uri $uri/ /index.php?$query_string;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
# Long cache on versioned Vite assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Step 5: Write compose.prod.yaml
This is the heart of the setup. Note what is not here: no port mapping for MySQL or Redis (they are only reachable inside the Compose network), no version: key, and the queue worker and scheduler reuse the same app image with a different command.
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
image: myapp:latest
restart: unless-stopped
env_file: .env
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "php", "-v"]
interval: 30s
timeout: 5s
retries: 3
webserver:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# static assets must exist in both containers
- app-public:/var/www/public:ro
depends_on:
app:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/up"]
interval: 30s
timeout: 5s
retries: 3
mysql:
image: mysql:8.4
restart: unless-stopped
env_file: .env
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
worker:
image: myapp:latest
restart: unless-stopped
env_file: .env
command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
depends_on:
app:
condition: service_started
redis:
condition: service_started
scheduler:
image: myapp:latest
restart: unless-stopped
env_file: .env
command: >
sh -c "while true; do php artisan schedule:run --no-interaction; sleep 60; done"
depends_on:
app:
condition: service_started
volumes:
app-public:
mysql-data:
redis-data:
A few deliberate choices here:
- One image, three roles. The worker and scheduler are the exact same image as the web app, so a queue worker can never run different code than the web tier. This kills an entire class of “but it worked on the web container” bugs.
- The scheduler is a loop, not cron. Laravel’s scheduler is designed to run once a minute, and a tiny shell loop inside a container does that with zero extra tooling. (For heavy cron needs, a dedicated
supercronicsidecar is the cleaner long-term answer.) - Named volumes for state.
mysql-dataandredis-datalive outside any container, sodocker compose downnever deletes your database. Code, meanwhile, lives inside the image — the two are updated on different rhythms, exactly as they should be.
One wrinkle: the Nginx container needs your compiled public/build assets, which live inside the app image. The app-public volume shared between them solves it — add a line to the entrypoint script that copies /var/www/public into the shared volume on boot (shown in Step 6).
Step 6: The Entrypoint Script
The entrypoint runs every time the app container starts. It waits for the database, runs migrations, warms the caches, syncs public assets, then hands control to PHP-FPM. Migrations run here — not at build time — because build time has no database to migrate against.
#!/bin/sh
set -e
# Sync public assets (Vite build output, storage link target) to the shared volume
if [ -d /var/www-public ]; then
cp -r /var/www/public/. /var/www-public/
php artisan storage:link --force || true
fi
# Wait for MySQL to accept connections (up to ~60s)
for i in $(seq 1 30); do
if php artisan db:show >/dev/null 2>&1; then
break
fi
echo "Waiting for database... ($i)"
sleep 2
done
php artisan migrate --force
php artisan optimize
exec "$@"
Mount the shared volume at /var/www-public by adding this to the app service in compose.prod.yaml:
volumes:
- app-public:/var/www-public
php artisan optimize caches your config, routes, and views in one shot — the single biggest free performance win on a Laravel deploy, and the reason you must keep APP_DEBUG=false in production: cached config means a debug flag flipped at runtime is silently ignored.
Step 7: Production .env (On the Server, Never in Git)
Your .env lives on the server only — it is in .dockerignore and .gitignore, and env_file: .env in Compose injects it into every container. The critical Docker-specific values:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=mysql # the Compose service name, NOT localhost
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=myapp
DB_PASSWORD=use-a-long-random-password-here
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis # again: the service name
REDIS_PORT=6379
# Generate once: php artisan key:generate --show
APP_KEY=base64:...
The number-one “it works locally but not in Docker” bug is DB_HOST=127.0.0.1. Inside a container, 127.0.0.1 is the container itself, and your app container is not running MySQL. Service names are DNS names on the Compose network — use them.
Step 8: The Deploy Script
Save this as deploy.sh on the server. It is deliberately boring: pull, rebuild, start, clean up old images. Because the code is baked into the image, a deploy is atomic-ish — the old containers keep serving until the new ones are healthy.
#!/bin/bash
set -e
cd /opt/myapp
git fetch origin main
git reset --hard origin/main
# Rebuild only what changed, start everything fresh
docker compose -f compose.prod.yaml up -d --build
# Drop dangling images so the disk doesn't fill up over months
docker image prune -f
docker compose -f compose.prod.yaml ps
Run your first deploy with it, then verify:
chmod +x deploy.sh && ./deploy.sh
# Watch the app container run its migrations
docker compose -f compose.prod.yaml logs -f app
# Smoke-test the health route
curl -f http://localhost/up && echo "OK"
# Run a one-off Artisan command inside the running app container
docker compose -f compose.prod.yaml exec app php artisan about
If you prefer zero-downtime deploys rather than a few seconds of restart, put this whole stack behind a reverse proxy (Traefik or Caddy) and do blue-green swaps — but for most side projects and small client apps, the restart window here is seconds, and the simplicity is worth it.
Step 9: Add HTTPS
You have two honest options, and both are fine:
- Cloudflare in front (easiest). Point your DNS at the server, enable the orange-cloud proxy, and set SSL mode to Full (strict). Cloudflare terminates TLS and your Nginx keeps serving plain HTTP on port 80. Free, and you get a CDN and DDoS protection thrown in.
- Certbot on the host. Install Certbot, run
certbot --nginxagainst your domain, and let it manage certificates and renewal. Slightly more moving parts, but no third party in front of your traffic.
What you should not do is serve a production Laravel app over plain HTTP in 2026 — browsers flag it, and any login form on it is a liability.
Troubleshooting the Usual Suspects
| Symptom | Usual cause | Fix |
|---|---|---|
SQLSTATE[HY000] [2002] Connection refused |
DB_HOST is 127.0.0.1 or localhost |
Set DB_HOST=mysql (the service name) |
| 502 Bad Gateway from Nginx | PHP-FPM not reachable | Check fastcgi_pass app:9000 matches the service name; confirm the app container is running with docker compose ps |
| Blank pages, nothing in the log | APP_DEBUG=false with cached config, or permissions |
chmod -R 775 storage bootstrap/cache; check storage/logs/laravel.log |
| Changes don’t appear after deploy | OPcache validate_timestamps=0 or old containers still running |
Deploys rebuild the image; verify with docker compose images that the new image is live |
| Queue jobs sit unprocessed | Worker not running or wrong queue connection | docker compose logs worker; confirm QUEUE_CONNECTION=redis and REDIS_HOST=redis |
| Build is painfully slow | No layer caching; huge build context | Check .dockerignore; order Dockerfile steps from least- to most-frequently-changed |
When This Setup Is Overkill
Honesty matters: if your app is a weekend side project with no queues and fifty visitors a day, this whole stack is more machinery than you need. A single container running ServerSideUp’s PHP images (which bundle PHP-FPM and Nginx in one variant) or even php artisan serve behind a process manager will do. Reach for the five-container layout when you have background jobs, scheduled tasks, real traffic, or a team that needs everyone’s local environment to match production exactly.
Further Reading & References
- Develop and Deploy Laravel applications with Docker Compose — Docker’s official guide: multi-stage builds and the dev/prod Compose file split this post is modelled on.
- Laravel Docker Setup: Compose, Queues & CI/CD — Zestminds’ production FAQ, including why Sail is a dev tool and how to structure worker/scheduler containers.
- Deploying a Laravel App with Docker and MySQL Using Docker Compose, Step by Step Guide — Medevel’s walkthrough of the classic Nginx + PHP-FPM + MySQL trio.
- Laradock vs Laravel Sail — Laradock’s docs comparing Sail’s minimal official stack with heavier community setups, useful when deciding how much Docker you need.
- How to Deploy a Laravel Web Application Using Dokploy, Docker, Nginx & Cloudflare — recent video walkthrough of a full VPS deployment with Docker, Nginx, MySQL and Cloudflare in front.
- Easily Deploy a Laravel Application with Docker — Andrew Schmelyun’s video on deploying Laravel with Docker, covering server setup and getting code onto the box.
- Laravel Release Cycle: Versions, Support Policy, and Dates — which Laravel version is current and what PHP it needs.
- Serversideup PHP images reference — variant/tag reference for
serversideup/phpimages if you’d rather not maintain your own Nginx config.
Published 3 October 2026. Verified against Docker’s official Laravel guide, the Laravel 13.x deployment docs, and the Laravel release timeline on the same date. Commands target Docker Engine 24+ with Compose v2.



