Docker Compose for Full Stack Developers: React + Laravel + MySQL

Learn how to containerise a full stack application with Docker Compose, combining React (Vite), Laravel (php-fpm + nginx), and MySQL. Covers hot reload volumes, environment variables, health checks, and multi-stage Dockerfiles for production.

Docker Compose for Full Stack Developers: React + Laravel + MySQL

By Elhassane Mehdioui, Full Stack Web Developer


If you have ever onboarded a new developer only to spend the first afternoon debugging PHP version mismatches, missing Node packages, or a MySQL socket that refuses to connect, you already understand the pain that Docker Compose solves. A single docker compose up command should hand every developer on your team an identical, reproducible environment — no matter whether they are running macOS, Windows, or Linux.

In this Docker Compose tutorial you will build a fully containerised full stack setup for a real-world project: a React (Vite) frontend, a Laravel backend served by php-fpm and nginx, and a MySQL database. Along the way you will wire up hot-reload volumes, environment variables, health checks, and a multi-stage Dockerfile ready for production.


Why Docker Compose for Full Stack Development?

Docker Compose lets you declare every service your application needs in a single docker-compose.yml file, then spin them all up together with one command. The benefits for a containerised full stack project are significant:

  • Parity between environments. Development, staging, and production all run the same images.
  • Isolated services. Each process lives in its own container, so a Node version upgrade in the frontend never touches the PHP runtime.
  • Reproducible onboarding. New team members run docker compose up and everything works.
  • Easy cleanup. docker compose down -v removes containers and volumes with no leftover state.

Project Structure

Before writing a single line of YAML, it helps to understand the folder layout:

my-app/
├── docker/
│   ├── nginx/
│   │   └── default.conf
│   ├── php/
│   │   └── Dockerfile
│   └── react/
│       └── Dockerfile
├── frontend/          # Vite + React app
├── backend/           # Laravel app
├── .env
└── docker-compose.yml

Keeping Docker configuration in a dedicated docker/ directory prevents it from cluttering application source folders and makes it straightforward to maintain separate Dockerfiles per service.


The docker-compose.yml Structure

The heart of any Docker React Laravel MySQL setup is the docker-compose.yml file. Here is the complete file you will build toward, broken into its key sections.

version: "3.9"

services:
  # ── MySQL ──────────────────────────────────────────
  mysql:
    image: mysql:8.2
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app_network

  # ── Laravel php-fpm ───────────────────────────────
  php:
    build:
      context: ./docker/php
      target: development
    restart: unless-stopped
    volumes:
      - ./backend:/var/www/html
    environment:
      DB_HOST: mysql
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      APP_KEY: ${APP_KEY}
      APP_ENV: local
      APP_DEBUG: "true"
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - app_network

  # ── Nginx (serves Laravel) ────────────────────────
  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./backend:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php
    networks:
      - app_network

  # ── React (Vite dev server) ───────────────────────
  react:
    build:
      context: ./docker/react
      target: development
    restart: unless-stopped
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      - VITE_API_URL=http://localhost:8080/api
    networks:
      - app_network

volumes:
  mysql_data:

networks:
  app_network:
    driver: bridge

Key Observations

  • Named network app_network lets every container reach the others by service name (e.g., the Laravel app resolves mysql as a hostname).
  • Named volume mysql_data persists your database between docker compose down and docker compose up cycles.
  • depends_on with condition: service_healthy on the php service ensures Laravel does not attempt to connect to MySQL before it is ready to accept connections.

Nginx Configuration for Laravel

Laravel is a PHP application and needs a web server to proxy HTTP requests to php-fpm. Place the following in docker/nginx/default.conf:

server {
    listen 80;
    server_name _;
    root /var/www/html/public;
    index index.php index.html;

    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;
    }
}

Nginx listens on port 80 inside the container (mapped to 8080 on your host), serves static assets directly, and forwards all PHP requests to the php service on port 9000 via FastCGI.


Multi-Stage Dockerfile for PHP (Laravel)

A multi-stage Dockerfile keeps development fast and production images lean. Place this in docker/php/Dockerfile:

# ── Base stage ────────────────────────────────────
FROM php:8.3-fpm-alpine AS base

RUN apk add --no-cache \
        libpng-dev \
        libzip-dev \
        oniguruma-dev \
        && docker-php-ext-install pdo_mysql zip mbstring gd bcmath

COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# ── Development stage ─────────────────────────────
FROM base AS development
ENV APP_ENV=local
# Source code is mounted as a volume — no COPY needed

# ── Production stage ──────────────────────────────
FROM base AS production
ENV APP_ENV=production

