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 Apps

By Kokil Thapa | Last reviewed: September 2026

Docker Compose: Multi-Container Apps turn a folder of services into one runnable system. You declare PHP, MySQL, Redis, and a web server in a single compose.yaml file. Then one command starts the whole stack. That pattern matches how real apps run in production, but on your laptop. If you already use Docker Compose for local Laravel development, this guide goes deeper on wiring, persistence, and the mistakes that break teams after the first docker compose up.

What is Docker Compose for multi-container apps?

Docker Compose is a tool that reads a YAML manifest and orchestrates several containers as one project. Each service gets its own image, environment, ports, and dependencies. Compose creates a default bridge network so containers reach each other by service name, not by IP address.

A typical Laravel app you containerize from scratch needs at least four moving parts: the PHP application, a database, a cache layer, and a reverse proxy. Without Compose, you start four separate docker run commands and manually link networks. With Compose, the file is the contract. New developers clone the repo, run one command, and get the same stack you use.

Multi-Container App StackNginxPort 80 / 443PHP-FPMLaravel 13MySQL 9.7Persistent DBRedis 8.10Cache / Queuecompose.yaml Project NetworkDNS: mysql, redis, app resolve by service nameNamed volumes survive container restarts
Docker Compose multi-container apps: Laravel, Nginx, MySQL, and Redis on one shared network

Compose v2 is a Docker CLI plugin. You invoke it as docker compose, not the legacy standalone docker-compose binary. The official Docker Compose documentation covers the full command set. For day-to-day work, you need up, down, ps, logs, and exec.

How do you write a compose.yaml for a Laravel stack?

Start with a minimal but complete file. Place it at the project root next to your application code. Use service names that match what your app expects in .env.

Base compose.yaml structure

name: laravel-stack

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    volumes:
      - .:/var/www/html
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - .:/var/www/html:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

  mysql:
    image: mysql:9.7
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: laravel
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:8.10-alpine
    volumes:
      - redis_data:/data

volumes:
  mysql_data:
  redis_data:

Notice three design choices that save hours later. First, DB_HOST: mysql uses the service name, not 127.0.0.1. Inside the app container, localhost points to PHP itself. Second, depends_on with a health check stops Laravel from booting before MySQL accepts connections. Third, named volumes keep database files when you rebuild images.

On projects I maintain, PHP 8.3 or 8.4 images are still common for Laravel 12. Laravel 13 needs PHP 8.3 minimum. Pin your Dockerfile base tag to match your framework version. A mismatch here produces silent extension errors or Composer platform failures.

Environment file handling

Compose can load variables from .env in the project directory automatically. Keep secrets out of Git. Use env_file: .env on the app service, or map individual keys with ${DB_PASSWORD} substitution. This mirrors the Symfony environment config pattern for multi-environment apps, but scoped to containers.

How do you start and manage a Docker Compose stack?

The daily workflow is short. Build images when Dockerfiles change. Recreate containers when compose config changes. Inspect logs when something fails after a teammate pulls main.

  1. Install Docker Engine and the Compose plugin on Ubuntu or your host OS. See the Docker install guide for Ubuntu if you are starting fresh.
  2. From the project root, run docker compose up -d --build to build and detach.
  3. Run docker compose exec app php artisan migrate for first-time database setup.
  4. Check status with docker compose ps and tail logs with docker compose logs -f app.
  5. Stop everything with docker compose down. Add -v only when you intend to wipe named volumes.
Compose Lifecyclecompose.yamlDefine servicesdocker composebuild + up -dHealth checksWait for DBStack readycompose psCommon Commandslogs -f app | exec app bash | down | pullRebuild one service: docker compose up -d --build appNever run down -v on shared dev DB volumes
Starting Docker Compose multi-container apps: from YAML definition to a healthy running stack

The multi-container setup for local development article covers bind mounts and IDE integration. Bind-mounting your source tree into the app container gives instant code reloads. Named volumes for MySQL stay on the host disk outside the container filesystem.

If a service exits immediately, run docker compose logs mysql before guessing. Permission errors on storage/ and wrong DB_HOST values cause most first-day failures I see on client projects.

How do Docker Compose networks and volumes work?

Every Compose project gets a default network named {project}_default. Containers join it automatically. DNS entries map service names to container IPs. Your Laravel .env should use mysql and redis as hostnames, exactly as declared under services:.

