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.
docker compose up --buildA 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
// 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.
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.
# 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_healthyThe 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.
# 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