Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Docker Compose Multi-Container Setup for Local Development

By Kokil Thapa | Last reviewed: September 2026

A Docker Compose multi-container setup for local development replaces the old pattern of installing PHP, MySQL, and Redis directly on your laptop. One YAML file defines every service, network, and volume your app needs. Run docker compose up and the full stack starts in seconds. On real client projects I maintain with Laravel development workflows, this approach has saved hours of "works on my machine" debugging. The guide below walks through a production-grade stack you can copy today.

What Is a Docker Compose Multi-Container Setup for Local Development?

Docker Compose is a tool that reads a YAML manifest and orchestrates multiple containers as one application. Each container runs a single process — PHP-FPM, MySQL, Redis, or Nginx. Compose wires them together with internal DNS, shared volumes, and environment variables.

Think of it as infrastructure-as-code for your laptop. A new developer clones the repo, runs two commands, and gets an identical environment. No manual PHP version switches. No conflicting MySQL ports. No guessing which extensions are installed.

For web application development, this matters because modern stacks rarely run on PHP alone. A typical Laravel 13 project needs PHP 8.3+, MySQL or PostgreSQL, Redis for cache and queues, and sometimes Node.js 26 LTS for Vite 8.x asset builds. Compose handles all of that declaratively.

Multi-Container Local Dev StackHost MachineNginxPort 8080PHP-FPMPHP 8.5MySQL9.7Redis8.10Bridge Network: app-networkVolume: db-dataPersistent storageBind Mount: ./srcLive code sync
Typical Docker Compose multi-container architecture for Laravel local development with Nginx, PHP-FPM, MySQL, and Redis

How Do You Create a docker-compose.yml for Local Development?

Start with a project root directory. Place docker-compose.yml alongside your application source. The file has three top-level keys: services, networks, and volumes.

Step 1: Define the PHP Application Service

The app container runs PHP-FPM. Mount your source code as a bind mount so file changes reflect immediately without rebuilding the image.

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    container_name: laravel-app
    restart: unless-stopped
    working_dir: /var/www/html
    volumes:
      - ./:/var/www/html
      - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
    networks:
      - app-network
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

Your Dockerfile should target PHP 8.5 (or 8.3 minimum for Laravel 13). Install common extensions: pdo_mysql, redis, gd, zip, intl, and opcache.

FROM php:8.5-fpm

RUN apt-get update && apt-get install -y \
    git curl zip unzip libpng-dev libonig-dev libxml2-dev \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd intl zip \
    && pecl install redis && docker-php-ext-enable redis

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

WORKDIR /var/www/html

Step 2: Add the Web Server

Nginx terminates HTTP and forwards PHP requests to the app container via FastCGI. The service name app becomes a DNS hostname on the internal network.

  nginx:
    image: nginx:1.27-alpine
    container_name: laravel-nginx
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    networks:
      - app-network
    depends_on:
      - app

A minimal Nginx config for Laravel:

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_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Step 3: Configure Database and Cache

MySQL 9.7 (or 8.4 LTS if you prefer wider hosting compatibility) stores application data. Redis 8.10 handles cache, sessions, and queue backends.

  db:
    image: mysql:9.7
    container_name: laravel-db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_USER: laravel
      MYSQL_PASSWORD: secret
    volumes:
      - db-data:/var/lib/mysql
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:8.10-alpine
    container_name: laravel-redis
    restart: unless-stopped
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  db-data:
    driver: local

Match your Laravel .env to these service hostnames:

DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=secret

REDIS_HOST=redis
REDIS_PORT=6379
CACHE_STORE=redis
QUEUE_CONNECTION=redis
Container Startup Flowcompose upCreatenetworkStart db+ redisHealthcheck OKStart appPHP-FPM readyStart nginxPort 8080 openApp Readylocalhost:8080Common Failure: app starts before db is readyFix with depends_on + healthcheck conditionsNever rely on sleep scripts in entrypoint
Docker Compose startup sequence with health-check dependencies preventing race conditions