Custom networks isolate stacks. Two projects on one machine can both expose port 8080 if only one publishes it to the host. Internal traffic stays on the bridge. Read the dedicated Docker networking and volumes guide for bridge vs overlay details.

Volume types you will actually use

  • Named volumes — best for database data. Survive docker compose down without -v.
  • Bind mounts — map host paths into containers. Ideal for application code during development.
  • Anonymous volumes — created implicitly. Easy to lose track of. Prefer explicit names.

For PostgreSQL instead of MySQL, swap the image to postgres:18 and adjust health checks. The PostgreSQL in Docker for development walkthrough shows the env vars and init scripts.

Networks and Volumesapp containerDB_HOST=mysqlmysql serviceInternal DNS namemysql_dataNamed volumeBind mount: ./:/var/www/html (dev code sync)Named volume: survives container rebuildHost port 3306 publish only when needed
Service DNS and named volumes in Docker Compose multi-container apps

How does Docker Compose compare to Laravel Sail and raw docker run?

Teams often ask whether to adopt Sail, hand-written Compose, or plain Docker commands. The answer depends on who maintains the stack and where it runs.

ApproachBest forTrade-off
Docker Compose (custom)Full control, production-like local stacks, mixed services (workers, Mailpit, MinIO)You own every Dockerfile and upgrade path
Laravel SailLaravel-only projects with standard defaultsLess flexible for non-PHP sidecars; see Laravel Sail vs Docker Compose
docker run scriptsQuick one-off containers, CI smoke testsNo declarative manifest; hard to reproduce across machines
KubernetesProduction at scale, multi-node clustersHeavy for local dev; overkill for a two-person agency team

For a booking platform like Adventure Third Pole Trek, I prefer explicit Compose files. They document queue workers, scheduler containers, and Redis in one place. Sail is fine when the stack matches Laravel defaults and nobody needs custom Nginx rules.

Compose is not a production orchestrator on its own. It works on a single host for staging or small deployments. For zero-downtime multi-server setups, you eventually move to Swarm or Kubernetes. The Kubernetes vs Docker Swarm comparison helps frame that decision.

How do you harden Docker Compose for staging and production?

Local Compose files optimize for speed. Production files optimize for immutability, secrets, and resource limits. Treat them as siblings, not copies.

Production-oriented patterns

Build images in CI and push to a registry. Production Compose should reference tagged images, not build: contexts on the server. Follow multi-stage Docker builds for Laravel production to keep images small.

services:
  app:
    image: registry.example.com/laravel-app:2026.09.1
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    read_only: true
    tmpfs:
      - /tmp

Set CPU and memory limits so one runaway queue worker cannot starve the database. The guide to limiting Docker container resources explains cgroups behaviour on Linux hosts.

Use Compose profiles and override files to split dev tools from production services. A compose.override.yaml can add Mailpit locally while production omits it entirely. Never commit production secrets. Inject them from your host environment or a secrets manager at deploy time.

On Ubuntu servers I administer for clients, Compose stacks sit behind host-level Nginx or Traefik with TLS from Let's Encrypt. That mirrors patterns in our Linux system administration service, where PHP-FPM reload and volume backups are part of the same contract as the app code.

Dev vs Production ComposeSame app, two manifestsLocal compose.yamlBind mounts, Mailpit, Xdebugbuild: context on laptopProduction composePinned images, no source mountLimits, restart policy, secretsCI builds image, Compose pulls tag on serverMatches twelve-factor config via env vars
Split Docker Compose multi-container apps into development and production manifests

Health checks matter in production. Without them, a reverse proxy may route traffic to a PHP container that booted before MySQL was ready. Define checks on every critical dependency. The Compose file healthcheck reference lists supported test formats.

Worker and scheduler services

Real Laravel apps need more than web and database containers. Add a queue worker and a scheduler as separate services sharing the same app image.

  queue:
    image: registry.example.com/laravel-app:2026.09.1
    command: php artisan queue:work --sleep=3 --tries=3
    depends_on:
      - redis
      - mysql
    restart: unless-stopped

  scheduler:
    image: registry.example.com/laravel-app:2026.09.1
    command: php artisan schedule:work
    depends_on:
      - mysql
    restart: unless-stopped

One image, three roles — web, queue, scheduler. That is the same pattern I use on production Laravel deployments before GitLab CI pushes a release. It aligns with the twelve-factor app principles revisited for 2026: config in environment, processes as containers, logs to stdout.

What are common Docker Compose mistakes on real projects?

