Docker Compose for Full-Stack Projects

Stop juggling four terminals and setup docs that only work on your laptop. This guide builds a complete Docker Compose stack — frontend, API, database, cache — with annotated config, healthchecks, persistent volumes, and a dev/prod split that survives demo day.

Written by Projectech6 min readPublished
For B.E./B.Tech Computer Science and IT students whose full-stack projects need a frontend, backend, and database running together and are tired of setup instructions that only work Topics: Docker, Docker Compose, Node.js, MongoDB
Illustration of Docker Compose orchestrating a full-stack application with frontend, API, database, and cache containers connected in one network.
Illustration generated for this guide.
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. Use image: for ready-made images (databases) and build: for your own code.
  • Service names are hostnames — the API reaches MongoDB at mongo:27017, not localhost. Compose creates a private DNS network automatically. This is the concept beginners trip on most.
  • depends_on with healthcheck — plain depends_on only 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 down removes containers but keeps volumes; your database survives restarts. (Add -v to 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:

  1. 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.
  2. npm ci over npm install in 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 .env file (gitignored); Compose reads it automatically.
  • Separate dev/prod configs: compose.yaml for shared structure, compose.override.yaml for local dev tweaks, compose.prod.yaml for 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:latest is 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 localhost between services. Inside the Compose network, localhost means "this container." Use service names (mongo, redis, api). localhost is only for reaching published ports from your laptop.
  • depends_on without 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 down deletes your data. Fine for a cache; catastrophic for your demo database.
  • Baking secrets into images. ENV in a Dockerfile with a real key, committed to GitHub — assume it is compromised and rotate it. Use .env files and runtime environment variables.
  • One giant Compose file for everything. Split dev overrides into compose.override.yaml so production config stays clean.
  • Forgetting .dockerignore. Without it, COPY . . ships node_modules, .git, and your .env into the image — slow builds and leaked secrets.

Quick checklist

  • compose.yaml defines every service; docker compose up --build works 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
  • .dockerignore present in each build context
  • Dev override gives live reload; prod file strips dev conveniences

Where to go from here

More project guides

More in Web Development