How Do You Run and Manage Multi-Container Stacks Day to Day?

Once your YAML file is in place, daily workflow revolves around a handful of Compose commands. These replace manual service restarts and port juggling.

  1. Start the stack: docker compose up -d runs all services in detached mode.
  2. Install dependencies: docker compose exec app composer install
  3. Run migrations: docker compose exec app php artisan migrate
  4. Build frontend assets: docker compose exec app npm install && npm run dev
  5. View logs: docker compose logs -f app
  6. Stop everything: docker compose down
  7. Reset database volume: docker compose down -v (destroys data — use carefully)

Add a Makefile or shell alias if your team runs the same commands repeatedly. On projects I've shipped with Laravel and Livewire booking systems, a simple make up target reduced onboarding from half a day to twenty minutes.

For deeper networking and volume behaviour, see the dedicated guide on Docker networking and volumes. Bind mounts sync code instantly. Named volumes persist database files across container restarts.

Running Artisan, Composer, and Tests Inside Containers

Never install PHP or Composer on your host if the container is your runtime. Every command goes through docker compose exec:

docker compose exec app php artisan queue:work
docker compose exec app php artisan test
docker compose exec app composer require spatie/laravel-permission

Interactive shells work the same way: docker compose exec app bash. This guarantees the PHP version and extensions match production.

Docker Compose vs Laravel Sail: Which Should You Choose?

Laravel Sail is Laravel's official Docker wrapper built on Compose. Raw Compose gives you full control. Both run containers — the difference is abstraction level and portability across frameworks.

CriteriaRaw Docker ComposeLaravel Sail
Setup effortManual YAML + Dockerfilescomposer require laravel/sail + publish
Framework lock-inWorks with Symfony, WordPress, custom PHPLaravel-only
CustomizationFull control over every layerConstrained to Sail's service catalogue
PHP versionYou choose (8.3, 8.4, 8.5)Sail runtime tags (check current Sail docs)
Learning valueTeaches Docker fundamentalsHides Compose details behind ./vendor/bin/sail
CI/CD paritySame compose file in CI pipelinesRequires Sail binary or extracted compose file
Multi-project teamsOne pattern for all stacksEach Laravel repo has its own Sail config

My recommendation: learn raw Compose first. Sail is convenient for greenfield Laravel 13 projects where you want speed over control. For mixed teams running WordPress, Magento 2.4.x, and Laravel side by side, a shared Compose pattern pays off quickly. Read the full comparison in Laravel Sail vs Docker Compose.

Volume Strategy ComparisonBind Mount (./src:/var/www/html)Instant code changes, no rebuildIDE debugging on host filesOS file-permission mismatchesSlower on macOS Docker DesktopNamed Volume (db-data)Data survives container rebuildManaged by Docker, fast I/ONot directly editable on hostRequires docker volume commandsRule: bind mount code, named volume for databases
Bind mounts versus named volumes — choosing the right storage strategy in Docker Compose multi-container setups

What Are Common Mistakes in Docker Compose Local Development?

After setting up Compose stacks on dozens of production-bound projects, the same problems appear repeatedly. Most are configuration issues, not Docker bugs.

Port Conflicts and Binding Errors

Binding 3306:3306 fails if MySQL already runs on the host. Map to a non-standard host port instead: 3307:3306. Access from host tools via 127.0.0.1:3307. Inside the Compose network, services still connect on port 3306 using the hostname db.

File Permission Problems on Linux

PHP-FPM runs as www-data inside the container. Files created by Artisan (storage/logs, bootstrap/cache) may become root-owned on the host. Fix this in your Dockerfile:

RUN groupadd -g 1000 devgroup && useradd -u 1000 -g devgroup devuser
USER devuser

Match UID 1000 to your host user. On Ubuntu dev machines this is usually correct out of the box. For server-side deployment patterns, see Ubuntu server setup and Linux system administration practices.