Most failures are configuration, not Docker bugs. These show up repeatedly when a team adopts Compose after months of native PHP on the host.

  • Using 127.0.0.1 for database host — wrong inside containers. Use the service name.
  • Publishing MySQL port 3306 on production — exposes the database to the LAN or internet. Keep it internal.
  • Running docker compose down -v casually — deletes named volumes and wipes local data.
  • Omitting platform pins on Apple Silicon — add platform: linux/amd64 when images lack arm64 builds.
  • Mixing Compose file versions incorrectly — modern Compose uses the Compose Specification; avoid obsolete version: "3.8" keys unless tooling requires them.
  • Ignoring file permissions on bind mounts — PHP may not write to storage/. Match UID/GID in the Dockerfile or fix ownership on the host.

Validate YAML before pushing. Paste your manifest into the JSON and YAML formatter tool or run docker compose config to render the merged result. That command catches typos in service names and invalid keys early.

For WordPress or WooCommerce stacks, the same Compose principles apply. Separate MariaDB 12.3, PHP, and Nginx. Pin WooCommerce 11.1 against WordPress 7.1 in documentation for your team. Our WordPress development service often starts with a reproducible local Compose stack before any theme work.

When you outgrow a single server, Compose files become documentation for what Kubernetes Deployments must recreate. Treat the service list as the source of truth for your architecture review. Enterprise teams sometimes engage us through enterprise application development once Compose-proven stacks need HA clustering.

Key Takeaways

  • Define every service, network, and volume in compose.yaml so the full stack starts with docker compose up -d.
  • Point application config at service names (mysql, redis), not localhost, inside containers.
  • Use named volumes for database persistence and bind mounts only where hot reload matters.
  • Add health checks and resource limits before calling a Compose stack production-ready.
  • Split dev and prod manifests with profiles or override files instead of one bloated YAML file.
  • Build and tag images in CI; production Compose should pull immutable tags, not build on the server.

People Also Ask

Can Docker Compose run in production?

Yes, on a single Linux host with proper secrets, restart policies, health checks, and resource limits. Compose is not a cluster scheduler. For multi-node HA you move to Kubernetes or Swarm, but many small and mid-size apps run reliably on one VPS with Compose for years.

How many containers should one Compose project have?

As many as your architecture needs, typically four to eight for a Laravel app: web proxy, PHP, database, cache, queue worker, scheduler, and optional dev tools. If the file exceeds a dozen services, consider profiles or splitting into separate projects linked by external networks.

Do I still need a .env file with Docker Compose?

Yes. Compose loads a project .env for variable substitution in YAML. Application containers also need runtime env vars for Laravel or Symfony. Keep secrets out of Git and inject them on the server at deploy time.

What is the difference between docker compose and docker-compose?

docker compose is the current V2 plugin integrated into the Docker CLI. docker-compose is the legacy Python binary. New projects should use V2 syntax and commands exclusively.

Ship your next stack with confidence

Docker Compose: Multi-Container Apps give you a repeatable contract between developers, CI, and staging. Start with a honest service list, add health checks, and split dev from production configs before the file grows messy. If you want help dockerizing a Laravel, Symfony, or WordPress system for your team in Nepal or abroad, review our portfolio of shipped platforms or reach out through contact us to plan your stack. For ongoing server work after launch, see support and maintenance and the broader custom software development offering on kokil.com.np.

Frequently Asked Questions

Docker Compose reads a YAML manifest and orchestrates several containers as one project. Each service gets its own image, environment, ports, and dependencies. Compose creates a default bridge network so containers reach each other by service name instead of IP. For a Laravel stack, that typically means PHP, Nginx, MySQL, and Redis declared in one compose.yaml file. One docker compose up -d command starts the full stack with shared DNS and isolated storage, giving every developer the same reproducible environment.

Place compose.yaml at the project root next to your application code. Define services for app, nginx, mysql, and redis with names matching what Laravel expects in .env. Set DB_HOST to mysql and REDIS_HOST to redis, not localhost. Use build context for the PHP container and pin images like nginx:1.27-alpine, mysql:9.7, and redis:8.10-alpine. Add named volumes for mysql_data and redis_data so database files survive rebuilds. Use depends_on with a MySQL healthcheck so Laravel does not boot before the database accepts connections.

docker compose is the current V2 plugin integrated into the Docker CLI. docker-compose is the legacy standalone Python binary. New projects should use V2 syntax and commands exclusively.

