Docker Compose for Local Symfony and Next.js Development: The Setup That Doesn't Slow Your Workflow

#docker compose symfony nextjs
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most teams that containerize a Symfony and Next.js stack for production eventually try to reuse the same setup for local development, and that's usually where things slow down. A docker compose symfony nextjs setup built for production optimizes for image size and immutability. Local development needs something different: fast file syncing, hot reload, and a way to poke around inside a running container without tearing the whole thing down. This post covers that setup specifically, not the production one. If you're looking for production Docker patterns, we've covered those separately in containerizing a Symfony application.

The services you actually need

A Symfony and Next.js local environment usually needs six containers: PHP-FPM, Nginx, PostgreSQL, Redis, Mailpit for catching outgoing email, and the Next.js dev server. Here's a working docker-compose.yml for that:

services:
  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      target: dev
    volumes:
      - ./api:/var/www/app
      - php_vendor:/var/www/app/vendor
    environment:
      APP_ENV: dev
      DATABASE_URL: postgresql://app:app@postgres:5432/app
    depends_on:
      - postgres
      - redis

  nginx:
    image: nginx:1.27-alpine
    volumes:
      - ./api/public:/var/www/app/public
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    ports:
      - '8080:80'
    depends_on:
      - php

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - pg_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

  mailpit:
    image: axllent/mailpit:latest
    ports:
      - '8025:8025'
      - '1025:1025'

  web:
    build:
      context: ./web
      dockerfile: Dockerfile.dev
    volumes:
      - ./web:/app
      - web_node_modules:/app/node_modules
    environment:
      NEXT_PUBLIC_API_URL: http://php:8080
    ports:
      - '3000:3000'
    depends_on:
      - nginx

volumes:
  pg_data:
  php_vendor:
  web_node_modules:

Two things worth noting here before the volume problem. First, vendor and node_modules get their own named volumes instead of being bind-mounted from the host. That's not an optimization, it's a requirement: if you bind-mount those directories, npm and Composer end up fighting the host filesystem's file permissions, and on macOS the sheer number of small files in node_modules is exactly what causes the performance problem covered next. Second, the Next.js app talks to the API through the nginx service name, not localhost. Both containers are on the same Compose network by default, so http://php:8080 resolves inside the container even though it means nothing on your host machine.

The macOS volume problem

If you're developing on macOS and bind-mounting your Symfony application code with a plain volumes: - ./api:/var/www/app, you'll notice that Composer installs are slow, PHPUnit runs take longer than they should, and Symfony's cache warmup drags. This isn't a Docker Desktop bug so much as a structural cost: Docker on macOS runs a Linux VM, and every file read or write across the bind mount crosses that VM boundary. With PHP's habit of opening thousands of small files per request (autoloaded classes, cached container definitions, translation catalogs), that overhead adds up fast.

There are two practical fixes. The first is switching Docker Desktop's file sharing implementation to VirtioFS, which is faster than the older gRPC FUSE backend and requires no changes to your Compose file. It's a setting change in Docker Desktop's preferences, and for most teams it's enough to make the slowdown tolerable.

The second, more thorough fix is Mutagen, which syncs files between the host and the container instead of mounting them directly. Mutagen watches your host filesystem and pushes changes into the container asynchronously, so PHP reads local files from the container's own disk rather than across the VM boundary. Docker Compose has native Mutagen support through docker compose watch (more on that below), or you can run Mutagen directly with a sync session defined in mutagen.yml:

sync:
  defaults:
    ignore:
      vcs: true
      paths:
        - vendor
        - var/cache
  php-code:
    alpha: './api'
    beta: 'docker://project-php-1/var/www/app'
    mode: 'two-way-resolved'

Teams on Apple Silicon with recent Docker Desktop versions and VirtioFS enabled often find they don't need Mutagen at all. Teams still on older hardware, or with large codebases and a lot of vendor files, usually do.

Watch mode: hot reload without rebuilding

The other recurring friction point is rebuilding the PHP container every time a source file changes. If your Dockerfile copies application code into the image at build time (which it should, for the production stage), a bind mount in dev handles most of that automatically, but there are still cases where you want Compose to actively sync files and restart a service without a full rebuild: updating composer.json, changing a Dockerfile itself, or syncing static assets into the Next.js container.

Docker Compose's watch mode, part of Compose v2.22 and later, handles this declaratively:

services:
  php:
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /var/www/app/src
        - action: rebuild
          path: ./api/composer.json
  web:
    develop:
      watch:
        - action: sync
          path: ./web/src
          target: /app/src
        - action: rebuild
          path: ./web/package.json

Run it with docker compose watch and Compose syncs source changes into the running container immediately, while changes to composer.json or package.json trigger a targeted rebuild of just that service. This gets you most of what Mutagen offers for source files specifically, without a separate tool to configure, though it doesn't replace Mutagen's continuous two-way sync for large directories like vendor.

Database seeding and fixtures

A development environment that starts empty every time slows everyone down, since each developer ends up manually recreating the same test accounts and sample data. Doctrine Fixtures Bundle solves this well for Symfony:

docker compose exec php bin/console doctrine:fixtures:load --no-interaction

Wrap that in a Makefile target alongside migrations so a fresh clone of the repository gets a working database in one command instead of several manual steps. Fixture classes belong in src/DataFixtures, and it's worth keeping a TestFixtures group separate from a DevFixtures group so CI can load a minimal dataset while local development gets something closer to a full seed.

A Makefile that hides the docker compose incantations

Nobody wants to type docker compose exec php bin/console cache:clear five times a day. A thin Makefile wrapper keeps the common commands short and consistent across the team:

.PHONY: dev test migrate shell logs

dev:
	docker compose watch

test:
	docker compose exec php bin/phpunit

migrate:
	docker compose exec php bin/console doctrine:migrations:migrate --no-interaction

shell:
	docker compose exec php bash

logs:
	docker compose logs -f php web

make dev, make test, make migrate, make shell. New developers don't need to know the underlying Compose commands to be productive on day one, and the Makefile doubles as documentation for anyone auditing how the environment works.

Where this fits with production

The environment described here is meant to mirror production closely enough that "works on my machine" problems are rare, without carrying production concerns like multi-stage build optimization or image size into your daily workflow. If your team is still running Symfony or Next.js locally without containers at all, or if your current setup has drifted far enough from production that deployment surprises are routine, that's usually a sign the underlying custom software development process needs a closer look, not just the Docker Compose file. The same applies if your local setup has become so complex that onboarding a new developer takes days rather than an hour: that complexity tends to be a symptom of a codebase that's outgrown its original architecture, which is the kind of problem a code quality consulting engagement is built to diagnose.

If you want a second opinion on your local development setup, or help getting a Symfony and Next.js stack production-ready, reach out at hello@wolf-tech.io or take a look at what we do at wolf-tech.io.