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 for Local Laravel Dev Environment

By Kokil Thapa | Last reviewed: August 2026

Setting up a consistent Docker Compose for local Laravel dev environment eliminates the "works on my machine" problem that plagues team development and solo projects alike. Instead of wrestling with Homebrew conflicts, mismatched PHP extensions, or global Composer pollution, you define your entire stack—PHP 8.4, Nginx, MySQL 8.4, Redis—in declarative YAML and spin it up with one command. This guide provides a battle-tested configuration I use daily for Laravel development, optimized for Laravel 12.x performance and realistic production parity.

How do you structure Docker Compose for local Laravel dev environment services?

The most common mistake in containerized Laravel setups is cramming everything into a single monolithic container. In practice, separating concerns mirrors how you deploy to production (where I typically use Apache/Nginx + PHP-FPM on Ubuntu servers) and makes debugging significantly easier. For a functional Docker Compose for local Laravel dev environment, you need four distinct services communicating over an internal Docker network.

Laravel Docker Service TopologyNginx (Web)Port 80 → HostStatic AssetsReverse ProxyPHP 8.4 FPMApp Code MountComposer DepsXdebug ReadyMySQL 8.4Port 3306Named VolumePersistent DataRedis 7.4Cache / SessionsQueue DriverPub/Sub ReadyFastCGITCP 3306TCP 6379
Service topology for Docker Compose for local Laravel dev environment: Nginx proxies to PHP-FPM, which connects to MySQL and Redis over an isolated Docker network.

This separation gives you independent lifecycle control. Need to test a different Redis version? Swap only the cache service image tag. Database migration failing? Inspect the db logs without wading through PHP output. On legal-tech portals I've built like Court Marriage In Nepal, this architecture let me replicate staging issues locally within minutes because the container boundaries matched the server process boundaries exactly.

Essential service definitions

Your docker-compose.yml should declare explicit dependencies and health checks. Without health checks, Laravel's container often starts before MySQL accepts connections, causing cryptic "Connection refused" errors during php artisan migrate.

<!-- docker-compose.yml -->
services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    volumes:
      - .:/var/www/html
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      - DB_HOST=db
      - REDIS_HOST=redis
    networks:
      - laravel-net

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

  db:
    image: mysql:8.4-lts
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_ROOT_PASSWORD: secret
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks:
      - laravel-net

  redis:
    image: redis:7.4-alpine
    networks:
      - laravel-net

volumes:
  db-data:

networks:
  laravel-net:
    driver: bridge

Note the named volume db-data. Bind-mounting the host filesystem for MySQL causes permission nightmares on Linux and catastrophic performance degradation on macOS/Windows due to filesystem translation layers. Named volumes keep database I/O inside Docker's optimized storage driver.

What PHP 8.4 Dockerfile configuration does Laravel 12 actually require?

Laravel 12.x requires PHP 8.2 minimum, but PHP 8.4 is the current stable release in 2026 and offers meaningful performance improvements for CPU-bound tasks like serialization and validation. Your Dockerfile must install the exact extensions Laravel expects—missing bcmath or intl will cause silent failures in currency formatting or locale handling, problems I've debugged repeatedly on eCommerce projects like Nepal Gift Card.

Base Imagephp:8.4-fpm-bookwormSystem Depslibpng, libzip, icuPHP Extensionspdo_mysql, bcmath, intlComposer 2.7+Global binary installEntrypointphp-fpm foregroundCritical Extensions Checklist (Laravel 12 + PHP 8.4)✓ pdo_mysql — Eloquent ORM database connectivity✓ mbstring — Unicode string handling (Nepali/Devanagari support)✓ bcmath — Precise currency calculations (eCommerce NPR/USD)✓ intl — Locale-aware formatting, date/time, collation✓ gd / imagick — Image processing for media libraries✓ zip — Composer archive extraction, Excel exports✓ opcache — Production-equivalent bytecode caching
PHP 8.4 Dockerfile build pipeline and mandatory extension checklist for Laravel 12 compatibility in Docker Compose for local Laravel dev environment.
# docker/php/Dockerfile
FROM php:8.4-fpm-bookworm

RUN apt-get update && apt-get install -y \
    git curl zip unzip libpng-dev libjpeg62-turbo-dev \
    libfreetype6-dev libwebp-dev libzip-dev libicu-dev \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
    && docker-php-ext-install -j$(nproc) \
        pdo_mysql \
        mbstring \
        bcmath \
        intl \
        gd \
        zip \
        opcache

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

WORKDIR /var/www/html
USER www-data

EXPOSE 9000
CMD ["php-fpm"]

