Course 5 · Tooling

Wiring the Mini-Platform

In Course 4 you met the components one at a time — Postgres, a streaming broker, a warehouse, an orchestrator, a BI tool. Here we wire them into a single reproducible system that any teammate can stand up with one command. By the end of this lab you'll have a real, runnable local data platform: source, stream, warehouse, orchestration, and dashboards, all on one private network, all defined in code.

Setup — do this first

You need everything from Course 2 (a working shell, git, and Docker Desktop running) plus chapters 01–02 of this course (you can read and write a Dockerfile and you understand images vs. containers, volumes, and networks). Confirm Docker is alive with docker compose version — if it prints a version, you're ready. Make a fresh folder for this lab: mkdir -p ~/mini-platform && cd ~/mini-platform. Keep this page open beside your terminal.

The goal: stand up the whole platform with one command

So far each tool has been a separate thing you start by hand. That doesn't scale: a real platform is many services that have to find each other, start in the right order, and keep their data between restarts. Wiring them by hand is slow and unrepeatable — exactly the "works on my machine" trap Course 5 exists to kill.

The fix is Docker Compose: one YAML file that declares every service, the network they share, the volumes that persist their data, and the configuration they read. Then a single docker compose up -d brings the entire platform to life — identically, on your laptop or a teammate's. Here is the architecture we're assembling:

┌──────────────────── shared private network ────────────────────┐ │ │ PRODUCERS │ SOURCE STREAM WAREHOUSE ORCHESTRATE │ PRESENT ───────────▶ │ │ ──────────▶ │ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ app / scripts ──────────▶ Postgres │─────▶ Redpanda │ │ Dagster │ │ ┌──────────┐ │ │ (source │ │ (Kafka- │ │ (jobs + │──────▶ Metabase │ │ │ tables)│ │ compat │ │ web UI) │ │ │ (charts │ │ └────┬────┘ │ stream) │ └─────┬─────┘ │ │ & SQL) │ │ │ └────┬─────┘ │ │ └────┬─────┘ │ │ │ in-process │ │ │ │ └────────────────┴───────▶ DuckDB ◀─────────────┘ │ │ │ (warehouse, runs INSIDE │ │ │ the Dagster/dbt containers) │ │ │ │ │ │ │ (optional) ┌───────▼────────┐ │ │ │ │ MinIO │◀─────────────────────┘ │ │ (S3-compatible │ │ │ │ object store) │ │ │ └────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ data flows left → right; every box is one container declared in docker-compose.yml
The payoff

When this file exists, "set up the platform" stops being a half-day of clicking and becomes a 30-second command in a README. That single property — reproducibility — is what separates a demo from a system a team can run.

The services & how they connect

Each box in the diagram is a container (except DuckDB — more on that below). They talk to each other by service name: Compose gives every service a DNS name on the shared network, so Dagster reaches Postgres at the hostname postgres, not localhost. The port column is what we publish to your laptop so you can reach each tool from a browser or psql.

