Docker Compose - Local Development Environment from Scratch
Docker Compose Local
devopsDocker 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 upand 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!
Team of programming experts specializing in modern web technologies.