Matthew Hodge
Full Stack Developer

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 --from=composer:latest /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:9000php 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.
  • dbdata named 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 Makefile to wrap the long commands. make migrate, make shell, make up are much nicer than remembering the full docker compose exec syntax.
  • Watch out for file permission issues on Linux hosts — www-data inside the container may conflict with your local user. Setting user: "${UID}:${GID}" in the service definition can help.
  • Mount vendor as 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.