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 upand everything works. - Easy cleanup.
docker compose down -vremoves 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_networklets every container reach the others by service name (e.g., the Laravel app resolvesmysqlas a hostname). - Named volume
mysql_datapersists your database betweendocker compose downanddocker compose upcycles. depends_onwithcondition: service_healthyon thephpservice 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:
| Service | URL |
|---|---|
| React (Vite) | http://localhost:5173 |
| Laravel API | http://localhost:8080/api |
| MySQL | localhost: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.ymlthat removes volume mounts and uses pre-built images. - Environment-specific
.envfiles 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.