← Back to blog
DevOps Jan 2, 2026 · 6 min read

Docker Compose for Local Parity Across a Java, PHP, and Node Stack

Docker Compose for Local Parity Across a Java, PHP, and Node Stack

The problem with "works on my machine"

Building a multi-service financial platform means coordinating multiple runtime environments. Our system architecture relies on three core applications written in distinct languages, each serving a specialized purpose:

  • Java 21 Matching Engine: A high-throughput Spring Boot service that ingests order flows, executes matching algorithms, and speaks low-latency FIX and ITCH exchange protocols.
  • PHP 8.4 (Laravel 12) Core API: The primary backend handling account authentication, RESTful order placement, user billing, database persistence, and audit logging.
  • Node.js 22 WebSocket Gateway: An event-driven microservice responsible for establishing persistent client connections and broadcasting real-time market data updates.

Before containerizing our development workflow, running this polyglot stack locally required every engineer to manually install and configure three language runtimes, matching PostgreSQL 17 database extensions, Redis 7.4, and a RabbitMQ 4 broker directly on their host operating system.

Small discrepancies quickly caused friction. A developer running Java 17 locally would miss language feature deprecations that broke in our Java 21 production environment. Missing PHP extensions or different Redis client drivers caused subtle bugs that only surfaced after code reached staging pipelines. New team members spent their first day reading multi-page setup documentation, manually executing database migrations, and configuring local queue exchanges.

Designing a production-identical Compose topology

To eliminate environment drift, we designed a unified docker-compose.yml topology that provisions all application services and backing infrastructure components using the exact base container images deployed in production.

graph TD
    Client["Developer Workstation"] --> Nginx["Nginx Reverse Proxy"]
    Nginx --> Laravel["Laravel 12 API (PHP 8.4)"]
    Nginx --> Node["WebSocket Gateway (Node.js 22)"]
    Laravel --> Java["Matching Engine (Java 21 / FIX)"]
    Laravel --> Postgres[("PostgreSQL 17")]
    Laravel --> Redis[("Redis 7.4")]
    Laravel --> RabbitMQ[("RabbitMQ 4")]

Docker Compose Local Developer Workstation Architecture