Inside a container, localhost points to that container itself, not the host machine or sibling containers. If Laravel sets DB_HOST to 127.0.0.1, PHP looks for MySQL inside its own filesystem and fails. Compose DNS maps service names like mysql and redis to container IPs on the project network. Your .env and compose environment block should use those exact service names. Wrong DB_HOST values are among the most common first-day failures on client projects adopting Compose after native PHP development.

Every Compose project gets a default network named {project}_default. Containers join automatically and resolve each other by service name. Custom networks can isolate two stacks on one machine so both can use port 8080 internally while only one publishes it to the host. Named volumes persist database data across docker compose down without the -v flag. Bind mounts map host paths into containers and suit application code during development for instant reloads. Prefer explicit named volumes over anonymous ones you might lose track of.

From the project root, run docker compose up -d --build to build images and start detached. Run docker compose exec app php artisan migrate for first-time database setup. Check status with docker compose ps and inspect failures with docker compose logs -f app or docker compose logs mysql. Stop everything with docker compose down, adding -v only when you intend to wipe named volumes. Rebuild when Dockerfiles change and recreate containers when compose config changes. Validate merged YAML with docker compose config before pushing to catch typos early.

Yes, on a single Linux host with secrets, restart policies, health checks, and resource limits. Compose is not a cluster scheduler; multi-node HA needs Kubernetes or Swarm.

Custom Docker Compose suits teams needing full control, production-like local stacks, and mixed services such as queue workers, Mailpit, or MinIO. You own every Dockerfile and upgrade path. Laravel Sail works for Laravel-only projects with standard defaults but is less flexible for custom Nginx rules or non-PHP sidecars. Raw docker run scripts suit one-off containers or CI smoke tests but lack a declarative manifest and are hard to reproduce across machines. Kubernetes fits production at scale but is heavy for local dev or small agency teams.

Using 127.0.0.1 for database host instead of the service name. Publishing MySQL port 3306 on production and exposing the database. Running docker compose down -v casually and wiping named volumes. Omitting platform pins on Apple Silicon when images lack arm64 builds; add platform linux/amd64. Mixing obsolete version keys when modern Compose uses the Compose Specification. Ignoring bind mount permissions so PHP cannot write to storage. If a service exits immediately, check docker compose logs before guessing. Permission errors and wrong DB_HOST cause most first-day failures.

Yes. Compose loads project .env for YAML variable substitution, and application containers need runtime env vars for Laravel or Symfony. Keep secrets out of Git.

As many as your architecture needs. A typical Laravel app runs four to eight containers: web proxy, PHP, database, cache, queue worker, scheduler, and optional dev tools. Real apps often need worker and scheduler services sharing the same app image alongside web and database containers. If the file exceeds a dozen services, use Compose profiles or split into separate projects linked by external networks rather than one bloated manifest.

Build images in CI and push tagged images to a registry; production Compose should pull immutable tags, not build on the server. Set restart unless-stopped, CPU and memory limits, read_only root filesystems, and tmpfs for writable paths like /tmp. Never commit production secrets; inject them from the host environment or a secrets manager at deploy time. Add health checks on every critical dependency so a reverse proxy does not route to PHP before MySQL is ready. Split dev tools from production using profiles or compose.override.yaml.

Without health checks, Laravel can boot before MySQL accepts connections and fail silently or crash on first query. The article recommends depends_on mysql with condition service_healthy and a mysqladmin ping test on mysql:9.7 with interval, timeout, and retry settings. Redis can use condition service_started since it becomes available faster. Health checks matter in production too, where a reverse proxy might route traffic to a PHP container that started before its database dependency was actually ready to serve requests.

Use named volumes for database data because they survive docker compose down without the -v flag and keep MySQL or Redis files on the host disk outside the container filesystem. Use bind mounts for application code during development so code changes reload instantly without rebuilding images. The article maps the project root into the app container and mounts nginx config read-only. Anonymous volumes are easy to lose track of; prefer explicit names like mysql_data and redis_data declared under the top-level volumes key.

Local Compose files optimize for speed with bind mounts and dev tools like Mailpit. Production files optimize for immutability, secrets, and resource limits. Treat them as siblings, not copies. Use Compose profiles and compose.override.yaml to add local-only services while production omits them entirely. Production should reference registry.example.com tagged images with restart policies and resource limits, not build contexts on the server. On Ubuntu servers, Compose stacks often sit behind host-level Nginx or Traefik with TLS from Let's Encrypt, matching patterns used for staging and small VPS deployments before moving to Kubernetes or Swarm.

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: