Docker Compose Tutorial for Beginners
이 글의 핵심
Docker Compose tutorial: services, networks, volumes, depends_on, and env files—run a Node app with MongoDB and Nginx from one docker-compose.yml. Full working examples, common pitfalls, and production tips.
What is Docker Compose?
Running a modern web application usually means running several processes: an API server, a database, a cache, maybe a reverse proxy. You could start each with a long docker run command — but that gets unwieldy fast, requires remembering port mappings and volume paths, and is hard to share with teammates.
Docker Compose solves this by letting you describe your entire stack in a single docker-compose.yml file. One command (docker compose up) starts everything; another (docker compose down) tears it all down cleanly.
your-project/
├── docker-compose.yml ← the entire stack in one file
├── Dockerfile ← how to build your app image
├── .env ← secrets and env vars (git-ignored)
├── app.js
└── package.json
This tutorial walks through the mental model, a complete working example, and the commands you’ll use every day.
Core Concepts
Understanding four concepts covers 90% of Compose:
| Concept | What it is |
|---|---|
| Service | One container definition: which image to use (or build), ports to expose, environment variables, volumes, restart policy |
| Network | A private virtual network Compose creates for your project. Services communicate by service name, not by IP |
| Volume | Persistent storage that survives container restarts. Database data goes here |
| depends_on | Declares start order. Does NOT wait for readiness — use health checks for that |
Why service names instead of localhost?
Inside a container, localhost means that container’s own loopback. The database is in a separate container. Compose creates a DNS name for each service so they can reach each other:
# From inside the 'app' container:
mongodb://mongo:27017/mydb ← 'mongo' resolves to the database container
redis://cache:6379 ← 'cache' resolves to the Redis container
http://nginx:80 ← 'nginx' resolves to the proxy container
The Dockerfile
Compose often builds your API image from a Dockerfile rather than pulling a pre-built one. A minimal Node.js Dockerfile:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy dependency manifests first (cache layer)
COPY package*.json ./
RUN npm ci --only=production
# Copy source
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
Always add a .dockerignore — without it, every docker compose up --build copies node_modules and .git into the image, slowing builds dramatically:
# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
A Complete docker-compose.yml
Here is a three-service stack: Node.js API, MongoDB database, and Nginx reverse proxy.
# docker-compose.yml
services:
app:
build: . # Build from ./Dockerfile
ports:
- "3000:3000" # host:container
environment:
NODE_ENV: production
PORT: 3000
MONGODB_URI: mongodb://mongo:27017/mydb
env_file:
- .env # Load secrets from .env
depends_on:
- mongo
restart: unless-stopped
volumes:
- ./logs:/app/logs # Bind mount for log files
mongo:
image: mongo:7
ports:
- "27017:27017" # Expose for local tools (e.g. Compass)
volumes:
- mongo-data:/data/db # Named volume — data survives restarts
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro # :ro = read-only
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
restart: unless-stopped
volumes:
mongo-data: # Named volume declaration — Compose manages the storage
The .env file (never commit this):
# .env
MONGO_USER=admin
MONGO_PASSWORD=supersecretpassword
JWT_SECRET=another-secret
The Nginx Configuration
With Nginx proxying to the app container, your Node.js server is never exposed directly:
# nginx.conf
events {
worker_connections 1024;
}
http {
upstream app {
server app:3000; # 'app' is the service name
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}
}
The Node.js App
The app connects to MongoDB using the MONGODB_URI environment variable Compose injects:
// app.js
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Connect using the service name from docker-compose.yml
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const Item = mongoose.model('Item', new mongoose.Schema({
name: String,
createdAt: { type: Date, default: Date.now }
}));
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/items', async (req, res) => {
const items = await Item.find().sort({ createdAt: -1 }).limit(20);
res.json(items);
});
app.post('/items', async (req, res) => {
const item = await Item.create({ name: req.body.name });
res.status(201).json(item);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
Daily Commands
# Start all services in the background
docker compose up -d
# Start and rebuild images (after Dockerfile changes)
docker compose up -d --build
# View logs from all services
docker compose logs -f
# View logs from one service
docker compose logs -f app
# List running services
docker compose ps
# Run a one-off command inside a running container
docker compose exec app node -e "console.log('hello')"
docker compose exec mongo mongosh
# Stop containers (volumes preserved)
docker compose down
# Stop containers AND remove named volumes (database data deleted!)
docker compose down -v
# Restart one service
docker compose restart app
# Scale a service (if no host port conflict)
docker compose up -d --scale app=3
Networks in Depth
By default, Compose creates one network named <project>_default where <project> is the directory name. Every service joins it automatically.
You can define multiple networks to isolate services:
services:
app:
networks:
- frontend
- backend
nginx:
networks:
- frontend # nginx can reach app, but NOT mongo directly
mongo:
networks:
- backend # only app can reach mongo
networks:
frontend:
backend:
This mirrors real-world security: the database is only accessible from the application tier.
Volumes in Depth
Two types of volumes:
Named Volumes (preferred for databases)
volumes:
mongo-data: # declares the volume
services:
mongo:
volumes:
- mongo-data:/data/db # mounts it
Docker manages the storage location. The data persists across docker compose down / docker compose up. Only docker compose down -v removes it.
Bind Mounts (useful for development)
services:
app:
volumes:
- .:/app # mirrors your local source into the container
- /app/node_modules # exclude node_modules from the mirror
Bind mounts let you edit code locally and see changes immediately (when using a tool like nodemon). They are not recommended for production because they couple the container to the host filesystem layout.
Health Checks
depends_on only controls start order — it does not wait for MongoDB to be ready to accept connections. Without health checks, the app container often starts before Mongo is ready and crashes:
services:
mongo:
image: mongo:7
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s # grace period before checks count
app:
depends_on:
mongo:
condition: service_healthy # wait for the health check to pass
For PostgreSQL, the test is typically pg_isready:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
Override Files for Environments
Instead of one file for everything, split configuration across override files:
docker-compose.yml ← base config (shared by all environments)
docker-compose.dev.yml ← development overrides (bind mounts, nodemon)
docker-compose.prod.yml ← production overrides (replicas, resource limits)
# docker-compose.dev.yml
services:
app:
command: npx nodemon app.js # hot reload in development
environment:
NODE_ENV: development
volumes:
- .:/app # bind mount for live editing
- /app/node_modules
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production (just the base file, or with prod overrides)
docker compose up -d
Common Pitfalls
Container can’t connect to the database
MongoNetworkError: failed to connect to server [localhost:27017]
Cause: using localhost in the connection string instead of the service name.
Fix: use mongodb://mongo:27017/mydb — mongo is the service name, not localhost.
Port already in use
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:27017
Cause: another process (or another Compose project) is using port 27017.
Fix: either stop the other process or change the host port mapping ("27018:27017").
App crashes on startup before DB is ready
Cause: depends_on does not wait for the database to accept connections.
Fix: add a health check to the database service and use condition: service_healthy in depends_on.
Changes not reflected after rebuild
Cause: running docker compose up -d without --build uses the cached image.
Fix: docker compose up -d --build after any Dockerfile or dependency changes.
Database data lost on docker compose down
Cause: using docker compose down -v removes named volumes.
Fix: use plain docker compose down (no -v) to preserve data. Reserve -v for intentional resets.
Resource Limits
In production, set memory and CPU limits to prevent runaway containers from starving neighbors:
services:
app:
deploy:
resources:
limits:
cpus: '0.5' # max 50% of one CPU core
memory: 512M
reservations:
memory: 256M # guaranteed minimum
Note: deploy.resources is read by docker compose since Compose v2. For Swarm mode, it’s also used for scheduling.
Key Takeaways
- One
docker-compose.ymldefines your entire stack — services, networks, and volumes in one place - Service names are DNS names — connect from
apptomongo:27017, neverlocalhost:27017 - Named volumes persist database data across restarts;
docker compose down -vdeletes them depends_onis not enough for readiness — add health checks andcondition: service_healthy- Use
.envfor secrets — never hard-code credentials indocker-compose.yml - Override files keep dev and prod config separate without duplication
docker compose up -d --buildafter Dockerfile changes; plainup -duses the cache- Network isolation via multiple named networks: expose only what each tier needs to see
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Docker Compose tutorial: services, networks, volumes, depends_on, and env files—run a Node app with MongoDB and Nginx fr… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Docker Compose로 Node API·PostgreSQL·Redis 한 번에 띄우기
- Node.js 배포 가이드 | PM2, Docker, AWS, Nginx
- [GitHub Actions CI/CD Tutorial for Node.js | Test· Build](/en/blog/github-actions-ci-cd-tutorial/
이 글에서 다루는 키워드 (관련 검색어)
Docker, Docker Compose, Tutorial, Node.js, MongoDB, DevOps 등으로 검색하시면 이 글이 도움이 됩니다.