All services communicate across an isolated internal bridge network (trading_net), allowing microservices to resolve each other by container name (http://laravel-api:8000, amqp://rabbitmq:5672, redis:6379) exactly as they do via service discovery in our production Kubernetes clusters.

Environment variables are standardized through a tracked .env.example file copied to .env.local during setup. This guarantees that database credentials, queue virtual hosts, and API keys remain consistent across all development machines without leaking production credentials.

version: '3.8'

networks:
  trading_net:
    driver: bridge

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: oms_local
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: secret_password
    ports:
      - "5432:5432"
    networks:
      - trading_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev -d oms_local"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7.4-alpine
    ports:
      - "6379:6379"
    networks:
      - trading_net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  rabbitmq:
    image: rabbitmq:4-management-alpine
    ports:
      - "5672:5672"
      - "15672:15672"
    networks:
      - trading_net
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  matching-engine:
    build:
      context: ./services/matching-engine
      dockerfile: Dockerfile.dev
    environment:
      SPRING_PROFILES_ACTIVE: local
      SPRING_RABBITMQ_HOST: rabbitmq
      SPRING_REDIS_HOST: redis
    volumes:
      - ./services/matching-engine:/app
      - ~/.m2:/root/.m2
    networks:
      - trading_net
    depends_on:
      rabbitmq:
        condition: service_healthy
      redis:
        condition: service_healthy

  laravel-api:
    build:
      context: ./services/api
      dockerfile: Dockerfile.dev
      args:
        WWWUSER: ${WWWUSER:-1000}
        WWWGROUP: ${WWWGROUP:-1000}
    environment:
      APP_ENV: local
      DB_HOST: postgres
      REDIS_HOST: redis
      RABBITMQ_HOST: rabbitmq
    volumes:
      - ./services/api:/var/www/html
    ports:
      - "8000:8000"
    networks:
      - trading_net
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy

  ws-gateway:
    build:
      context: ./services/ws-gateway
      dockerfile: Dockerfile.dev
    environment:
      NODE_ENV: development
      REDIS_HOST: redis
    volumes:
      - ./services/ws-gateway:/app
      - /app/node_modules
    ports:
      - "3000:3000"
    networks:
      - trading_net
    depends_on:
      redis:
        condition: service_healthy

Solving volume mount permission friction on Linux

Mounting host source code directories into containers is essential for live code reloading during development. However, on Linux workstations, this introduced a frustrating file permission issue.

When processes running inside a container as root generate files — such as Laravel's storage/framework/views, log files, or compiled frontend assets — those files are created with root:root ownership on the host filesystem. Consequently, developers on host machines could not edit, format, or clean up those files without using sudo.

We solved this by injecting the host developer's User ID (UID) and Group ID (GID) as build arguments during image construction:

# Dockerfile.dev for PHP/Laravel
FROM php:8.4-fpm

ARG WWWUSER=1000
ARG WWWGROUP=1000

# Create application group and user matching host UID/GID
RUN groupadd --force -g ${WWWGROUP} devuser && \
    useradd -ms /bin/bash --no-log-init --no-create-home -u ${WWWUSER} -g ${WWWGROUP} devuser

WORKDIR /var/www/html

# Grant ownership of application directory to non-root user
RUN chown -R devuser:devuser /var/www/html

USER devuser

In .env.local, developers export WWWUSER=$(id -u) and WWWGROUP=$(id -g). When Docker Compose builds or runs the application container, all files created inside volume mounts automatically inherit the host user's exact permissions. This completely eliminated permission friction across Linux and macOS workstations.

To preserve node package performance, we also defined an anonymous volume (/app/node_modules) for the Node.js WebSocket gateway. This keeps heavy package directories inside the Linux container filesystem, avoiding slow cross-filesystem I/O synchronization between host and container.

Deterministic startup with container healthchecks

Spinning up six containers simultaneously created race conditions. In early iterations, the Laravel container would attempt database migrations before PostgreSQL had finished initializing its data directory and socket. Similarly, the Java matching engine failed on boot because it tried to declare queue exchanges before RabbitMQ's management plugin was operational.

Relying solely on depends_on in Docker Compose only ensures that a container process has started — not that the service inside is ready to process traffic.

To establish deterministic startup sequences, we added explicit healthchecks to every backing service:

  • PostgreSQL: pg_isready -U dev -d oms_local checks whether the database is actively accepting socket connections.
  • Redis: redis-cli ping confirms memory store responsiveness.
  • RabbitMQ: rabbitmq-diagnostics -q ping verifies that the AMQP broker and node diagnostic interfaces are ready.

Pairing these healthchecks with depends_on: condition: service_healthy ensured that application containers remain on hold until their backing databases and brokers are fully operational. This eliminated custom startup delay scripts and reduced fresh stack cold-start times to under 15 seconds.

Hot reloading and developer ergonomics

Maintaining local development speed required instant feedback loops when code changes:

  1. Java Engine: Configured with Spring Boot DevTools and mounted Maven cache directories (~/.m2), allowing automatic class reloading upon compilation.
  2. Laravel API: Utilizes volume-mounted source code paired with Vite hot module replacement (HMR), reflecting Blade and asset changes instantly.
  3. Node.js Gateway: Executes via tsx watch, automatically restarting the WebSocket worker when source files are modified.

Developers retain full access to host debugging tools while all background execution, queue listening, and database queries run inside reproducible, containerized boundaries.

Results and key takeaways

Adopting Docker Compose for local development produced immediate improvements across our engineering workflow:

  • Onboarding Speed: New engineers go from a fresh git clone to a fully running polyglot environment in under two minutes (git clone -> cp .env.example .env.local -> docker-compose up).
  • Zero Environment Drift: Bugs resulting from local PHP, Java, or Node runtime version discrepancies dropped to zero.
  • Reliable CI/CD Integration: Local integration tests run against identical container definitions as automated GitHub Actions CI pipelines, preventing surprise failures during staging deployments.

Investing time into healthcheck declarations, volume permission mapping, and clear environment templates transformed local development from an error-prone manual process into a fast, single-command workflow.