Containerizing a Full-Stack Application

I show how one Docker Compose file connects a frontend, API, and database — covering service names as hostnames, per-service env vars, and dev versus prod override files.

Containerizing a Full-Stack Application

You’ve containerized each piece individually. The API image builds. The frontend image builds. The Postgres image works on its own. So you run all three, the API tries to connect to localhost:5432, and nothing answers.

That’s when the localhost mental model breaks. Inside a container, localhost points to the container itself — not the host machine, and certainly not another container. The database is running in a separate isolated process, and the two containers don’t know each other exists unless you wire them together.

Quick answer: One compose.yaml connects all three services. Docker creates a shared bridge network and registers each service name with an internal DNS server. Your API reaches the database at db:5432, not localhost:5432. A per-service environment: block with ${VAR} interpolation handles per-environment configuration.

Series: Part 4 of 4. Start with Part 1: Docker for Developers: From Image to Container · Previous: Docker Compose for Local Development

On this page

One compose.yaml for all three services

Compose reads a single compose.yaml file and starts every service it describes. Here’s a minimal but complete structure for a three-tier app: a Postgres database, a Node API (like the one in Building a REST API with Node.js and Express), and a Next.js frontend:

services:
  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    build:
      context: ./api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/appdb
      PORT: 4000
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "4000:4000"

  web:
    build:
      context: ./web
    restart: unless-stopped
    depends_on:
      - api
    ports:
      - "3000:3000"

volumes:
  db-data:

Three decisions embedded in this file are worth naming. The top-level volumes: key declares a named volume for Postgres data — without it, the database resets on every container restart. The depends_on: keys express a startup dependency order. And restart: unless-stopped means a crashed container restarts automatically unless you explicitly stopped it.

One decision to make early: what goes in context:. That path points to the directory Docker packages and sends to the build daemon when building an image. Every file in that directory that you haven’t excluded goes along for the ride — more on that in a moment.

Service discovery: how containers find each other

When docker compose up runs, Compose creates a bridge network named <project-name>_default and attaches every service to it. Each service registers its name with Docker’s internal DNS server. So db, api, and web become valid hostnames within that network — automatically, with no extra configuration.

That’s why DATABASE_URL in the API service uses @db:5432 instead of @localhost:5432. The API container sends a DNS query for db and gets back the current IP of the database container. Docker assigns container IPs dynamically on each restart, so you should always reference containers by service name, not by address. The name stays stable; the IP doesn’t.

The ports: mapping works differently. "4000:4000" maps port 4000 on the host to port 4000 inside the container. That lets you reach the API from your browser or terminal with localhost:4000. But from inside the Docker network, the address is api:4000 — not localhost:4000.

This distinction is where frontend setups trip up. If your Next.js application makes API calls from the browser, those requests originate from the user’s machine, outside the Docker network entirely. A browser-side fetch("http://api:4000/...") fails because the browser has no access to Docker’s internal DNS. Browser-side calls must go to http://localhost:4000. Server-side code within the container — Next.js server components, route handlers, or any server-only data fetching — can use http://api:4000 because it runs inside the network.

Check this before moving on

  • DATABASE_URL in your API uses the service name db, not localhost
  • Browser-side API calls point to the host port (localhost:4000), not the service name
  • depends_on.condition: service_healthy is set for any service that connects to the database at startup

Environment variables per service

Two approaches exist for passing environment variables to a Compose service.

The environment: block is the direct approach — list key-value pairs inside the Compose file:

api:
  environment:
    PORT: 4000
    NODE_ENV: production
    DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/appdb

The ${POSTGRES_PASSWORD} syntax is Compose variable interpolation. Compose reads the value from a .env file in the same directory as your compose.yaml, or from the shell environment at startup. The .env file is not passed into the container — Compose reads it to resolve the placeholders, then the resolved value is what the container environment actually receives.

The env_file: key is the alternative. It points to a file whose contents are loaded directly into the container’s environment:

api:
  env_file:
    - .env.api

The distinction matters in practice. env_file exposes every line in the file to the container. The environment: block with ${VAR} interpolation keeps your variable names visible in the Compose file while keeping the actual secrets in a separate .env file. For most projects, the environment: block is the cleaner choice — the structure of your configuration is readable, and the sensitive values stay separate.

Don’t commit .env. Add it to .gitignore now. Commit .env.example with variable names and placeholder values so another developer can reproduce the setup without guessing what the app needs.