Environment Variable Drift

Your .env file must use Docker service names as hostnames — not 127.0.0.1. A common mistake is copying a local Valet or XAMPP .env into a Compose project unchanged. The app container cannot reach 127.0.0.1:3306 because that points to itself, not the MySQL container.

Use a .env.example with Compose-ready defaults. Document the difference clearly in your README. Validate JSON config files with a JSON formatter before committing API fixture data.

Missing Health Checks and Race Conditions

Without health checks, the app container starts before MySQL accepts connections. Laravel throws SQLSTATE[HY000] [2002] Connection refused. The fix is depends_on with condition: service_healthy, as shown in the YAML above. Never use sleep 10 in entrypoint scripts — it is unreliable and slows every startup.

Resource Hogging

MySQL and Elasticsearch containers can consume all available RAM on a 8 GB laptop. Set limits in Compose:

  db:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

See limiting Docker container resources for tuning guidance. On budget laptops common in Nepal (Rs 80,000–120,000, ~USD 600–900), resource limits prevent Docker from freezing the host OS.

Compose Overrides Decision TreeNeed extra services?Mailpit for emailprofile: mailMeilisearchprofile: searchNode for Viteprofile: frontendcompose.override.ymlAuto-loaded, gitignoredcompose.prod.ymlCI: -f flag explicitdocker compose --profile mail --profile search up -dStart only the services your task needs
Using Docker Compose profiles and override files to activate optional services only when needed

How Do You Extend a Multi-Container Setup for Real Projects?

Basic stacks cover CRUD apps. Production-bound local environments need mail catching, search engines, queue workers, and sometimes a separate Node container for Vite 8.x builds.

Compose Profiles for Optional Services

Profiles let you define services that only start when requested. A mail catcher avoids sending real emails during development:

  mailpit:
    image: axllent/mailpit:latest
    profiles: ["mail"]
    ports:
      - "8025:8025"
      - "1025:1025"
    networks:
      - app-network

Start with: docker compose --profile mail up -d. Full details in Docker Compose profiles and overrides.

Queue Workers as Separate Services

Run a dedicated worker container instead of remembering to start queue:work manually:

  queue:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    command: php artisan queue:work --sleep=3 --tries=3
    volumes:
      - ./:/var/www/html
    networks:
      - app-network
    depends_on:
      - app
      - redis

This mirrors how I structure API development projects that process webhooks and payment callbacks asynchronously.

Adding Node.js for Frontend Builds

If your host does not have Node.js 26 LTS installed, add a Node service or use a multi-stage approach. A dedicated Node container keeps the PHP image lean:

  node:
    image: node:26-alpine
    profiles: ["frontend"]
    working_dir: /var/www/html
    volumes:
      - ./:/var/www/html
    command: sh -c "npm install && npm run dev"
    ports:
      - "5173:5173"
    networks:
      - app-network

For Vue or Alpine frontends paired with Laravel Blade, see the Vue with Laravel setup guide. On legal-tech portals like Mijar Law Associates, I run the same Compose base across sister sites with only .env differences.

Parity with Production

Local Compose should mirror production topology, not production hardware. If production runs Apache + PHP-FPM on Ubuntu 24 with MySQL 8.4 LTS, match those versions locally. If production uses Docker on Ubuntu with Deployer 7, your Compose file becomes the reference spec for the production Dockerfile.

Do not run Docker in production on a single VPS unless you have orchestration requirements. For most custom software projects I deliver, production stays on bare PHP-FPM with Compose reserved for local dev and CI. The official Docker Compose documentation and Compose networking guide are the authoritative references for syntax changes.

Key Takeaways

  • Define each service (PHP-FPM, Nginx, MySQL, Redis) in one docker-compose.yml with explicit networks and volumes.
  • Use service hostnames (db, redis) in .env — never 127.0.0.1 from inside containers.
  • Bind-mount application code for live reload; use named volumes for database persistence.
  • Add health checks and depends_on conditions to prevent startup race errors.
  • Run all CLI commands through docker compose exec to match container PHP and extensions.
  • Use Compose profiles and override files to keep optional services (mail, search, Node) out of the default stack.