ServiceRole in the platformReached from your laptop at
PostgresThe source — an operational database holding raw application tables we ingest from.localhost:5432 (psql / client)
RedpandaThe stream — a Kafka-compatible broker carrying events between producers and consumers.localhost:9092 (Kafka API)
DuckDBThe warehouse / lakehouse. In-process — not a server. It runs as a library inside your dbt and Dagster containers, reading/writing a single .duckdb file on a shared volume.— (no port; it's a file)
DagsterOrchestration — schedules and runs your jobs (ingest, dbt builds) and serves a web UI for runs and assets.localhost:3000 (web UI)
MetabaseBI / presentation — point-and-click charts and SQL over the warehouse and Postgres.localhost:3001 (web UI)
MinIO (optional)Object storage — an S3-compatible bucket for raw files / a lake layer. Lets you practice the cloud storage pattern locally.localhost:9000 API · 9001 console
Why DuckDB has no container

Server databases (Postgres) run as their own process and you connect over the network. DuckDB is the opposite: it's a library that runs inside whatever process opens it — your dbt run, your Dagster job, a Python script. So there's no "DuckDB service" to start. Instead, every container that uses it mounts the same named volume and opens the same warehouse.duckdb file. The "service" is really just a shared file on disk.

The connections, end to end: scripts write rows into Postgres and/or events onto Redpanda; Dagster runs jobs that read from those sources and load DuckDB (via dbt or Python, in-process); Metabase connects to DuckDB (and Postgres) to draw charts. Optionally raw files land in MinIO first. One private network ties them together.

The docker-compose.yml that wires them

Here is the whole platform in one file. Read it top to bottom — the comments call out each wiring decision. Save it as docker-compose.yml in ~/mini-platform. (Dagster and the dbt-runner reference a small custom image; for now we use a base image and a placeholder command so the file is complete and the rest of the stack runs — you'll flesh those out as you port your Course 4 projects in.)

docker-compose.yml
# Defines the whole local data platform. Bring it up with: docker compose up -d
services:

  # ── SOURCE: operational database we ingest from ───────────────────
  postgres:
    image: postgres:16
    env_file: .env                      # POSTGRES_* read from the environment, not hardcoded
    ports:
      - "5432:5432"                     # publish to your laptop for psql access
    volumes:
      - pgdata:/var/lib/postgresql/data # persist tables across restarts
    networks: [platform]
    healthcheck:                        # "is it actually ready?" — others wait on this
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
      interval: 5s
      timeout: 3s
      retries: 10

  # ── STREAM: Kafka-compatible broker ───────────────────────────────
  redpanda:
    image: redpandadata/redpanda:v24.2.7
    command:
      - redpanda
      - start
      - --smp=1
      - --overprovisioned
      - --kafka-addr=PLAINTEXT://0.0.0.0:9092
      - --advertise-kafka-addr=PLAINTEXT://redpanda:9092
    ports:
      - "9092:9092"
    volumes:
      - redpandadata:/var/lib/redpanda/data
    networks: [platform]
    healthcheck:
      test: ["CMD-SHELL", "rpk cluster health | grep -q 'Healthy:.*true'"]
      interval: 10s
      timeout: 5s
      retries: 10

  # ── ORCHESTRATION: runs jobs + serves the web UI ──────────────────
  # DuckDB runs IN-PROCESS inside this container — note the shared 'warehouse' volume.
  dagster:
    image: python:3.12-slim
    command: ["sleep", "infinity"]      # placeholder; replace with: dagster dev -h 0.0.0.0 -p 3000
    env_file: .env
    environment:
      DUCKDB_PATH: /warehouse/warehouse.duckdb   # the in-process warehouse file
    ports:
      - "3000:3000"                     # Dagster web UI
    volumes:
      - ./dagster:/opt/dagster          # your Dagster project code
      - ./dbt:/opt/dbt                  # your dbt project (runs against DuckDB)
      - warehouse:/warehouse            # the DuckDB file lives here, shared
    networks: [platform]
    depends_on:
      postgres:
        condition: service_healthy      # don't start jobs before the source is ready
      redpanda:
        condition: service_healthy

  # ── BI: charts & SQL over the warehouse ───────────────────────────
  metabase:
    image: metabase/metabase:v0.50.21
    ports:
      - "3001:3000"                     # Metabase serves on 3000 inside; we publish 3001
    volumes:
      - warehouse:/warehouse            # read the same DuckDB file
      - metabasedata:/metabase-data
    environment:
      MB_DB_FILE: /metabase-data/metabase.db
    networks: [platform]
    depends_on:
      postgres:
        condition: service_healthy

  # ── OBJECT STORAGE (optional): S3-compatible bucket ───────────────
  minio:
    image: minio/minio:RELEASE.2024-09-13T20-26-02Z
    command: server /data --console-address ":9001"
    env_file: .env                      # MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
    ports:
      - "9000:9000"                     # S3 API
      - "9001:9001"                     # web console
    volumes:
      - miniodata:/data
    networks: [platform]

# One private network so services reach each other by name (e.g. host "postgres").
networks:
  platform:
    driver: bridge

# Named volumes survive `docker compose down` — your data isn't lost on restart.
volumes:
  pgdata:
  redpandadata:
  warehouse:
  metabasedata:
  miniodata:
Compose featureWhat it buys you
networks: [platform]All services share one private network and resolve each other by service name. No IP juggling.
named volumes:Data persists across down/up. Without them, every restart wipes your tables.
env_file: .envConfiguration (passwords, paths) comes from the environment, not baked into the file.
healthcheckCompose knows when a service is truly ready, not just "process started."
depends_on: condition: service_healthyStart order: Dagster won't run jobs before Postgres and Redpanda pass their health checks.
The shared volume is the trick

Notice warehouse: is mounted into both dagster and metabase. That's how an in-process warehouse becomes "shared" — Dagster writes the DuckDB file, Metabase reads the same file. Two containers, one file, no server.

Configuration & environments

Look back at the Compose file: there are no passwords or hostnames hardcoded. Every changeable value comes from .env via env_file. This is the 12-factor rule: store config in the environment, keep it strictly separate from code. The same image, the same Compose file, runs in dev or prod — only the environment differs.

Create a .env next to your docker-compose.yml:

.env
# ── Postgres (the source database) ────────────────────────────────
POSTGRES_USER=platform
POSTGRES_PASSWORD=dev_only_change_me
POSTGRES_DB=appdb

# ── How services address each other on the shared network ─────────
# (use the service NAME as host, not localhost — containers aren't on your laptop's network)
POSTGRES_HOST=postgres
REDPANDA_BROKER=redpanda:9092

# ── DuckDB warehouse file (in-process) ────────────────────────────
DUCKDB_PATH=/warehouse/warehouse.duckdb

# ── MinIO (optional object storage) ───────────────────────────────
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=dev_only_change_me
Dev (this lab)Prod (later courses)
Where config livesA local .env fileInjected by the platform (CI/CD, k8s secrets, parameter store)
PasswordsThrowaway dev valuesStrong, rotated, pulled from a secret manager
WarehouseDuckDB file in a volumeA managed cloud warehouse (Snowflake/BigQuery)
Object storageLocal MinIOReal S3 / GCS

The win: your code never changes between environments. You swap the environment, and the same platform runs against bigger, managed infrastructure. That's why we wire config this way from day one.

Secrets management

Config and secrets follow the same "in the environment" rule — but secrets carry one extra, non-negotiable habit: never commit them to git. Your .env holds real (even if throwaway) passwords, so it must stay out of version control. Add a .gitignore before your first commit:

.gitignore
# secrets & local config — NEVER commit
.env
*.env.local

# local data & state
*.duckdb
data/
.dagster/

So that teammates know which variables to set without seeing your values, commit a template instead — .env.example with the keys and dummy placeholders, no real secrets:

.env.example
POSTGRES_USER=platform
POSTGRES_PASSWORD=changeme
POSTGRES_DB=appdb
POSTGRES_HOST=postgres
REDPANDA_BROKER=redpanda:9092
DUCKDB_PATH=/warehouse/warehouse.duckdb
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=changeme

A new teammate runs cp .env.example .env, fills in real values, and they're set — without those values ever touching the repo.

⚠ Production does not use committed files

A local .env is fine for a dev laptop. Production secrets never live in any file in the repo — not even encrypted. They're stored in a dedicated secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, or your CI's secret store) and injected as environment variables at deploy time. The mental model is identical to .env — "config in the environment" — only the source of that environment changes. We'll wire real secret managers in the CI/CD and IaC chapters. For now: if a secret ever lands in a commit, treat it as leaked and rotate it.

Bring the whole stack up

You now have four files in ~/mini-platform: docker-compose.yml, .env, .env.example, and .gitignore. Light it up:

terminal
cd ~/mini-platform

# pull images and start everything in the background (-d = detached)
docker compose up -d

# watch the services come up; STATUS should move to "healthy" / "running"
docker compose ps

# tail the logs of one service while it boots (Ctrl-C to stop watching)
docker compose logs -f dagster

Give it a minute on first run while images download and health checks pass. Now reach each service:

terminal
# 1) psql INTO Postgres from your laptop (password is in your .env)
docker compose exec postgres psql -U platform -d appdb -c "SELECT 'source db up' AS status;"

# 2) confirm Redpanda's cluster is healthy
docker compose exec redpanda rpk cluster health

# 3) open the web UIs in your browser
open http://localhost:3000    # Dagster  (use xdg-open on Linux)
open http://localhost:3001    # Metabase (first visit walks you through setup)
open http://localhost:9001    # MinIO console (optional)
✓ You should see

docker compose ps lists every service with STATUS running and Postgres/Redpanda showing (healthy). The psql command prints status | source db up. rpk cluster health reports Healthy: true. localhost:3000 loads the Dagster UI; localhost:3001 shows the Metabase welcome screen. If a service is stuck "starting," run docker compose logs <service> — the error is almost always a typo in .env or a port already in use.

When you're done, docker compose down stops everything but keeps your volumes (your data is safe). Add -v only when you truly want a clean slate: docker compose down -v wipes the named volumes too.

Port already in use?

If up fails with "address already in use," another program owns that port (often a stray Postgres or a previous run). Either stop it, or change the left-hand number in the port mapping — e.g. "5433:5432" publishes Postgres on 5433 instead. The right-hand side (inside the container) stays the same.

Recommended repo structure

One Compose file is enough to start, but as you port your Course 4 projects in, give each piece a home. A clean layout makes the repo self-explanatory and keeps the orchestrator, transformations, and one-off scripts cleanly separated:

mini-platform/ ├── docker-compose.yml ← the wiring: all services, network, volumes ├── .env ← real config & secrets (gitignored — never committed) ├── .env.example ← template of required keys (committed) ├── .gitignore ← keeps .env, data, *.duckdb out of git ├── README.md ← "how to run": copy .env, then `docker compose up -d` │ ├── dagster/ ← orchestration project (jobs, schedules, asset defs) │ └── definitions.py ├── dbt/ ← dbt project: models that build the DuckDB warehouse │ ├── dbt_project.yml │ └── models/ ├── scripts/ ← one-off / ingest helpers (load Postgres, produce events) │ └── seed_postgres.py └── data/ ← local sample data (gitignored)
Why this matters

A stranger should be able to clone this repo, read the README, copy .env.example to .env, run one command, and have the whole platform running. That's the reproducibility principle from "Start Here," made concrete. The folder layout is the map that makes it possible.

✓ Check yourself

  • Can you explain why services address each other by service name (e.g. postgres) rather than localhost?
  • Why does DuckDB have no container or port — and how do two services still "share" it?
  • What's the difference between docker compose down and docker compose down -v?
  • Why is .env gitignored while .env.example is committed?
  • What does depends_on: condition: service_healthy guarantee that a plain depends_on does not?
Exercise — Add a service and confirm it's on the shared network

Add a Redpanda Console (a web UI for inspecting Kafka topics) to the stack, publish it on localhost:8080, and prove it's wired onto the same platform network as the broker.

Append this service to docker-compose.yml, then bring just it up:

docker-compose.yml (add under services:)
  redpanda-console:
    image: redpandadata/console:v2.7.2
    environment:
      KAFKA_BROKERS: redpanda:9092      # reach the broker BY SERVICE NAME
    ports:
      - "8080:8080"
    networks: [platform]               # join the same shared network
    depends_on:
      redpanda:
        condition: service_healthy
terminal
# start the new service (Compose only creates what's missing)
docker compose up -d redpanda-console

# confirm it's running
docker compose ps redpanda-console

# PROVE it's on the shared network: from inside the new container,
# resolve the broker by name and reach it. Success = it's wired in.
docker compose exec redpanda-console nslookup redpanda

# inspect the network and see both containers listed on it
docker network inspect mini-platform_platform --format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}'

# and open the UI
open http://localhost:8080

What you should see: nslookup redpanda resolves to an internal IP — that only works because both containers share the platform network and Compose provides name-based DNS. The docker network inspect command lists both redpanda and redpanda-console (the network name is <project>_platform, where the project defaults to the folder name mini-platform). And localhost:8080 shows the console with your broker's topics. You just extended a running platform by editing one file and re-running up — no service restarted that didn't need to.

Next

Your platform runs reproducibly on one machine — now let's learn the primitives it will run on for real: storage, compute, identity, and cost. → Cloud Fundamentals