The -j$(nproc) flag parallelizes extension compilation, cutting build time roughly in half on multi-core machines. Always pin the Composer version explicitly; pulling composer:latest can introduce breaking changes mid-project. The www-data user directive prevents file permission conflicts when bind-mounted volumes are written by both the container and your host IDE—a recurring pain point on shared development teams.

Nginx configuration for Laravel routing

Laravel's front controller pattern requires every non-file request to route through index.php. A misconfigured try_files directive causes 404s on every route except the homepage. This is the exact Nginx config I use:

# docker/nginx/default.conf
server {
    listen 80;
    index index.php index.html;
    root /var/www/html/public;
    charset utf-8;

    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;
        fastcgi_buffer_size 32k;
        fastcgi_buffers 16 16k;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The $realpath_root variable resolves symlinks correctly, which matters when you later adopt zero-downtime deployment patterns similar to what I describe in my CI/CD pipeline guide. The buffer size tuning prevents "upstream sent too big header" errors when Laravel returns large JSON responses from API endpoints.

How do you handle file permissions and volume mounts in Laravel Docker containers?

File permission mismatches between host and container are the single most frequent failure mode in Dockerized Laravel development. When your host UID doesn't match the container's www-data UID (typically 33 on Debian-based images), Artisan commands create files owned by root that your IDE can't edit, and vice versa.

  1. Match UIDs at build time. Add ARG USER_ID=1000 and ARG GROUP_ID=1000 to your Dockerfile, then run usermod -u $USER_ID www-data && groupmod -g $GROUP_ID www-data. Pass these from your host: docker compose build --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g).
  2. Create storage directories before mounting. Run mkdir -p storage/framework/{cache,sessions,views} bootstrap/cache on the host before first docker compose up. Missing directories cause Laravel to attempt creation as root inside the container.
  3. Use ACLs on Linux hosts. If UID matching isn't feasible, set default ACLs: setfacl -Rdm u:www-data:rwx storage bootstrap/cache. This grants the container user write access regardless of ownership.
  4. Never mount vendor/ or node_modules/. These directories contain platform-specific binaries. Let Composer and npm install inside the container, or use a separate anonymous volume to shadow the host directory.

On macOS and Windows, Docker Desktop's VirtioFS (macOS) or WSL2 backend (Windows) handles permission translation automatically in 2026, but bind-mount performance remains 3–5× slower than native Linux I/O. For Laravel applications with heavy file operations—like the document workflows in Notary Nepal—I recommend developing inside WSL2 on Windows or using Mutagen for bidirectional sync on macOS rather than direct bind mounts.

How do you configure Xdebug 3 in Docker Compose for local Laravel dev environment?

Xdebug 3 changed configuration fundamentally from version 2. The old xdebug.remote_host directives are ignored silently, leading developers to believe Xdebug is broken when it's simply misconfigured. For Docker Compose for local Laravel dev environment, you need three things: correct INI settings, proper host gateway resolution, and IDE path mapping.

PHP Containerxdebug.mode=debugxdebug.client_host=host.docker.internalxdebug.client_port=9003xdebug.start_with_request=yesHost GatewayDocker DNS Resolutionhost.docker.internal → Host IPLinux: extra_hosts requiredmacOS/Win: AutomaticIDE ListenerVS Code / PhpStormListen Port: 9003Path Mapping ConfiguredBreakpoints ActiveCommon Xdebug 3 Failures & Fixes✗ Using xdebug.remote_* (v2 syntax) → Use xdebug.client_*✗ Missing extra_hosts on Linux → Add host.docker.internal:host-gateway✗ Firewall blocking port 9003 → Allow inbound TCP 9003 on host✗ Wrong path mapping → Map /var/www/html to local project root
Xdebug 3 connection flow and troubleshooting checklist for Docker Compose for local Laravel dev environment debugging sessions.
# docker/php/xdebug.ini (mounted into /usr/local/etc/php/conf.d/)
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.start_with_request=yes
xdebug.log=/tmp/xdebug.log

On Linux hosts, host.docker.internal doesn't resolve by default. Add this to your app service in docker-compose.yml:

extra_hosts:
  - "host.docker.internal:host-gateway"

Enable Xdebug conditionally via environment variable so you're not paying the 20–30% performance penalty during normal development. Override the INI at runtime: docker compose exec app php -d xdebug.mode=off artisan tinker. For browser-triggered debugging, use the Xdebug Helper extension to set the XDEBUG_TRIGGER cookie only when needed.

Docker vs Sail vs Herd: Which local Laravel environment should you choose in 2026?

Laravel Sail simplified Docker adoption enormously, but it's not always the right tool. Understanding the trade-offs prevents costly rework when your project outgrows Sail's assumptions. Here's how they compare for real-world Docker Compose for local Laravel dev environment scenarios:

CriteriaCustom Docker ComposeLaravel SailLaravel Herd
Setup Time2–4 hours initial<10 minutes<5 minutes
Production ParityExact (you control everything)Good (opinionated defaults)Poor (native binaries, not containers)
Multi-PHP VersionsTrivial (separate Dockerfiles)Possible but awkwardBuilt-in toggle
Team ConsistencyGuaranteed (committed YAML)Guaranteed (published stubs)Varies per developer machine
CI IntegrationDirect reuse of compose fileSail supports testingNot applicable
Custom ServicesAdd anything to YAMLPublish & modify stubsLimited to bundled services
Performance (macOS)Moderate (VirtioFS)Moderate (same backend)Fastest (no virtualization)
Learning CurveSteep (Docker fundamentals)Gentle (Laravel-centric)Minimal (GUI-driven)

Choose custom Docker Compose when your production infrastructure uses containers, you need services Sail doesn't bundle (Elasticsearch, MinIO, custom queues), or you're building multi-tenant SaaS applications where environment isolation matters. Choose Sail for greenfield Laravel projects with standard stacks and teams new to containerization. Choose Herd for quick prototyping, WordPress work alongside Laravel, or when you're solo and value speed over parity.

In my experience maintaining multiple client projects simultaneously—including legal-tech platforms with strict compliance requirements and eCommerce sites needing Elasticsearch—custom Docker Compose pays its setup cost within weeks. The ability to version-control the exact runtime environment means onboarding a new developer takes cloning a repo and running docker compose up, not following a wiki page that hasn't been updated since PHP 8.1.

Optimizing Docker Compose for local Laravel dev environment performance

Container overhead is real, but most slowness comes from misconfiguration rather than inherent Docker limitations. Apply these optimizations in order of impact:

  • Enable OPcache with JIT. PHP 8.4's JIT compiler provides 10–25% throughput improvement for compute-heavy Laravel middleware. Add opcache.jit=1255 and opcache.jit_buffer_size=256M to your php.ini. Verify with php -r "print_r(opcache_get_status());".
  • Use tmpfs for cache and sessions. Add tmpfs: [/tmp, /var/www/html/storage/framework/cache] to your app service. RAM-backed filesystems eliminate disk I/O for ephemeral data that doesn't need persistence across restarts.
  • Exclude vendor/ from bind mounts on macOS. Use an anonymous volume overlay: add - vendor-data:/var/www/html/vendor to volumes and declare vendor-data: under top-level volumes. Composer installs happen at native container speed instead of crossing the VirtioFS boundary.
  • Set COMPOSER_HOME to tmpfs. Prevents repeated filesystem traversal for package metadata: environment: [COMPOSER_HOME=/tmp/composer].
  • Use BuildKit cache mounts. In your Dockerfile, replace RUN composer install with RUN --mount=type=cache,target=/root/.composer composer install. Subsequent builds reuse downloaded packages even after Dockerfile changes invalidate the layer cache.

For Laravel applications serving significant traffic—even locally during load testing—these optimizations routinely cut response times by 40–60%. On a recent e-commerce build handling complex cart calculations, enabling JIT and tmpfs brought average API response from 180ms down to 95ms inside the same container.

Next Steps for Your Laravel Docker Workflow

A properly configured Docker Compose for local Laravel dev environment transforms development from environment management into actual feature work. Start with the four-service architecture above, validate permissions before writing application code, and enable Xdebug only when actively debugging. As your project matures, extend the compose file with Mailpit for email testing, Meilisearch for full-text search, or MinIO for S3-compatible object storage—all without touching your host machine.

If you're setting up Docker for a team, need help migrating an existing Laravel application to containers, or want to align your local environment with production infrastructure, reach out directly. I've helped Nepal-based agencies and international clients standardize their Laravel development workflows, reducing onboarding time from days to hours and eliminating environment-related bug reports entirely.

Frequently Asked Questions

Docker Compose V2 (integrated into Docker Desktop or docker-compose-plugin) is required. The legacy standalone v1 binary is deprecated and lacks support for modern features like watch mode and improved networking used in current Laravel Sail and custom stacks.

Native Valet is faster for file I/O, but Docker offers environment parity with production. On Apple Silicon Macs using Laravel Sail, bind mounts can be slow; enabling VirtioFS or Mutagen sync reduces latency significantly. For Windows users, WSL2 with Docker is mandatory for acceptable performance, as native Windows filesystem access through Docker is prohibitively slow for framework-heavy applications like Laravel.

Yes, by assigning unique project names and mapping different host ports. In your compose.yaml, change the web service port from 80:80 to 8081:80 for the second project. Alternatively, use a reverse proxy like Traefik or Nginx Proxy Manager to route based on domain name, avoiding manual port management entirely when running five or six client projects concurrently during development.