People Also Ask

Do I need Docker Desktop to use Docker Compose?

On macOS and Windows, Docker Desktop includes Compose. On Linux, install the Docker Engine and Compose plugin separately (docker compose, not the legacy docker-compose hyphenated binary). Ubuntu 22.04 and 24.04 both support the plugin via Docker's official apt repository.

Can Docker Compose replace my local PHP installation entirely?

Yes, for project work. You only need Docker on the host. All PHP, Composer, and Node versions live inside containers. Some developers keep a host PHP for quick scripts — that is optional, not required.

How do I debug PHP inside a Docker Compose container?

Install Xdebug in your PHP Dockerfile and map port 9003 for step debugging. Point your IDE's path mappings from the host project root to /var/www/html inside the container. Trigger debugging with docker compose exec app php artisan serve or on your Nginx-served pages.

Is Docker Compose suitable for production deployment?

Compose works for small production setups and staging environments. For high-availability production, most teams move to orchestrators or bare-metal PHP-FPM with Deployer. Compose excels at local development parity and CI test environments — which is its primary strength.

Build Your Local Stack and Ship With Confidence

A well-structured Docker Compose multi-container setup for local development removes environment drift and gets new developers productive on day one. Start with the four-service stack in this guide — PHP-FPM, Nginx, MySQL, and Redis — then layer profiles for mail, search, and frontend tooling as your project grows. The same patterns scale from a solo Laravel app to multi-site pipelines like those described in Docker Compose for local Laravel.

If you want help architecting a Compose stack that mirrors your production environment, or migrating an existing team off XAMPP and Valet, get in touch — or browse the portfolio for examples of Laravel systems built with these workflows. For related reading, see local Laravel dev with Sail and Docker and about my development approach.

Frequently Asked Questions

A YAML-defined stack that runs PHP-FPM, Nginx, MySQL, and Redis as separate containers on one shared network. One docker compose up -d command starts the full application environment identically for every developer.

Place docker-compose.yml in your project root with three top-level keys: services, networks, and volumes. Define an app service built from docker/php/Dockerfile targeting PHP 8.5 with extensions like pdo_mysql and redis, an nginx service forwarding FastCGI to app:9000, a db service using mysql:9.7 with a health check, and a redis:8.10-alpine cache service. Wire them on a bridge network called app-network. Bind-mount your source code into /var/www/html and use a named volume for database persistence. Match your Laravel .env hostnames to service names: DB_HOST=db and REDIS_HOST=redis.

On macOS and Windows, yes — Docker Desktop includes Compose. On Linux, install Docker Engine and the Compose plugin separately via Docker's official apt repository on Ubuntu 22.04 or 24.04.

Yes. You only need Docker on the host. All PHP, Composer 2.10, and Node.js 26 LTS versions live inside containers. A host PHP install is optional, not required.

Both run containers, but raw Compose gives full control over every layer and works across Symfony, WordPress, and custom PHP stacks. Sail wraps Compose for Laravel-only projects with faster greenfield setup but less customization. Learn raw Compose first — it teaches Docker fundamentals and produces a compose file you can reuse in CI pipelines without the Sail binary. For mixed teams running WordPress, Magento 2.4.x, and Laravel side by side, a shared Compose pattern pays off quickly. Sail suits greenfield Laravel 13 projects where speed matters more than control.

Port conflicts occur when binding 3306:3306 while MySQL already runs on the host — map to 3307:3306 instead. File permission problems happen when PHP-FPM creates root-owned files on Linux; fix by matching container UID 1000 to your host user in the Dockerfile. Environment variable drift from copying a Valet or XAMPP .env leaves DB_HOST=127.0.0.1, which points to the app container itself, not MySQL. Missing health checks cause SQLSTATE connection refused errors at startup. Resource hogging from MySQL can freeze an 8 GB laptop — set deploy.resources.limits in Compose.

