The real problem
One dev machine often needs to run multiple projects at the same time:
- project A needs MySQL + Redis
- project B also needs MySQL, but different data
- project C adds MongoDB
If you install everything directly on the host, you quickly get:
- port conflicts
- version drift
- mixed local data across projects
The clean fix is simple: each project gets its own docker-compose.yml, with its own app, MySQL, and Redis.
A complete compose file first
If the real goal is "give me one compose file I can use right now so each project has isolated MySQL and Redis without port conflicts", start with this:
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
NODE_ENV: development
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASS: localroot
DB_NAME: mydb
REDIS_URL: redis://redis:6379
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: localroot
MYSQL_DATABASE: mydb
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-plocalroot"]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
redis:
image: redis:7-alpine
ports:
- "6380:6379"
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
Dockerfile.dev:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "start:dev"]
Start it:
docker compose up -d
docker compose logs -f api
With this file:
- the app runs at
localhost:3000 - this project's MySQL runs at
localhost:3307 - this project's Redis runs at
localhost:6380 - MySQL and Redis data live in isolated volumes
If another project also uses Compose, you only change the host ports:
- project A:
3307,6380 - project B:
3308,6381 - project C:
3309,6382
That is the core trick for avoiding port conflicts while keeping each project fully isolated.
Break down the file piece by piece
Why does api use DB_HOST=mysql instead of localhost?
Because inside a Docker Compose network, services talk to each other by service name.
In this file:
apireaches MySQL throughmysql:3306apireaches Redis throughredis:6379
localhost is only for connections coming from the host machine.
Why is MySQL 3307:3306 and Redis 6380:6379?
The format is:
HOST_PORT:CONTAINER_PORT
That means:
- the host uses
3307 - inside the MySQL container it is still
3306
This lets every project keep the same internal config while only changing external host ports.
Example for another project:
mysql:
ports:
- "3308:3306"
redis:
ports:
- "6381:6379"
Why mount the whole project with - .:/app?
For local dev, this is often better than mounting only ./src:/app/src because:
- file changes are visible immediately
- many frameworks need config files outside
src - you avoid missing folders like
prisma,migrations,uploads, or framework config files
This line:
- /app/node_modules
prevents the host directory from overwriting the container's node_modules.
Why do MySQL and Redis need their own volumes?
Without volumes, deleting the containers also deletes the data.
This part keeps data persistent:
volumes:
- mysql_data:/var/lib/mysql
- redis_data:/data
That is exactly what makes multi-project local dev cleaner:
- each project gets its own data
- resetting project A does not wipe project B
What do depends_on and healthcheck solve?
Many apps fail on startup because the API comes up before MySQL is actually ready.
In this file:
mysqlhas ahealthcheckapiwaits for MySQL to become healthy first
That reduces common startup issues like:
ECONNREFUSED- database not ready
- migrations running too early
The commands make more sense after the file
Once the compose file is clear, the commands are straightforward:
# start the full stack
docker compose up -d
# tail app logs
docker compose logs -f api
# list running containers
docker compose ps
# open a shell inside the app container
docker compose exec api sh
# stop the stack
docker compose down
# stop everything and remove data volumes too
docker compose down -v
docker compose down -v is especially useful when you want to reset only that project's database without cleaning up your whole machine manually.
When should you expose MySQL or Redis to the host?
In local dev, usually only when you need:
- TablePlus or DBeaver
- a Redis GUI or direct host-side debugging
If you do not need that, you can remove the ports section entirely for MySQL and Redis. The app will still connect internally by service name.
A stricter version:
mysql:
image: mysql:8
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
That means:
- the app still uses
mysql:3306andredis:6379 - but the host machine cannot access those services directly anymore
When should each project have its own MySQL and Redis?
You usually want separate services when:
- each project has its own schema
- you want
down -von one project without affecting another - different projects need different MySQL or Redis versions
Trying to force all projects to share one local MySQL sounds clean at first, but it often turns into mixed data and config drift.
Compose File for Production
Production needs a few differences:
- No source code bind mounts (build a complete image)
- Don't expose database ports to the host
- Add restart policies
- Use environment variable injection, not hardcoded secrets
# docker-compose.prod.yml
services:
api:
image: myapp:latest # pre-built image, no rebuild on server
ports:
- "3000:3000"
environment:
NODE_ENV: production
DB_HOST: mysql
DB_PASS: ${DB_PASSWORD}
REDIS_URL: redis://redis:6379
restart: unless-stopped
depends_on:
- mysql
- redis
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: mydb
volumes:
- mysql_data:/var/lib/mysql
# No port exposed to host in production
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- api
restart: unless-stopped
volumes:
mysql_data:
redis_data:
Deploy:
# Build new image
docker build -t myapp:latest .
# Pull latest image (if using a registry)
docker compose -f docker-compose.prod.yml pull
# Restart just the api service with new image, no downtime for others
docker compose -f docker-compose.prod.yml up -d --no-deps api
Most Useful Commands
# Start all services in detach mode
docker compose up -d
# Stop all services
docker compose down
# Stop and remove volumes (deletes data)
docker compose down -v
# Tail logs for a service
docker compose logs -f api
# Restart a service
docker compose restart api
# Open a shell inside a container
docker compose exec api sh
# Check service status
docker compose ps
# Rebuild image (after Dockerfile changes)
docker compose build api
# Run multiple instances of a service
docker compose up -d --scale api=3
Healthcheck — Wait Until Services Are Ready
APIs frequently crash on startup if the database isn't ready yet. Use healthcheck + depends_on condition to handle it:
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
api:
depends_on:
mysql:
condition: service_healthy # waits until mysql is healthy before starting
Summary
Docker Compose isn't just a convenience tool for spinning up databases — it's the standard way to manage multi-service applications from dev to production:
| Install directly | Docker Compose | |
|---|---|---|
| New machine setup | Reinstall everything | docker compose up |
| Multiple projects | Port conflicts, version clashes | Each project isolated |
| Team consistency | "Works on my machine" | Same config for everyone |
| Cleanup | Leftover files everywhere | docker compose down -v |
| Production | Separate config | Same file, different env vars |
Once you get used to Compose, it's very hard to go back to installing services directly on the machine.