Docker Compose for Laravel: Local Dev Stack from Scratch
"Works on my machine" is one of those phrases that ends careers and friendships. Docker fixes it — or at least it gives you the tools to. The problem is that most Docker tutorials either show you a toy example or assume you already know what you're doing.
In this post, I'll walk through setting up a real Laravel development stack with Docker Compose: PHP-FPM, Nginx, MySQL, and Redis. No Laravel Sail — I want to show you what's happening under the hood so you understand what you're running.
What We're Building
- PHP 8.3 FPM — runs your Laravel application
- Nginx — handles HTTP and forwards PHP requests to FPM
- MySQL 8.0 — your database
- Redis — cache, sessions, queues
The project structure we'll end up with:
your-laravel-app/
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ └── php/
│ └── Dockerfile
├── docker-compose.yml
├── .env
└── ... (Laravel app files)
The Dockerfile
We need a custom PHP image because we need extensions that aren't in the base php:8.3-fpm image.
# docker/php/Dockerfile
FROM php:8.3-fpm
# System dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# PHP extensions
RUN docker-php-ext-install \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip
# Redis extension via PECL
RUN pecl install redis && docker-php-ext-enable redis
# Composer
COPY /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www
# Copy application files
COPY . /var/www
# Fix permissions
RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
Nginx Configuration
# docker/nginx/default.conf
server {
listen 80;
index index.php index.html;
root /var/www/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
The key bit here is fastcgi_pass php:9000 — php is the service name in Docker Compose, and Docker's internal DNS resolves it automatically.
docker-compose.yml
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: laravel_app
restart: unless-stopped
volumes:
- .:/var/www
- ./storage:/var/www/storage
networks:
- laravel
depends_on:
- db
- redis
nginx:
image: nginx:alpine
container_name: laravel_nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- .:/var/www
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
networks:
- laravel
depends_on:
- app
db:
image: mysql:8.0
container_name: laravel_db
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_USER: ${DB_USERNAME}
volumes:
- dbdata:/var/lib/mysql
ports:
- "3306:3306"
networks:
- laravel
redis:
image: redis:alpine
container_name: laravel_redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- laravel
networks:
laravel:
driver: bridge
volumes:
dbdata:
driver: local
A couple of things worth noting:
- Volumes on
.:/var/www— this mounts your local project into the container, so code changes reflect immediately without rebuilding. dbdatanamed volume — MySQL data persists between container restarts. Without this, your database gets wiped every time you bring the stack down.restart: unless-stopped— containers come back up automatically after a reboot.
.env Changes
Update your Laravel .env to point at the Docker services:
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=secret
REDIS_HOST=redis
REDIS_PORT=6379
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
Note that DB_HOST is db — the service name, not 127.0.0.1. Same with REDIS_HOST=redis. Docker Compose's internal network handles the DNS.
Starting the Stack
# Build and start everything
docker compose up -d --build
# Run migrations
docker compose exec app php artisan migrate
# Generate app key if needed
docker compose exec app php artisan key:generate
Your app should now be available at http://localhost:8080.
Useful Commands
# Tail Laravel logs
docker compose exec app tail -f storage/logs/laravel.log
# Run artisan commands
docker compose exec app php artisan tinker
docker compose exec app php artisan queue:work
# Access MySQL directly
docker compose exec db mysql -u laravel -p laravel
# Install Composer packages
docker compose exec app composer require some/package
# Rebuild after Dockerfile changes
docker compose up -d --build app
# Stop everything
docker compose down
# Stop and wipe the database volume
docker compose down -v
Composer Install on First Run
If you're cloning into a fresh repo, the vendor directory won't exist yet. You can handle this with an entrypoint or just run it manually after the first up:
docker compose exec app composer install
Or add an entrypoint script to your Dockerfile that runs composer install on startup — useful if you want fully automated setup for new team members.
Tips
- Don't run Composer or Artisan on your host once you're using Docker — run everything through
docker compose exec app. This keeps your environment consistent. - Add a
Makefileto wrap the long commands.make migrate,make shell,make upare much nicer than remembering the fulldocker compose execsyntax. - Watch out for file permission issues on Linux hosts —
www-datainside the container may conflict with your local user. Settinguser: "${UID}:${GID}"in the service definition can help. - Mount
vendoras a volume if you want to exclude it from the bind mount for better performance on macOS.
Final Thoughts
Once you've got this set up, adding a new developer to the project is git clone, docker compose up, done. No "install PHP 8.3", no "configure your Nginx", no diverging local environments.
It's a bit more upfront investment than php artisan serve, but you'll thank yourself the first time someone onboards in under five minutes.