Skip to content
DevOps

Docker Compose - Local Development Environment from Scratch

Published on:
·7 min read·Author: MDS Software Solutions Group

Docker Compose Local

devops

Docker Compose - Local Development Environment from Scratch

Setting up a local development environment is one of the first challenges every development team faces. A new team member often spends their entire first day installing databases, configuring environment variables, and resolving version conflicts. Docker Compose eliminates these problems by allowing you to spin up a complete development environment with a single command: docker compose up.

In this article, we will walk through step by step how to build a professional local development environment comprising a frontend (Next.js), backend (.NET API), database (PostgreSQL), cache (Redis), and email server (Mailhog). We will cover docker-compose.yml syntax, volumes, environment variables, networking, health checks, hot reload, database migrations, and debugging in containers.

What Is Docker Compose?#

Docker Compose is a tool for defining and running multi-container Docker applications. Instead of manually starting each container with a separate docker run command, you define all services in a single YAML file and manage them with simple commands.

Key advantages of Docker Compose:

  • Single configuration file - your entire infrastructure described in docker-compose.yml
  • Reproducibility - identical environment on every developer's machine
  • Isolation - no conflicts between projects; each has its own containers
  • Easy onboarding - a new developer runs docker compose up and gets to work
  • Version control - the compose file is part of your repository, changes are tracked in Git

docker-compose.yml Syntax#

The docker-compose.yml file is the heart of Docker Compose. Here is the basic structure:

# Specification version (optional since Compose v2)
version: "3.9"

services:
  # Service (container) definitions
  service-name:
    image: image:tag              # Pre-built image from Docker Hub
    build: ./path                 # Or build from Dockerfile
    ports:
      - "host:container"          # Port mapping
    volumes:
      - ./local:/container        # Volume mapping
    environment:                  # Environment variables
      - KEY=value
    env_file:
      - .env                      # File with variables
    depends_on:                   # Dependencies between services
      - another-service
    networks:                     # Networks
      - my-network
    restart: unless-stopped       # Restart policy

volumes:
  # Named volumes (persistent data)
  postgres-data:

networks:
  # Custom networks
  my-network:
    driver: bridge

Each entry under services defines a single container. The volumes section declares named volumes for persistent data storage, and networks defines networks that allow containers to communicate with each other.

Complete Environment: Next.js + .NET API + PostgreSQL + Redis + Mailhog#

Let's get practical. Below is the complete configuration for a development environment for a typical enterprise application. The project structure looks like this:

project/
├── docker-compose.yml
├── docker-compose.override.yml
├── docker-compose.prod.yml
├── .env
├── .env.example
├── frontend/
│   ├── Dockerfile
│   ├── Dockerfile.dev
│   ├── package.json
│   └── src/
├── backend/
│   ├── Dockerfile
│   ├── Dockerfile.dev
│   ├── MyApi.sln
│   └── src/
└── database/
    ├── init.sql
    └── migrations/

Main docker-compose.yml#

version: "3.9"

services:
  # ─── Frontend (Next.js) ────────────────────────────────
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - NEXT_PUBLIC_API_URL=http://localhost:5000/api
      - WATCHPACK_POLLING=true
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

  # ─── Backend (.NET API) ────────────────────────────────
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    ports:
      - "5000:5000"
      - "5001:5001"
    volumes:
      - ./backend/src:/app/src
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://+:5000
      - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=myapp_dev;Username=devuser;Password=devpass123
      - Redis__ConnectionString=redis:6379
      - Email__SmtpHost=mailhog
      - Email__SmtpPort=1025
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - app-network
    restart: unless-stopped

  # ─── PostgreSQL ────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
    environment:
      - POSTGRES_DB=myapp_dev
      - POSTGRES_USER=devuser
      - POSTGRES_PASSWORD=devpass123
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U devuser -d myapp_dev"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - app-network
    restart: unless-stopped

  # ─── Redis ─────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - app-network
    restart: unless-stopped

  # ─── Mailhog (test email server) ──────────────────────
  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"
      - "8025:8025"
    networks:
      - app-network
    restart: unless-stopped