COPY . /var/www/html
RUN composer install --no-dev --optimize-autoloader --no-interaction \
    && php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache \
    && chown -R www-data:www-data /var/www/html/storage bootstrap/cache

In docker-compose.yml you reference target: development. In a CI/CD pipeline building the production image you use --target production. The production stage bakes the application code directly into the image, eliminating the need for a volume mount and enabling immutable deployments.


Multi-Stage Dockerfile for React (Vite)

Place this in docker/react/Dockerfile:

# ── Development stage ─────────────────────────────
FROM node:20-alpine AS development

WORKDIR /app
COPY package*.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

# ── Build stage ───────────────────────────────────
FROM development AS builder
COPY . .
RUN npm run build

# ── Production stage (static files via nginx) ─────
FROM nginx:1.25-alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

The --host 0.0.0.0 flag passed to Vite is critical. Without it the Vite dev server binds to localhost inside the container and the port mapping from your host machine never reaches it.


Hot Reload Volumes Explained

Hot reload is the killer feature for developer experience. Two volume declarations in the react service make it work:

volumes:
  - ./frontend:/app           # mounts your source code
  - /app/node_modules         # anonymous volume — keeps container's node_modules intact

The first line mounts your local frontend/ folder over /app inside the container, so every file save on the host is instantly reflected inside the running Vite process. The second line is an anonymous volume that shadows node_modules. Without it, Docker would overwrite the container's compiled node_modules with whatever (possibly empty) directory exists locally — breaking the container's dependencies.

For Laravel, the ./backend:/var/www/html volume mount works the same way. Changes to PHP files are picked up immediately because php-fpm reads from disk on every request in development mode.


Environment Variables

Keep secrets and per-environment configuration out of version control by using a .env file at the project root. Docker Compose automatically reads this file:

# .env
DB_ROOT_PASSWORD=supersecret
DB_DATABASE=myapp
DB_USERNAME=myapp_user
DB_PASSWORD=myapp_pass

APP_KEY=base64:GENERATE_ME_WITH_php_artisan_key:generate

Reference variables in docker-compose.yml with ${VARIABLE_NAME} syntax. For the Laravel container, you can also pass them directly as environment key-value pairs (as shown in the compose file above), which maps them into the container's environment and makes them available to $_ENV and env().

Never commit your .env file. Add it to .gitignore and provide a .env.example with placeholder values instead.


Health Checks

The MySQL health check in this setup prevents a classic race condition: Laravel tries to run migrations or open a connection before MySQL has finished initialising.

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
  interval: 10s
  timeout: 5s
  retries: 5

Docker polls this command every 10 seconds. Only once it exits with code 0 does the service status change to healthy, and only then does the php service start (because of condition: service_healthy). You can verify health status at any time with:

docker compose ps

Running the Stack

With all files in place, start the full stack in detached mode:

docker compose up -d --build

Then run Laravel migrations inside the running php container:

docker compose exec php php artisan migrate --seed

Your services are now available at:

ServiceURL
React (Vite)http://localhost:5173
Laravel APIhttp://localhost:8080/api
MySQLlocalhost:3306

Stop and remove containers (keeping volumes):

docker compose down

Remove containers and volumes (full reset):

docker compose down -v

Production Considerations

When shipping to production, rebuild with the production target for both services:

docker build --target production -t myapp-php ./docker/php
docker build --target production -t myapp-react ./docker/react

For production deployments consider:

  • Separate docker-compose.prod.yml that removes volume mounts and uses pre-built images.
  • Environment-specific .env files injected by your CI/CD pipeline, never stored in the repository.
  • Read-only filesystems on the React nginx container — the only writes needed are to nginx's cache directories.
  • Resource limits (mem_limit, cpus) in Compose to prevent a single service from starving others.

Conclusion

A well-structured docker-compose.yml removes entire categories of environment-related bugs from your Docker development workflow. By combining a Vite dev server with hot-reload volumes, a php-fpm and nginx pair for Laravel, a health-checked MySQL service, and multi-stage Dockerfiles that serve both development and production needs, you get a containerised full stack that is fast to iterate on locally and straightforward to deploy.

The patterns shown here — named volumes, anonymous node_modules volumes, depends_on with health conditions, and multi-stage builds — apply well beyond this specific stack. Once you are comfortable with them you will find yourself reaching for Docker Compose on every new project.


Elhassane Mehdioui is a Full Stack Web Developer specialising in React, Laravel, and containerised architectures.

Need a robust API or back-end?

Scalable back-end systems with Laravel, Node.js or Spring Boot.

REST APIs, authentication, role management, and clean architecture — built to last.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://