Development and production Compose files

One compose.yaml can serve both environments if you separate the concerns correctly.

The base compose.yaml defines services, images, and volumes. A compose.override.yaml in the same directory is merged automatically when you run docker compose up without flags. Put development-only settings there — source code bind mounts, exposed database ports, and debug flags:

# compose.override.yaml — merged automatically in development
services:
  api:
    volumes:
      - ./api:/app
      - /app/node_modules
    environment:
      NODE_ENV: development

  db:
    ports:
      - "5432:5432"

For production, create a separate compose.prod.yaml that overrides only what needs to change:

# compose.prod.yaml
services:
  api:
    image: registry.example.com/api:1.0.0
    restart: always

  web:
    image: registry.example.com/web:1.0.0
    restart: always

Deploy with both files:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Compose merges them in order. Single-value fields like image and restart are replaced by the second file. Lists like volumes are merged together.

The key difference between dev and prod: development uses build: ./api and bind mounts source code into the container for live reloading. Production uses a pre-built, tagged image with no bind mounts and stricter restart policies. Keeping these in separate files means a development bind mount can never accidentally land in a production deployment.

Keep your build context small

When Compose builds an image, it packages the entire context: directory and sends it to the Docker daemon. Without a .dockerignore file, that includes node_modules/, .git/, test fixtures, coverage reports, and log files — potentially hundreds of megabytes that the Dockerfile never actually uses.

Add a .dockerignore to each service’s context directory:

node_modules
.git
.env
*.log
dist
.next
build
coverage

The build speed difference is significant. A context that includes node_modules/ can easily be 400 MB. Without it, the same context might be 2 MB. There’s also a correctness reason: if node_modules/ ends up in the context, native modules compiled for your host machine’s architecture land inside the Linux container. Those often fail silently or throw cryptic errors. Excluding node_modules/ and letting the Dockerfile install dependencies fresh avoids the problem entirely.

Each service’s context directory needs its own .dockerignore. The file applies relative to the context root, so ./api/.dockerignore and ./web/.dockerignore are independent files — each should list what’s irrelevant to that specific service.

What breaks first — and how to fix it

The database isn’t ready when the API starts. depends_on: waits for the container to reach a running state, not for the service inside it to accept connections. Postgres takes several seconds to initialise its data directory on a fresh volume. If the API starts immediately and attempts a connection, it fails — and depending on your ORM or connection library, it might crash rather than retry.

The fix is a healthcheck on the database and a condition on the dependent service:

db:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
    interval: 5s
    timeout: 5s
    retries: 5

api:
  depends_on:
    db:
      condition: service_healthy

pg_isready exits with code 0 when Postgres can accept connections. Compose polls every 5 seconds and waits until 5 consecutive checks pass before starting the API. Startup takes a bit longer, but the connection attempt succeeds.

The .env file is in the wrong directory. Compose looks for .env in the same directory as compose.yaml, not in a subdirectory. If you run docker compose up from a parent directory, Compose won’t find .env inside ./app/. The ${POSTGRES_PASSWORD} placeholder resolves silently to an empty string, and Postgres rejects the connection with an authentication error — a symptom that doesn’t point at the actual cause. Run docker compose config before starting containers; it prints the fully merged configuration with all interpolated values, so you see empty strings before any container tries to use them.

Port conflicts on the host. Mapping "5432:5432" for the database container will clash with a locally installed Postgres. The compose startup fails with bind: address already in use. Use a different host port in the development override: "5433:5432". Services inside the Docker network still reach the database at db:5432 — the container port doesn’t change.

One file, one consistent deployment unit

Before Compose, starting a three-tier app locally meant remembering to: start Postgres, export DATABASE_URL, start the API, export the API URL, start the frontend — in the right order, every time. One forgotten step and the app starts in a broken state with no obvious error.

Compose replaces that sequence with one command that reads the dependency graph from a file. The configuration expresses what depends on what; you don’t have to hold that in your head.

The localhost replacement isn’t a limitation to work around. It’s the actual model. Service names are stable hostnames because they’re registered with DNS, not tied to an IP address that changes on every restart. DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/appdb works the same way in development, staging, and production as long as the service is named db and listens on port 5432 inside the container. That consistency is what makes Compose files useful beyond local development — the same file, or a slight production overlay of it, describes the whole thing.

If you want to understand what Postgres itself is doing inside that container, PostgreSQL for Developers covers the database side in more depth.

Sources