volumes:
  postgres-data:
    driver: local
  redis-data:
    driver: local

networks:
  app-network:
    driver: bridge

This configuration launches five services that together form a complete development environment. The Next.js frontend listens on port 3000, the .NET backend on port 5000, PostgreSQL on the standard port 5432, Redis on 6379, and the Mailhog panel is accessible at http://localhost:8025.

Volumes - Data Persistence#

Volumes in Docker Compose serve two critical roles: they ensure data persists across container restarts, and they enable file synchronization between the host and the container (essential for hot reload).

Named volumes (persistent data)#

volumes:
  postgres-data:
    driver: local

Named volumes (e.g., postgres-data) store database data independently of the container lifecycle. Removing the PostgreSQL container with docker compose down will not delete the data. To clear the data, you must explicitly use docker compose down -v.

Bind mounts (file synchronization)#

volumes:
  - ./frontend/src:/app/src        # Source code
  - ./frontend/public:/app/public  # Static files
  - /app/node_modules              # Anonymous volume - do not overwrite!

Bind mounts mount directories from the host directly into the container. Changing a file on the host is immediately visible in the container, which is the foundation of hot reload. It is important to use an anonymous volume for node_modules - without it, the host's node_modules directory would overwrite the one in the container, leading to issues with dependencies compiled for a different platform.

Environment Variables#

Docker Compose supports environment variables in several ways. The best practice is to store values in an .env file:

.env file#

# .env (DO NOT commit to Git!)
POSTGRES_DB=myapp_dev
POSTGRES_USER=devuser
POSTGRES_PASSWORD=devpass123
REDIS_PASSWORD=
NODE_ENV=development
API_PORT=5000
FRONTEND_PORT=3000

.env.example file#

# .env.example (commit to Git as a template)
POSTGRES_DB=myapp_dev
POSTGRES_USER=devuser
POSTGRES_PASSWORD=CHANGE_ME
REDIS_PASSWORD=
NODE_ENV=development
API_PORT=5000
FRONTEND_PORT=3000

Usage in docker-compose.yml#

services:
  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    ports:
      - "${POSTGRES_PORT:-5432}:5432"

The syntax ${POSTGRES_PORT:-5432} sets a default value of 5432 if the variable is not defined. The .env file should be added to .gitignore, and .env.example should be committed to the repository as a template for new developers.

Networking - Communication Between Containers#

Docker Compose automatically creates a network for each project. Containers within the same network can communicate using service names as DNS hostnames.

networks:
  app-network:
    driver: bridge

In our configuration, the backend connects to PostgreSQL using the host postgres (the service name):

Host=postgres;Port=5432;Database=myapp_dev

And the frontend communicates with the backend via backend:5000 within the Docker network. However, from the browser (client side), communication goes through localhost:5000, because the browser operates outside the Docker network.

Network isolation between projects#

Each Docker Compose project has its own isolated network. Containers from project A cannot see containers from project B, even if they use the same internal ports. Conflicts can only occur at the host port level - this is why it is good practice to parameterize ports with environment variables.

Health Checks - Service Readiness Control#

Health checks are a mechanism for verifying whether a service is fully ready to handle requests. Without them, depends_on only guarantees that a container has been started, not that the service is ready.

postgres:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U devuser -d myapp_dev"]
    interval: 5s       # How often to check
    timeout: 5s        # Maximum wait time for a response
    retries: 5          # How many retries before marking as unhealthy
    start_period: 10s   # Startup time (healthcheck does not mark as unhealthy)

backend:
  depends_on:
    postgres:
      condition: service_healthy  # Wait until PostgreSQL is healthy
    redis:
      condition: service_healthy  # Wait until Redis is healthy

Without health checks, the backend could attempt to connect to the database before it has finished starting up. The condition: service_healthy directive in depends_on ensures the correct startup order.

Compose Files: Development vs Production#

In practice, you need different configurations for development and production environments. Docker Compose supports file overlaying (override), which allows for an elegant separation of configurations.

docker-compose.yml (base configuration)#

