Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

Docker Compose Beyond Basics: Multi-Container Apps That Actually Work

Published

Docker Management
Docker Compose Beyond Basics: Multi-Container Apps That Actually Work

Running one container is easy. Running several containers that depend on each other, share data, survive reboots, and do not fall over when one component crashes — that is what Docker Compose is for. Most tutorials stop at the basics. This one builds a real working stack.

Why one container is rarely enough

A web application almost never runs alone. It needs a database, a cache, a reverse proxy, and possibly a background worker. Each of these is a separate concern that should be run, scaled, and updated independently. Docker Compose lets you define all of them in one file and manage them as a single unit.

The key difference from running multiple docker run commands is that Compose knows about the relationships between services. It starts them in the right order, connects them on a shared network, and restarts them automatically when they fail.

Networks: how containers talk to each other

When you run a container, it is isolated from other containers by default. Two containers on the same host cannot reach each other unless they share a Docker network.

Docker Compose creates a default network for you. Every service in the same docker-compose.yml file is on that network and can reach every other service by its service name.

services:
  web:
    image: nginx
  api:
    image: node-app

The nginx container can reach the node-app container at http://api:3000 — the service name becomes the hostname. You do not need to know the container IP address. Docker DNS handles the resolution automatically.

Named volumes: making data persist

Containers are ephemeral. When you delete and recreate a container, everything stored inside it is gone. For databases, caches, and any data you want to keep, you use a named volume.

services:
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

The volumes: section at the bottom of the file declares a volume named pgdata. The volumes: line inside the service mounts it at /var/lib/postgresql/data. When the container is deleted and recreated, the data survives because the volume exists independently.

Bind mounts — pointing a host directory into a container — work too, but named volumes are managed by Docker and work consistently regardless of which host the stack runs on.

Environment variables and .env files

Hardcoding passwords and URLs in a docker-compose.yml is a security problem and makes the file useless across environments. The solution is environment variables.

Create a .env file in the same directory as your docker-compose.yml:

POSTGRES_PASSWORD=securepassword123
POSTGRES_USER=webapp
POSTGRES_DB=production
REDIS_PASSWORD=redispass456
API_PORT=3000

Reference these in docker-compose.yml with ${VARIABLE_NAME}:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7
    command: redis-server --requirepass ${REDIS_PASSWORD}

  api:
    image: node-app
    ports:
      - "${API_PORT}:3000"

Docker Compose reads the .env file automatically. Never commit .env to version control — add it to .gitignore and share it securely with your team.

Restart policies: keeping containers alive

By default, a container that exits stays exited. On a VPS, that means your web server stays down until someone logs in and runs docker start. The restart policy fixes this.

services:
  api:
    image: node-app
    restart: unless-stopped

Available policies:

  • no — never restart. Default behavior.
  • always — restart on exit for any reason. The container starts when Docker starts.
  • on-failure — restart only if the container exits with a non-zero code.
  • unless-stopped — restart unless you explicitly stopped it. This is the right choice for most web services.

Build a real stack: Nginx + Node.js + Redis

Here is the complete docker-compose.yml for a web application with a Node API, Redis cache, and Nginx as a reverse proxy.

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    restart: unless-stopped
    networks:
      - appnet

  api:
    build: ./api
    environment:
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      DB_HOST: db
      DB_PASSWORD: ${POSTGRES_PASSWORD}
    depends_on:
      - redis
      - db
    restart: unless-stopped
    networks:
      - appnet

  redis:
    image: redis:7
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    restart: unless-stopped
    networks:
      - appnet

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped
    networks:
      - appnet

networks:
  appnet:

volumes:
  pgdata:
  redisdata:

And the Nginx config:

server {
    listen 80;
    server_name localhost;

    location / {
        resolver 127.0.0.11 valid=10s;
        set $api http://api:3000;
        proxy_pass $api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Two things to note here. First, resolver 127.0.0.11 is Docker’s internal DNS resolver. Without it, nginx resolves the upstream hostname once at startup and caches it. If the upstream container is not ready yet, nginx fails to start. Second, using a variable (set $api) instead of a hardcoded proxy_pass makes nginx resolve the hostname at request time rather than at startup. This is essential when nginx depends on other containers that may not be fully ready when nginx starts.

Build and start the stack:

docker compose up -d --build

The -d flag runs everything in detached mode — the containers run in the background. --build rebuilds the Node.js image from the Dockerfile before starting.

Verify the stack works

Check the status of all services:

docker compose ps

All services should show “running” in green. If one shows “exited”, check its logs:

docker compose logs api
docker compose logs nginx

Tail logs in real time:

docker compose logs -f

Test the Nginx endpoint:

curl http://localhost/

If everything is working, your Node.js API is reachable through Nginx on port 80. Nginx proxies requests to the api container, which connects to Redis and PostgreSQL using the hostnames from the Compose network.

Common mistakes and how to fix them

Service cannot reach another by name. Verify both services are on the same network. If they are in different docker-compose.yml files, either merge them into one network or explicitly declare an external network.

Volume data disappeared after rebuild. Named volumes persist across rebuilds. If your data is gone, the volume was probably not declared correctly or was deleted with docker compose down -v. The -v flag deletes volumes — never use it on a production stack.

Environment variable is empty. Docker Compose reads .env automatically. If the variable is showing as empty, the .env file is missing or the variable name in .env does not match the reference in the compose file exactly.

Container starts but the app inside is broken. Use docker compose logs servicename to see what the process is printing. If the app crashes on startup, the logs tell you why — usually a missing env var, a port conflict, or a failed database connection.

Port already in use when starting nginx. If nginx refuses to start with “address already in use”, another process on your host is using that port. Check with ss -tlnp | grep PORT and either stop the conflicting process or change the host port in your docker-compose.yml.

Nginx fails to resolve the upstream hostname at startup. This happens because nginx resolves DNS once at startup. If the api container is not ready, nginx fails with “host not found in upstream”. The fix is to use resolver 127.0.0.11 and a variable for the proxy_pass URL, as shown in the Nginx config above.

Stop vs down. docker compose stop stops containers without deleting them. docker compose down stops and removes them. docker compose down -v also removes volumes, wiping all data. Be careful with the -v flag on a production stack.

Related guides

Account services are unavailable until the production backend is configured.