Start everything with docker compose up -d. Install PHP dependencies via docker compose exec app composer install, run migrations with docker compose exec app php artisan migrate, and build frontend assets with docker compose exec app npm install && npm run dev. View logs using docker compose logs -f app. Stop the stack with docker compose down. Reset the database volume with docker compose down -v, which destroys all data — use carefully. On projects I've shipped with Laravel and Livewire booking systems, wrapping these into a Makefile reduced onboarding from half a day to twenty minutes.

Inside a container, 127.0.0.1 refers to that container itself, not the MySQL or Redis service. Compose provides internal DNS so hostname db resolves to the database container on port 3306. A common mistake is copying a local Valet or XAMPP .env unchanged — the app container cannot reach 127.0.0.1:3306. Set DB_HOST=db, REDIS_HOST=redis, and document Compose-ready defaults in .env.example so new developers do not hit connection errors on first boot.

PHP-FPM runs as www-data inside the container. When Artisan creates storage/logs or bootstrap/cache files, they may become root-owned on your host filesystem. Fix this in your Dockerfile by creating a devuser with UID 1000 and GID 1000: RUN groupadd -g 1000 devgroup && useradd -u 1000 -g devgroup devuser, then set USER devuser. On Ubuntu dev machines UID 1000 is usually correct out of the box. This keeps bind-mounted files writable by both the container and your host user without sudo chown after every artisan command.

Without health checks, the app container starts before MySQL accepts connections, causing SQLSTATE[HY000] [2002] Connection refused errors. Add a healthcheck to the db service using mysqladmin ping, then set depends_on with condition: service_healthy on the app service. Never use sleep 10 in entrypoint scripts — it is unreliable and slows every startup. The same pattern applies to redis dependencies using condition: service_started when a full health probe is not configured.

Use Compose profiles for optional services so they stay out of the default stack. Add a mailpit service under profiles: ["mail"] to catch outbound email locally, started with docker compose --profile mail up -d. Run queue workers as a dedicated service with command: php artisan queue:work instead of starting them manually. Add a node:26-alpine service under a frontend profile for Vite 8.x builds when your host lacks Node.js 26 LTS. On legal-tech portals like Mijar Law Associates, I run the same Compose base across sister sites with only .env differences.

Compose works for small production setups and staging environments where a single-server topology is acceptable. For high-availability production, most teams move to orchestrators or bare-metal PHP-FPM deployed with Deployer 7. Compose excels at local development parity and CI test environments. For most custom software projects I deliver, production stays on bare PHP-FPM with Compose reserved for local dev and CI — do not run Docker on a single VPS unless you have genuine orchestration requirements.

Install Xdebug in your PHP Dockerfile and map port 9003 for step debugging. Point your IDE path mappings from the host project root to /var/www/html inside the container. Trigger debugging on Nginx-served pages or via docker compose exec app php artisan serve. This keeps breakpoints working against the exact PHP 8.5 version and extensions running in the app container, eliminating mismatches between host and container runtimes that cause false debugging results.

Bind mounts sync application source code instantly between host and container — use them for your project directory so file changes reflect without rebuilding the image. Named volumes persist database files across container restarts and docker compose down — use them for db-data mounted at /var/lib/mysql. Never bind-mount database directories from the host unless you need direct file access for backups. This split keeps code editable on your laptop while database state survives container recreation.

Binding 3306:3306 fails if MySQL already runs on your laptop. Map to a non-standard host port instead, such as 3307:3306. Access from host tools like TablePlus via 127.0.0.1:3307. Inside the Compose network, services still connect on port 3306 using the hostname db — only the host-side mapping changes. The same approach applies to Nginx on 8080:80 when port 80 is occupied. Document alternate ports in your README so teammates do not assume defaults.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: