Skip to Content
The HIC Learning Exchange begins July 13, 2026. View the agenda
LabItem 4 of 22 · 2 hr

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 4Overview · Lab 1: Provision & Configure · Operations & Troubleshooting

What you'll learn
  • 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
Before you start

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
Your lab values

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.

  1. Open the repo on GitHub → Settings → Secrets and variables → Actions → Variables.
  2. Click New repository variable for each of DEPLOY_HOST, DEPLOY_USER, DEPLOY_PORT, DEPLOY_PATH.
  3. Enter the name and value, then Add variable.
CheckpointDeploy target variables set
You should see:
$ 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 now
Not seeing this?
  • A variable is missing from the list — the corresponding gh variable set command failed silently on an auth issue; run gh auth status and re-run the missing set command.
  • 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
CheckpointDeploy key authorized and secret uploaded
You should see:
$ 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__/mnchtest
Not seeing this?
  • ssh-copy-id reports Number of key(s) added: 0 — the key is already authorized, or you targeted the wrong DEPLOY_PORT/DEPLOY_USER; confirm with ssh -i ./deploy_key -p __DEPLOY_PORT__ __DEPLOY_USER__@__DEPLOY_HOST__.
  • gh secret set hangs or errors on auth — run gh 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_TOKEN needs 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 keyNotes
DB_HOSTDB_HOSTExternal warehouse Postgres host
DB_PORTDB_PORTUsually 5432
DB_USERDB_USERWarehouse DB user
DB_PASSWORDDB_PASSWORDRotate — see Phase 6
PREFECT_DB_NAMEPREFECT_DB_NAMEprefect_db
SUPERSET_DB_NAMESUPERSET_DB_NAMEsuperset_db
DAGSTER_DB_NAMEDAGSTER_DB_NAMEdagster_db
SUPERSET_SECRET_KEYSUPERSET_SECRET_KEYSigns sessions — keep stable
SUPERSET_ADMIN_PASSWORDSUPERSET_ADMIN_PASSWORDSuperset admin login
EBUZIMA_API_KEY / EBUZIMA_API_SECRET / EBUZIMA_BASE_URLsameeBuzima source API
HMIS_USERNAME / HMIS_PASSWORD / HMIS_BASE_URLsameHMIS/DHIS2 source API
CONNECTION_STRINGconnection_stringRead 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.example in 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/health until ready → create the default process work-pool → deploy --all → start the worker (foreground).
  • Dagster — runs dagster-webserver (UI, port 3000) + dagster-daemon (schedules/sensors). It loads the dbt/ project as Dagster assets and materializes them (dbt build) on the dbt_daily_schedule (0 9 * * * Africa/Kigali, after the overnight ingestion window). That schedule is defined with default_status=RUNNINGload-bearing, because Dagster schedules default to stopped. The dbt/ project is baked into the image at build time, so a .sql change ships automatically on the next CI build — no manual dbt run in 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 → create admin (skipped after first run) → superset initsuperset run on 8088. Requires the ./.superset:/app/pythonpath volume mount for superset_config.py, or it silently falls back to a SQLite metadata store.

Port map

ServiceHost portContainer portNotes
Prefect142004200No auth configured by default
Superset180888088Login admin / SUPERSET_ADMIN_PASSWORD
Dagster130003000No 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:

  1. git pull origin main (refresh flow/dbt/config code on the VM),
  2. regenerate .env from the GitHub secrets (Step 3.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:

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:

Open the repo’s Actions tab and click into the running workflow to watch job logs live.

CheckpointCI/CD run finished clean
You should see:
$ 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_KEY doesn’t match the public key authorized on the VM, or DEPLOY_HOST/DEPLOY_PORT/DEPLOY_USER are 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 .env regeneration 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
CheckpointLocal stack builds and starts
You should see:
$ docker compose up -d --build [+] Running 3/3 ✔ Container mnchtest-prefect-1 Started ✔ Container mnchtest-dagster-1 Started ✔ Container mnchtest-superset-1 Started
Not seeing this?
  • docker compose config errors with “variable is not set” — .env wasn’t filled in from .env.example, or a variable name in .env doesn’t match what docker-compose.yml expects.
  • A container exits immediately after starting — check docker compose logs <service>; Superset needs the ./.superset:/app/pythonpath mount or it silently falls back to SQLite.
  • A port fails to bind — something else on your machine already holds 14200, 18088, or 13000; stop it or remap the host port in docker-compose.yml.

Accessing the running services

ServiceURLLogin
Supersethttp://__DEPLOY_HOST__:18088admin / SUPERSET_ADMIN_PASSWORD
Prefecthttp://__DEPLOY_HOST__:14200none configured
Dagsterhttp://__DEPLOY_HOST__:13000none configured

GitHub Actions secrets are write-only — there’s no gh command to read one back. To recover the current Superset password, SSH into the VM and cat .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 test

Or in the Dagster UI (port 13000): Assets → select dbt assets → Materialize.

Deploying Prefect flows manually

cd .prefect prefect deploy --all

Clean 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 one

What’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.