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.
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:
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.
| Service | Role in the platform | Reached from your laptop at |
|---|---|---|
| Postgres | The source — an operational database holding raw application tables we ingest from. | localhost:5432 (psql / client) |
| Redpanda | The stream — a Kafka-compatible broker carrying events between producers and consumers. | localhost:9092 (Kafka API) |
| DuckDB | The 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) |
| Dagster | Orchestration — schedules and runs your jobs (ingest, dbt builds) and serves a web UI for runs and assets. | localhost:3000 (web UI) |
| Metabase | BI / 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 |
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.)
# 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 feature | What 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: .env | Configuration (passwords, paths) comes from the environment, not baked into the file. |
healthcheck | Compose knows when a service is truly ready, not just "process started." |
depends_on: condition: service_healthy | Start order: Dagster won't run jobs before Postgres and Redpanda pass their health checks. |
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:
# ── 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 lives | A local .env file | Injected by the platform (CI/CD, k8s secrets, parameter store) |
| Passwords | Throwaway dev values | Strong, rotated, pulled from a secret manager |
| Warehouse | DuckDB file in a volume | A managed cloud warehouse (Snowflake/BigQuery) |
| Object storage | Local MinIO | Real 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:
# 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:
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=changemeA new teammate runs cp .env.example .env, fills in real values, and they're set — without those values ever touching the repo.
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:
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 dagsterGive it a minute on first run while images download and health checks pass. Now reach each service:
# 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)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.
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:
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 thanlocalhost? - Why does DuckDB have no container or port — and how do two services still "share" it?
- What's the difference between
docker compose downanddocker compose down -v? - Why is
.envgitignored while.env.exampleis committed? - What does
depends_on: condition: service_healthyguarantee that a plaindepends_ondoes 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:
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# 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:8080What 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