Running the Stack Locally

Docker Compose for the app, Postgres and Redis, with depends_on waiting on a real readiness check instead of a guess.

Syntaxdocker compose up --build

A service is rarely alone. It needs a database, usually a cache, sometimes a queue. Docker Compose describes that whole set in one compose.yaml file and starts it with one command. Each entry under services: is one container, and they share a network where each service's name is its hostname: the app reaches Postgres at db.

Waiting for ready, not for started

depends_on: [db] only waits for the database container to start. Postgres takes several more seconds to accept connections, so the app's first query fails. The fix is a healthcheck on the database, a command the container runs repeatedly until it succeeds, plus the long form condition: service_healthy. Now Compose holds the app back until the check passes.

Keeping local close to production

Use the same image your pipeline builds, the same port, and configuration through environment variables rather than an appsettings.Development.json that only exists on your machine. The closer the two are, the more a local failure tells you about the real one.

What trips people up

Named volumes. Without one, a docker compose down takes the database's data with it; with one, a schema change you later rename is still sitting there confusing you. docker compose down -v clears it deliberately.

Example

Example · csharp
// Program.cs - the api service in the compose file below.
var builder = WebApplication.CreateBuilder(args);

// Compose sets ConnectionStrings__Postgres and ConnectionStrings__Redis in
// the container's environment. A double underscore is how an environment
// variable spells the nested key ConnectionStrings:Postgres.
string? postgres = builder.Configuration.GetConnectionString("Postgres");
string? redis = builder.Configuration.GetConnectionString("Redis");

// Report whether each dependency is configured, never the value: a
// connection string holds a password.
string[] status =
[
    postgres is null ? "postgres: missing" : "postgres: configured",
    redis is null ? "redis: missing" : "redis: configured"
];

var app = builder.Build();

app.MapGet("/health", () => Results.Ok(status));

app.Run();

When to use it

  • Give a new team member one command that brings up the API, its database and its cache.
  • Run integration tests against a real Postgres instead of an in-memory substitute.
  • Reproduce a bug that only appears when the app talks to the cache over a network.

More examples

compose.yaml

The whole local stack. docker compose up --build brings it up in dependency order, and curl http://localhost:8080/health then returns ["postgres: configured","redis: configured"]. The password is a throwaway development value written in plain text on purpose: a real one belongs in a secret, which the configuration lessons cover.

Example · yaml
services:
  api:
    build: .
    ports:
      # host port 8080 to container port 8080, the images' default
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      # "db" and "cache" are the service names below, used as hostnames
      ConnectionStrings__Postgres: "Host=db;Database=inventory;Username=postgres;Password=devpassword"
      ConnectionStrings__Redis: "cache:6379"
    depends_on:
      # long form: wait for the healthcheck to pass, not just for a start
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: inventory
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d inventory"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      # postgres:18 keeps its data in /var/lib/postgresql/18/docker and
      # declares its volume at /var/lib/postgresql, so mount that path.
      - pgdata:/var/lib/postgresql

  cache:
    image: redis:8
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

Shorthand and longhand for depends_on

The list form and the mapping form are the same key. Recognise the list form as service_started in disguise, because that is the single most common cause of a stack that works on the second docker compose up and fails on the first.

Example · yaml
    # Shorthand. Waits only until the db container has started, which for
    # Postgres is several seconds before it accepts a connection.
    depends_on:
      - db

    # Longhand. The same dependency, plus the condition to wait for.
    # service_started reproduces the shorthand exactly.
    depends_on:
      db:
        condition: service_started

    # What you actually want: hold the app until the healthcheck passes.
    depends_on:
      db:
        condition: service_healthy

The commands worth knowing

down and down -v are the pair to internalise. Reach for down -v when a migration failed halfway and left the schema in a state no migration expects, and it is faster to start the database from nothing than to unpick it.

Example · bash
# Build the api image and start everything in dependency order.
docker compose up --build

# Follow just the app's logs while the rest keeps running.
docker compose logs -f api

# Run a one-off command in the stack, against the real database. The
# --rm flag removes the throwaway container afterwards.
docker compose run --rm api dotnet ef database update

# Stop and remove the containers. The pgdata volume survives this.
docker compose down

# Stop and remove the containers AND the named volumes: a clean database.
docker compose down -v

Discussion

  • Be the first to comment on this lesson.