Skip to content
Tech

A Docker Compose Setup for Laravel, MySQL and Redis That Your Whole Team Can Use

One compose.yaml, four services and health checks that actually wait for MySQL. A practical local stack for Laravel that new teammates can run in minutes.

Banner for Laravel Docker Compose stack featuring multi-container orchestration, MySQL health checks, and Redis cache
Banner for Laravel Docker Compose stack featuring multi-container orchestration, MySQL health checks, and Redis cache
In this article

    "Works on my machine" is a funny phrase until a new developer spends their first two days installing the right PHP extensions. A small, well-understood Docker Compose file fixes that: everyone runs the same PHP, the same MySQL and the same Redis, and onboarding becomes git clone followed by one command.

    Laravel Sail is an excellent official option and the right default for many teams. The setup below is for when you want to own every line of the configuration — to match production more closely, add services Sail doesn't ship, or simply understand what's running.

    The shape of the stack

    Four containers, each doing one job:

    • app — PHP-FPM with the extensions Laravel needs, plus Composer.
    • nginx — serves static files and passes PHP requests to app.
    • mysql — MySQL 8.4 with a named volume so data survives restarts.
    • redis — cache, sessions and queues.

    Containers on the same Compose network reach each other by service name, so Laravel connects to mysql and redis as hostnames. No IP addresses, no localhost confusion.

    The PHP image

    The official PHP image is deliberately minimal. We add the extensions Laravel commonly uses, the phpredis extension, and copy Composer in from its own image instead of downloading an installer:

    FROM php:8.3-fpm-alpine
    
    RUN apk add --no-cache icu-dev libzip-dev \
        && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
        && docker-php-ext-install pdo_mysql intl zip bcmath opcache \
        && pecl install redis \
        && docker-php-ext-enable redis \
        && apk del .build-deps
    
    COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
    
    WORKDIR /var/www/html

    The build tools needed to compile extensions go into a virtual package group and are removed in the same layer, which keeps the image noticeably smaller.

    The compose file

    Modern Compose no longer needs a version: key, and the file is conventionally named compose.yaml. The important detail is the health checks: depends_on with condition: service_healthy makes the app wait until MySQL actually accepts connections, not merely until its container has started.

    services:
      app:
        build:
          context: .
          dockerfile: docker/php/Dockerfile
        volumes:
          - .:/var/www/html
        depends_on:
          mysql:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      nginx:
        image: nginx:1.27-alpine
        ports:
          - "8080:80"
        volumes:
          - .:/var/www/html
          - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
        depends_on:
          - app
    
      mysql:
        image: mysql:8.4
        environment:
          MYSQL_DATABASE: ${DB_DATABASE}
          MYSQL_USER: ${DB_USERNAME}
          MYSQL_PASSWORD: ${DB_PASSWORD}
          MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-secret}
        ports:
          - "3307:3306"
        volumes:
          - mysql-data:/var/lib/mysql
        healthcheck:
          test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
          interval: 5s
          timeout: 5s
          retries: 20
    
      redis:
        image: redis:7-alpine
        healthcheck:
          test: ["CMD", "redis-cli", "ping"]
          interval: 5s
          timeout: 3s
          retries: 10
    
    volumes:
      mysql-data:

    Compose automatically reads a .env file in the project directory for variable substitution — the same file Laravel uses. That means the database credentials are defined exactly once. MySQL is published on port 3307 so it doesn't collide with a MySQL server already running on your laptop, while containers still talk to it on 3306.

    Tip: MYSQL_USER must not be root — the image creates root separately and will refuse to start. Use an app-specific user like horizon.

    The nginx site

    server {
        listen 80;
        root /var/www/html/public;
        index index.php;
    
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
    
        location ~ \.php$ {
            fastcgi_pass app:9000;
            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            include fastcgi_params;
        }
    }

    Wiring Laravel and starting it up

    Point Laravel at the service names. Because we installed phpredis, the default Redis client works without extra packages:

    DB_CONNECTION=mysql
    DB_HOST=mysql
    DB_PORT=3306
    DB_DATABASE=horizondesk
    DB_USERNAME=horizon
    DB_PASSWORD=secret
    
    REDIS_HOST=redis
    CACHE_STORE=redis
    SESSION_DRIVER=redis
    QUEUE_CONNECTION=redis

    Then build and boot everything:

    docker compose up -d --build
    docker compose exec app composer install
    docker compose exec app php artisan key:generate
    docker compose exec app php artisan migrate --seed
    
    # Follow logs from every service
    docker compose logs -f

    Open http://localhost:8080 and you're running. To add a queue worker, copy the app service, name it queue, and give it command: php artisan queue:work --tries=3. It reuses the same image, so there's nothing new to build.

    Common snags

    • Permission errors on storage/ — on Linux hosts, the container's www-data user may not match your user ID. Build the image with a user that matches your UID, or fix ownership of storage and bootstrap/cache once.
    • Slow file access on macOS and Windows — bind mounts cross a virtualisation boundary. Keeping the project inside the WSL 2 filesystem on Windows makes a large difference.
    • Stale config — if you cached config on the host, run php artisan config:clear inside the container.
    The best local environment is the one a new teammate can start without asking you a single question.

    Commit the Dockerfile, the compose file, the nginx config and an .env.example with these values. Your next hire's first day will be spent reading code instead of debugging extensions.

    Written by Rajasekar

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.