Contains shared service definitions, networks, and volumes - everything that is identical across both environments.

docker-compose.override.yml (development)#

# docker-compose.override.yml
# Automatically loaded alongside docker-compose.yml
version: "3.9"

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - WATCHPACK_POLLING=true

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    volumes:
      - ./backend/src:/app/src
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
    ports:
      - "5000:5000"
      - "5001:5001"   # Debugger port

  postgres:
    ports:
      - "5432:5432"   # Host access (e.g., pgAdmin, DBeaver)

  redis:
    ports:
      - "6379:6379"   # Host access (e.g., RedisInsight)

docker-compose.prod.yml (production)#

# docker-compose.prod.yml
version: "3.9"

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        - NEXT_PUBLIC_API_URL=https://api.mydomain.com
    environment:
      - NODE_ENV=production
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G

  postgres:
    environment:
      - POSTGRES_PASSWORD=${PROD_DB_PASSWORD}
    # No port mapping to host - access only from Docker network
    ports: []

  redis:
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} --maxmemory 512mb
    ports: []

Running the environments:

# Development (loads docker-compose.yml + docker-compose.override.yml by default)
docker compose up

# Production (explicitly specifying files)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

In the production environment, database ports are not exposed to the host, images are built from production Dockerfiles (multi-stage builds, size optimization), and container resources are limited.

Hot Reload - Automatic Code Reloading#

Hot reload is a critical feature for developer productivity. Every code change should be immediately visible in the browser or in server logs.

Hot reload for Next.js#

# frontend/Dockerfile.dev
FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Next.js supports hot reload by default
# WATCHPACK_POLLING is needed on Windows/WSL2
ENV WATCHPACK_POLLING=true

EXPOSE 3000
CMD ["npm", "run", "dev"]

The key elements are the volume mounting the source code (./frontend/src:/app/src) and the WATCHPACK_POLLING=true variable, which is essential on Windows systems with WSL2 where standard file change notifications (inotify) may not work correctly.

Hot reload for .NET#

# backend/Dockerfile.dev
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app

COPY *.sln .
COPY src/**/*.csproj ./src/
RUN dotnet restore

COPY . .

EXPOSE 5000 5001
CMD ["dotnet", "watch", "run", "--project", "src/MyApi", "--urls", "http://+:5000"]

The dotnet watch run command automatically recompiles and restarts the application whenever a .cs file changes. The volume mounting ./backend/src:/app/src ensures file synchronization.

Database Migrations in Containers#

Database migrations should be run automatically at container startup or as a separate service.

Approach 1: PostgreSQL initialization script#

-- database/init.sql
-- Executed automatically on the first PostgreSQL container startup

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Initial tables
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS audit_log (
    id BIGSERIAL PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    action VARCHAR(100) NOT NULL,
    details JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Approach 2: Separate migration service#

services:
  migrations:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    command: ["dotnet", "ef", "database", "update", "--project", "src/MyApi"]
    environment:
      - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=myapp_dev;Username=devuser;Password=devpass123
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app-network
    restart: "no"  # Run once and exit

A separate migration service starts, executes migrations, and terminates. With restart: "no", the container is not restarted after completion.

Approach 3: Entrypoint with migrations#

# backend/entrypoint.sh
#!/bin/bash
set -e

echo "Running migrations..."
dotnet ef database update --project src/MyApi

echo "Starting application..."
exec dotnet watch run --project src/MyApi --urls "http://+:5000"
backend:
  entrypoint: ["/bin/bash", "./entrypoint.sh"]

This approach runs migrations automatically before starting the application. It is convenient but increases container startup time.

Debugging in Containers#

Debugging applications running in containers requires proper configuration, but it is not difficult.

Debugging .NET in a container (Visual Studio Code)#

Add the debugger configuration in .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Docker .NET Attach",
      "type": "docker",
      "request": "attach",
      "platform": "netCore",
      "sourceFileMap": {
        "/app/src": "${workspaceFolder}/backend/src"
      }
    }
  ]
}

Debugging Next.js in a container#

Add the debug flag to the start command:

frontend:
  command: ["node", "--inspect=0.0.0.0:9229", "node_modules/.bin/next", "dev"]
  ports:
    - "3000:3000"
    - "9229:9229"   # Node.js debugger port

VS Code configuration:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Docker Next.js Attach",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "remoteRoot": "/app",
      "localRoot": "${workspaceFolder}/frontend"
    }
  ]
}

Logs and troubleshooting#

# Logs for a specific service
docker compose logs backend

# Real-time logs (follow)
docker compose logs -f backend postgres

# Last 100 lines of logs
docker compose logs --tail=100 backend

# Enter a container (interactive shell)
docker compose exec backend bash

# Check container status
docker compose ps

# Check health checks
docker inspect --format='{{.State.Health.Status}}' project-postgres-1

Useful Docker Compose Commands#

Here is a list of the most commonly used commands that will speed up your daily workflow:

# ─── Lifecycle management ───────────────────────────────
docker compose up                    # Start everything
docker compose up -d                 # Start in the background (detached)
docker compose up --build            # Rebuild images and start
docker compose up backend postgres   # Start only selected services
docker compose down                  # Stop and remove containers
docker compose down -v               # Stop and remove containers + volumes
docker compose stop                  # Stop containers (without removing)
docker compose start                 # Start stopped containers
docker compose restart backend       # Restart a specific service

# ─── Building ───────────────────────────────────────────
docker compose build                 # Build all images
docker compose build --no-cache      # Build without cache
docker compose build backend         # Build a specific image

# ─── Diagnostics ────────────────────────────────────────
docker compose ps                    # Container status
docker compose logs -f               # Logs for all services (follow)
docker compose logs -f backend       # Logs for a specific service
docker compose top                   # Processes in containers

# ─── Container interaction ──────────────────────────────
docker compose exec postgres psql -U devuser -d myapp_dev    # PostgreSQL console
docker compose exec redis redis-cli                           # Redis console
docker compose exec backend dotnet ef migrations add Init     # New migration

# ─── Cleanup ────────────────────────────────────────────
docker compose down -v --rmi all     # Remove everything (containers, volumes, images)
docker system prune -a               # Clean unused Docker resources
docker volume prune                  # Clean unused volumes

Common Problems and Solutions#

Port already in use#

# Check what is using the port
# Linux/Mac:
lsof -i :5432
# Windows:
netstat -ano | findstr :5432

# Change the port in .env
POSTGRES_PORT=5433

Slow volumes on Windows/WSL2#

On Windows with WSL2, bind mounts can be very slow. Solutions:

# 1. Use delegated/cached (Docker Desktop)
volumes:
  - ./frontend/src:/app/src:delegated

# 2. Limit synchronized directories
volumes:
  - ./frontend/src:/app/src        # Source code only
  - /app/node_modules              # node_modules in container
  - /app/.next                     # Build cache in container

Container keeps restarting#

# Check logs
docker compose logs backend

# Check health check
docker inspect project-backend-1 | jq '.[0].State.Health'

# Enter the container and debug manually
docker compose run --entrypoint bash backend

Summary#

Docker Compose is an essential tool in every development team's arsenal. It enables:

  • Environment standardization - no more "it works on my machine"
  • Fast onboarding - a new developer starts working in minutes, not hours
  • Project isolation - multiple projects on one machine without conflicts
  • Automation - migrations, data seeding, and health checks work automatically
  • Productivity - hot reload, debugging, and logs all in one place

A well-configured Docker Compose environment is an investment that pays off with every new team member and every infrastructure change. Start with a simple configuration and expand it gradually - Docker Compose supports an iterative approach to building infrastructure.

Need a Professional Development Environment?#

At MDS Software Solutions Group, we help teams adopt Docker Compose in their daily workflows. We offer:

  • Local development environment setup
  • Optimization of existing Compose files
  • CI/CD implementation with Docker containers
  • Training for development teams
  • Migration of projects to container architecture

Contact us to accelerate your team's productivity!

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Docker Compose - Local Development Environment from Scratch | MDS Software Solutions Group | MDS Software Solutions Group