In this guide
"It works on my machine" is the most expensive sentence in student projects. Your backend runs, but the database is a different version. Your teammate's Node is v18, yours is v22. The examiner's laptop has nothing installed at all.
Docker Compose fixes this: one file describes your entire stack — frontend, backend, database, cache — and one command starts all of it, identically, on any machine. This guide takes you from zero to a working full-stack Compose setup.
What Docker Compose actually does
Docker packages one process and its dependencies into a container. Compose orchestrates multiple containers as one application: it builds images, creates a private network so services reach each other by name, manages startup order, and handles volumes for persistent data.
Without Compose: 4 terminals, manual startup order, "did you start Mongo?"
With Compose: docker compose up -> everything starts, wired together
Note: This guide assumes basic Docker knowledge (images, containers). If Docker itself is new, read the Docker beginner guide for student projects first.
A full-stack Compose file, annotated
Here is a realistic MERN-style stack — frontend, API, MongoDB, Redis — in a single compose.yaml:
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:5000
depends_on:
- api
api:
build: ./api
ports:
- "5000:5000"
environment:
- MONGO_URL=mongodb://mongo:27017/appdb
- REDIS_URL=redis://redis:6379
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
mongo:
image: mongo:7
volumes:
- mongo-data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
mongo-data:
redis-data:
What each part does:
build: ./api— builds the image from the Dockerfile in that folder. Useimage:for ready-made images (databases) andbuild:for your own code.- Service names are hostnames — the API reaches MongoDB at
mongo:27017, notlocalhost. Compose creates a private DNS network automatically. This is the concept beginners trip on most. depends_onwith healthcheck — plaindepends_ononly waits for the container to start, not for the database to accept connections. The healthcheck condition makes the API wait until Mongo actually responds. Without this, your API crashes on first boot trying to connect to a database that is still initializing.volumes— named volumes persist data outside container lifecycles.docker compose downremoves containers but keeps volumes; your database survives restarts. (Add-vto wipe them.)ports: "5000:5000"— host:container. Only expose what humans or external tools need; services talk to each other on the private network without published ports.
The Dockerfiles your services need
Compose's build: points at Dockerfiles. A minimal Node API Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]
Two details that matter:
- Copy dependency files first, install, then copy code. Docker caches layers — code changes rebuild only the last layer instead of reinstalling all dependencies every time.
npm ciovernpm installin containers — deterministic installs from the lockfile.
For development, you want live reload instead. Override the command and mount your code as a volume:
# compose.override.yaml (auto-loaded for local dev)
services:
api:
volumes:
- ./api:/app
- /app/node_modules
command: npm run dev
The anonymous volume /app/node_modules preserves the container's installed dependencies while your code directory is live-mounted. Edit code on your laptop, the container reloads instantly.
Environment variables done right
- Never commit secrets. Put them in a
.envfile (gitignored); Compose reads it automatically. - Separate dev/prod configs:
compose.yamlfor shared structure,compose.override.yamlfor local dev tweaks,compose.prod.yamlfor production (selected with-f). - Validate at startup: your API should fail fast with a clear error if a required variable is missing, not crash mysteriously on first request.
The daily commands
docker compose up --build # build images and start everything
That single command is genuinely the whole demo-day story. A few more for daily life:
docker compose up -d # start in background (detached)
docker compose logs -f api # follow one service's logs
docker compose exec mongo mongosh # shell into a running service
docker compose down # stop and remove containers (keeps volumes)
docker compose down -v # stop and WIPE volumes (fresh database)
Keep shell commands in your notes rather than long scripts — Compose's whole point is that these few commands replace your setup documentation.
Production hardening
A Compose file that is perfect for development needs a few additions before it faces the internet:
services:
api:
image: registry.example.com/myapp/api:1.4.2 # pinned tag, never :latest
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
logging:
options:
max-size: "10m"
max-file: "3"
What changed and why:
- Pinned image tags —
:latestis a moving target; a redeploy months later pulls different code than you tested. Pin versions, bump deliberately. restart: unless-stopped— containers come back after crashes and host reboots without you.- Resource limits — one runaway service cannot starve the database of memory.
- Log rotation — unbounded container logs fill disks silently; cap them.
- Non-root users in your Dockerfiles (
USER node) and read-only filesystems where the app allows it — basic container hygiene that costs nothing.
None of this replaces real orchestration at scale, but it is the difference between a VPS deployment that runs for months and one that pages you at 3 AM.
Common mistakes
- Using
localhostbetween services. Inside the Compose network,localhostmeans "this container." Use service names (mongo,redis,api).localhostis only for reaching published ports from your laptop. depends_onwithout healthchecks. Your API boots in 1 second; Postgres needs 10. The healthcheck condition is what makes startup order actually work.- No volumes on databases. Without a named volume,
docker compose downdeletes your data. Fine for a cache; catastrophic for your demo database. - Baking secrets into images.
ENVin a Dockerfile with a real key, committed to GitHub — assume it is compromised and rotate it. Use.envfiles and runtime environment variables. - One giant Compose file for everything. Split dev overrides into
compose.override.yamlso production config stays clean. - Forgetting
.dockerignore. Without it,COPY . .shipsnode_modules,.git, and your.envinto the image — slow builds and leaked secrets.
Quick checklist
-
compose.yamldefines every service;docker compose up --buildworks from a fresh clone - Services talk via service names, not localhost
- Databases have named volumes and healthchecks
- Secrets in gitignored
.env, never in Dockerfiles or committed files -
.dockerignorepresent in each build context - Dev override gives live reload; prod file strips dev conveniences
Where to go from here
- Docker fundamentals: Docker beginner guide for student projects.
- Automate builds and deploys of this stack: GitHub Actions: Build a CI/CD Pipeline.
- Put it on a server: how to deploy a MERN app on a VPS.
- Coordinate the team editing these files: Git branching workflows for project teams.
- More DevOps topics in the Web Development branch hub.