Lab 2: CI/CD, Warehouse Stack & Release
In this lab you wire the GitHub Actions pipeline to the VM, understand the three-container warehouse application, and ship it — both continuous deploys and versioned releases.
Part 3 of 4 — Overview · Lab 1: Provision & Configure · Operations & Troubleshooting
- Wire GitHub Actions deploy variables and secrets (SSH key, DB creds) to target the VM
- Understand the three-container warehouse stack (Prefect, Dagster, Superset) and its external Postgres
- Trigger continuous deploys via a push to main and versioned releases via GitHub Releases
- Verify the Docker Compose stack locally before pushing
- Run dbt and Prefect flows manually for development
From Lab 1:
- A provisioned, reachable VM with Docker installed (Lab 1)
For this lab:
- The GitHub CLI (
gh), authenticated (gh auth login) with write/admin access to the repo - SSH key material for the VM’s deploy user (or the ability to create one in Step 3.2)
- Credentials for the external PostgreSQL server and the eBuzima / HMIS/DHIS2 source APIs
- (Optional) Docker + Docker Compose installed locally, to verify the stack before pushing
Fill these once and every command on this page updates. Values stay in your browser only.
A note on values in this guide. Values you need to personalize for this lab —
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PORT, DEPLOY_PATH, GH_OWNER — appear as
__NAME__-style tokens and are filled in automatically once you enter them in the
Your lab values panel above. Other values (e.g. <DB_USER>) aren’t tracked by that panel —
substitute those by hand with your environment’s real values. Keep the real ones in your secret
store / password manager, not in these pages.
Phase 3 — Wire Up CI/CD Secrets & Deploy Target
Runs from your control machine with gh.
The warehouse is not deployed by hand. GitHub Actions (.github/workflows/deploy.yml) builds
the container images, pushes them to GHCR, then SSHes into the VM to pull and restart them. First
you must give the pipeline its target and its secrets.
Step 3.1 — Repository variables (non-secret deploy target)
These tell the workflow where to deploy. They replace values that used to be hardcoded.
GitHub UI
- Open the repo on GitHub → Settings → Secrets and variables → Actions → Variables.
- Click New repository variable for each of
DEPLOY_HOST,DEPLOY_USER,DEPLOY_PORT,DEPLOY_PATH. - Enter the name and value, then Add variable.
$ gh variable list
NAME VALUE UPDATED AT
DEPLOY_HOST __DEPLOY_HOST__ just now
DEPLOY_USER __DEPLOY_USER__ just now
DEPLOY_PORT __DEPLOY_PORT__ just now
DEPLOY_PATH __DEPLOY_PATH__ just nowNot seeing this?
- A variable is missing from the list — the corresponding
gh variable setcommand failed silently on an auth issue; rungh auth statusand re-run the missingsetcommand. - Values still show a literal
__NAME__-style token — you copied a command without filling in Your lab values above.
Step 3.2 — The SSH deploy key secret
Generate a dedicated key, authorize it on the VM, upload the private half as a secret:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""
# Authorize the public key on the VM (match your DEPLOY_* values)
ssh-copy-id -i ./deploy_key.pub -p __DEPLOY_PORT__ __DEPLOY_USER__@__DEPLOY_HOST__
# Upload the private key, then destroy the local copies
gh secret set SSH_PRIVATE_KEY < ./deploy_key
rm ./deploy_key ./deploy_key.pub$ ssh-copy-id -i ./deploy_key.pub -p __DEPLOY_PORT__ __DEPLOY_USER__@__DEPLOY_HOST__
Number of key(s) added: 1
$ gh secret set SSH_PRIVATE_KEY < ./deploy_key
✓ Set Actions secret SSH_PRIVATE_KEY for __GH_OWNER__/mnchtestNot seeing this?
ssh-copy-idreportsNumber of key(s) added: 0— the key is already authorized, or you targeted the wrongDEPLOY_PORT/DEPLOY_USER; confirm withssh -i ./deploy_key -p __DEPLOY_PORT__ __DEPLOY_USER__@__DEPLOY_HOST__.gh secret sethangs or errors on auth — rungh auth status; you need write/admin access on the repo to set Actions secrets.- You skipped
rm ./deploy_key ./deploy_key.pub— the private key is still sitting unencrypted on your control machine; delete both files now that it’s uploaded.
GITHUB_TOKENneeds no setup — GitHub Actions provides it per-run and uses it to push/pull images from GHCR (ghcr.io).
Step 3.3 — Application & database secrets
The deploy job regenerates the VM’s .env on every run from these repo secrets
(cat > .env <<EOF … EOF over SSH). The VM’s .env is disposable generated state — never
hand-edit it. Set each with gh secret set <NAME> --body '<value>':
| Secret | .env key | Notes |
|---|---|---|
DB_HOST | DB_HOST | External warehouse Postgres host |
DB_PORT | DB_PORT | Usually 5432 |
DB_USER | DB_USER | Warehouse DB user |
DB_PASSWORD | DB_PASSWORD | Rotate — see Phase 6 |
PREFECT_DB_NAME | PREFECT_DB_NAME | prefect_db |
SUPERSET_DB_NAME | SUPERSET_DB_NAME | superset_db |
DAGSTER_DB_NAME | DAGSTER_DB_NAME | dagster_db |
SUPERSET_SECRET_KEY | SUPERSET_SECRET_KEY | Signs sessions — keep stable |
SUPERSET_ADMIN_PASSWORD | SUPERSET_ADMIN_PASSWORD | Superset admin login |
EBUZIMA_API_KEY / EBUZIMA_API_SECRET / EBUZIMA_BASE_URL | same | eBuzima source API |
HMIS_USERNAME / HMIS_PASSWORD / HMIS_BASE_URL | same | HMIS/DHIS2 source API |
CONNECTION_STRING | connection_string | Read as-is by Prefect flows |
WAREHOUSE_DB_NAME (optional) | — | Read by dbt/profiles.yml; defaults to mnch_main_db |
The image tags (
PREFECT_IMAGE/SUPERSET_IMAGE/DAGSTER_IMAGE) are exported inline by the deploy job per run — you don’t set them anywhere..env.examplein the app repo is for local dev only, not the VM.
Step 3.4 — GHCR package visibility (first release only)
The first run creates three packages: ghcr.io/__GH_OWNER__/mnchtest/{prefect,superset,dagster}. They
inherit the repo’s visibility, so GITHUB_TOKEN should push/pull fine. If the deploy job’s pull
hits 401/403, open each package’s Settings → Manage Actions access and give the repo at
least Write access.
Phase 4 — The Warehouse Application
This is what actually runs on the VM.
Three Docker containers run on the VM via docker-compose.yml, all connecting out to the external
PostgreSQL server from Phase 3.
Runtime topology
What each container does:
- Prefect — runs the Prefect server (API + UI) and a worker in one container. The worker
executes ~60 ingestion deployments (from
.prefect/prefect.yaml) on cron schedules, writing into the warehouse’s landing schema. Startup command, in order: start server → poll/api/healthuntil ready → create thedefaultprocess work-pool →deploy --all→ start the worker (foreground). - Dagster — runs
dagster-webserver(UI, port 3000) +dagster-daemon(schedules/sensors). It loads thedbt/project as Dagster assets and materializes them (dbt build) on thedbt_daily_schedule(0 9 * * *Africa/Kigali, after the overnight ingestion window). That schedule is defined withdefault_status=RUNNING— load-bearing, because Dagster schedules default to stopped. Thedbt/project is baked into the image at build time, so a.sqlchange ships automatically on the next CI build — no manualdbt runin prod. Startup:dbt parse(needs runtime DB creds, so it happens at container start) →dagster-daemon(background) →dagster-webserver(foreground). - Superset — reads the dbt-built marts to render dashboards; stores its own state in
superset_db. Startup:superset db upgrade→ createadmin(skipped after first run) →superset init→superset runon 8088. Requires the./.superset:/app/pythonpathvolume mount forsuperset_config.py, or it silently falls back to a SQLite metadata store.
Port map
| Service | Host port | Container port | Notes |
|---|---|---|---|
| Prefect | 14200 | 4200 | No auth configured by default |
| Superset | 18088 | 8088 | Login admin / SUPERSET_ADMIN_PASSWORD |
| Dagster | 13000 | 3000 | No auth configured by default |
These are the ports your reverse proxy (Step 1.5 / playbook 02) forwards to.
dbt data model
Data quality is measured across six dimensions: accuracy, completeness, consistency, timeliness, uniqueness, validity.
Data sources:
- eBuzima (ERPNext-based EMR) — individual-level ANC/maternity/newborn records, ingested daily.
- HMIS/DHIS2 — facility-level monthly aggregates, ingested on the 20th of each month.
Phase 5 — Deploy & Release
With Phases 1–2 and Phase 3 done, deployment is triggered from GitHub — no builds ever happen on the VM.
Continuous deploy (push to main)
A plain push to main builds :latest images, pushes to GHCR, then SSHes into the VM to:
git pull origin main(refresh flow/dbt/config code on the VM),- regenerate
.envfrom the GitHub secrets (Step 3.3), docker compose pull && docker compose up -d --remove-orphans.
Cutting a versioned release
Releases are triggered by publishing a GitHub Release, not by pushing a tag directly:
GitHub UI
Releases → Draft a new release, type a new tag (e.g. v1.0.0), add notes, Publish release.
This builds and pushes ghcr.io/__GH_OWNER__/mnchtest/{prefect,superset,dagster}:v1.0.0 (and updates
:latest), then deploys that pinned version to the VM.
Watch progress:
GitHub UI
Open the repo’s Actions tab and click into the running workflow to watch job logs live.
$ gh run watch
✓ deploy in 1m42s (ID 123456789)Not seeing this?
- The run fails on the push-to-GHCR step — GHCR package visibility isn’t set to at least Write for the repo yet (Step 3.4); check each package’s Settings → Manage Actions access.
- The run fails on the SSH step —
SSH_PRIVATE_KEYdoesn’t match the public key authorized on the VM, orDEPLOY_HOST/DEPLOY_PORT/DEPLOY_USERare wrong; re-check Steps 3.1 and 3.2. - The run succeeds but the containers don’t change — SSH into the VM and check
docker compose ps/docker compose logs; the.envregeneration may have failed because a secret from Step 3.3 is missing or misnamed.
Local verification before pushing
cp .env.example .env # fill in real values (local dev only)
docker compose config # confirms env-var interpolation resolves
docker compose up -d --build$ docker compose up -d --build
[+] Running 3/3
✔ Container mnchtest-prefect-1 Started
✔ Container mnchtest-dagster-1 Started
✔ Container mnchtest-superset-1 StartedNot seeing this?
docker compose configerrors with “variable is not set” —.envwasn’t filled in from.env.example, or a variable name in.envdoesn’t match whatdocker-compose.ymlexpects.- A container exits immediately after starting — check
docker compose logs <service>; Superset needs the./.superset:/app/pythonpathmount or it silently falls back to SQLite. - A port fails to bind — something else on your machine already holds
14200,18088, or13000; stop it or remap the host port indocker-compose.yml.
Accessing the running services
| Service | URL | Login |
|---|---|---|
| Superset | http://__DEPLOY_HOST__:18088 | admin / SUPERSET_ADMIN_PASSWORD |
| Prefect | http://__DEPLOY_HOST__:14200 | none configured |
| Dagster | http://__DEPLOY_HOST__:13000 | none configured |
GitHub Actions secrets are write-only — there’s no
ghcommand to read one back. To recover the current Superset password, SSH into the VM andcat .env, or check your team’s secret record.
Running dbt manually (development)
In production dbt runs automatically via Dagster’s daily schedule. To run manually while developing:
cd dbt
dbt run
dbt testOr in the Dagster UI (port 13000): Assets → select dbt assets → Materialize.
Deploying Prefect flows manually
cd .prefect
prefect deploy --allClean up
Skip this entire section if you’re continuing to Operations & Troubleshooting — that lesson works against the live stack, secrets, and images you just deployed. Come back here only when you’re done with the whole track.
Full teardown of everything this lab deployed:
# stop and remove the running containers on the VM
ssh __DEPLOY_USER__@__DEPLOY_HOST__ -p __DEPLOY_PORT__ "cd __DEPLOY_PATH__ && docker compose down"
# delete the GHCR packages if you no longer need the images
gh api -X DELETE /orgs/__GH_OWNER__/packages/container/mnchtest%2Fprefect
gh api -X DELETE /orgs/__GH_OWNER__/packages/container/mnchtest%2Fsuperset
gh api -X DELETE /orgs/__GH_OWNER__/packages/container/mnchtest%2Fdagster
# clear the repo secrets and variables set in Phase 3
gh secret list | awk '{print $1}' | xargs -n1 gh secret delete
gh variable list | awk '{print $1}' | xargs -n1 gh variable delete
docker compose down # stop the local verification stack, if you started oneWhat’s next
The stack is live. Finish with Operations, Troubleshooting & Glossary for day-two tasks — credential rotation, TLS renewal, snapshots — and the cross-layer troubleshooting table.