Container processes typically run as root, creating files owned by UID 0 that your host user cannot edit. Fix this by adding a user directive matching your host UID/GID in the PHP-FPM or CLI service definition, or configure Laravel Sail to use the www-data user mapped to your local ID. Always check ownership with ls -la storage/ after container restarts to prevent cache and log write failures.

It depends on RAM. Docker Desktop consumes 4GB+ minimum. For laptops with 8GB RAM, native Valet or LAMP is more practical. Teams with 16GB+ can use Docker effectively. I have seen Nepali agencies standardize on Docker only after upgrading workstations; forcing it on underpowered machines kills productivity. Budget Rs 60,000–80,000 (~USD 450–600) per developer for adequate hardware before mandating containerized workflows.

Define a named volume in the volumes section of your database service, such as db_data:/var/lib/mysql. Never rely on bind-mounting the host directory for MySQL data files due to permission conflicts and filesystem incompatibilities. Named volumes survive container removal and recreation. Always include this in your compose.yaml; otherwise, every docker compose down destroys your development database silently.

Xdebug needs explicit host configuration because localhost inside the container refers to the container itself, not your IDE. Set xdebug.client_host=host.docker.internal for Docker Desktop or your LAN IP for Linux. Ensure port 9003 is open and your IDE listens on all interfaces. Add xdebug.log=/tmp/xdebug.log to diagnose connection failures. Misconfigured client_host accounts for nearly all debugging issues I encounter in production-like local setups.

Use Sail for new projects or standard stacks; it abstracts complexity and stays updated with Laravel releases. Write custom Compose files when you need specific extensions, non-standard services, or multi-stage builds Sail does not support. On legal-tech portals requiring custom PDF libraries or legacy SOAP integrations, I maintain custom configurations. For typical eCommerce or API projects, Sail reduces maintenance burden significantly.

Run Vite or Mix in a separate service rather than inside the PHP container. Use Docker Compose profiles to start the node service only when needed. Mount the source directory read-write and output compiled assets to a shared volume or dist folder. This prevents Node memory spikes from affecting PHP-FPM workers. For Livewire or Vue-heavy frontends, keeping build tools isolated improves both reliability and developer experience.

Default Docker memory limits are often insufficient for Composer dependency resolution. Increase the PHP memory_limit in your Dockerfile or runtime config to at least 2G. Alternatively, set COMPOSER_MEMORY_LIMIT=-1 as an environment variable in your CLI service. Large Laravel projects with dozens of packages regularly exceed 512MB during installation. This is a common gotcha when migrating from native environments where system RAM was implicitly available.

Profile each service initialization using docker compose logs --timestamps. Slow startups usually stem from unoptimized base images, missing layer caching, or health checks with aggressive intervals. Use official slim PHP images, cache Composer dependencies via volume mounts, and increase health check start_period values. On one project, reducing MySQL readiness probe frequency cut startup from 45 seconds to 12 seconds. Avoid installing unnecessary system packages in production-style dev images.

Yes, expose the MySQL port to localhost in your compose.yaml using ports: "3306:3306". Connect your GUI tool to 127.0.0.1:3306 with credentials from your .env file. Be cautious about exposing database ports on shared networks; restrict binding to 127.0.0.1 explicitly to prevent external access. For teams collaborating remotely, consider SSH tunneling instead of direct port exposure to maintain security while allowing database inspection during development.

Store secrets in a .env file referenced via env_file directive, never hardcoded in compose.yaml. Add .env to .gitignore and provide a .env.example template with safe defaults. Use Docker secrets or external vault integration for sensitive keys in CI pipelines. On client projects, I enforce this separation strictly; committed credentials cause breaches. Rotate any accidentally exposed keys immediately and audit git history with tools like trufflehog.

Services must reference each other by service name defined in compose.yaml, not localhost. If redis is your service name, configure REDIS_HOST=redis in Laravel’s .env. Ensure both services share the same network; Docker Compose creates one automatically but custom networks require explicit attachment. Connection refused errors almost always indicate hostname misconfiguration or missing network membership. Verify connectivity with docker compose exec php ping redis before troubleshooting application-level cache failures.

Abandon it if your hardware cannot sustain acceptable performance despite optimization, if your team lacks container debugging skills and training budget is unavailable, or if project timelines cannot absorb the initial setup tax. For simple brochure sites or rapid prototypes targeting Nepali SMBs with tight budgets, native stacks deliver faster. Docker adds value through consistency and deployment parity, but only when infrastructure supports it reliably. Do not force tooling that hinders delivery.

Share this article

Quick Contact Options
Choose how you want to connect me: