Run a dbt Project as a Flight via GitHub Archive
I want one deployed Flight that builds my dbt project on MotherDuck on a schedule and keeps a queryable history of build and test health — per-model status, timing, and test pass/fail over time — instead of just running dbt and losing the results. Help me adapt the "Run a dbt Project as a Flight via GitHub Archive" recipe to my own dbt repo and use case, using it as a guide: https://motherduck.com/docs/cookbook/flight-dbt-build-gh-archive
A single-file Flight that runs a dbt project on MotherDuck and records what
happened — downloading the dbt project as a GitHub archive over HTTPS at run
time (no git binary needed) and appending dbt's own run_results.json to a
snapshot table, one row per node per run. The source repository, commit, and
subdirectory are constants in flight.py. To build another project, change those
constants and deploy a new Flight version.
This is the dbt-only sibling of flight-dbt-metricflow,
reusing the same HTTPS-archive fetch engine; where that template runs mf query
against a semantic model, this one runs plain dbt build and snapshots the
results.
How it works
A Flight runs as a single flight.py in a fresh, torn-down container.
Embedding a copy of the dbt project would drift from the canonical example, so
flight.py downloads the project over HTTPS at run time (stdlib only — no
clone, no repo history) and shells out to the dbt CLI against it. (The
runtime does preinstall git; flight-dbt-build-git
is the sibling that clones instead.)
- Read config from the environment. Flight
configkeys arrive as environment variables. - Connect to MotherDuck (
md:) andCREATE DATABASE IF NOT EXISTSthe snapshot database. - Download the pinned repository commit as a gzip archive into a temp directory
and extract it. With no
GIT_TOKENsecret, the Flight uses the public…/archive/<ref>.tar.gzendpoint; with one it uses the authenticated GitHub API tarball endpoint (api.github.com/repos/<owner>/<repo>/tarball/<ref>). - Discover the dbt project (
dbt_project.yml) and its profile (profiles.yml) inside the pinned subdirectory. - Run dbt with
--target DBT_TARGET.RUN_MODE=buildrunsdbt seedthendbt build(run + test);RUN_MODE=testrunsdbt testonly. The project's ownprofiles.ymlis used as-is; its MotherDuck target reads the database name from anenv_var(), which the Flight feeds usingDB_ENV_VAR, so models land inMODELS_DATABASE. - Parse
target/run_results.jsonand append one row per dbt node to the snapshot table, tagged withrun_at, the git repo/ref, the dbt version, and the run mode. The full node is kept as aJSONcolumn; typed columns (status,execution_time,failures, …) are pulled out for easy querying.
config (env) ── download pinned archive ── dbt build/test ── run_results.json ── snapshot table
per-run values fixed source commit on MotherDuck one row per node append, JSON, run_at
Build vs test mode
RUN_MODE picks what the Flight does with the fetched project:
RUN_MODE=build(default) runsdbt seedthendbt build— priming the seed tables, then materializing and testing every model. This is the Flight that owns the build: it materializes the tables and records every node's result. (Seeding first is deliberate; see the cold-start caveat below.)RUN_MODE=testrunsdbt testonly, for when a separate job already built the tables. It does not write models, but the Flight still creates databases when needed and appendsrun_results.jsonrows. PointPROJECT_REPOandPROJECT_REFat the same project version as the build job.
SELECT scopes either mode to a subset of nodes (for example tag:nightly);
empty means the whole project.
The failure policy
dbt exits non-zero both when a model fails to build and when a test assertion fails. Those are different events, and the Flight treats them differently:
- A node error — a model, seed, or snapshot that failed to build, or a test
that errored (status
error) — fails the Flight run. Something is broken. - A test failure — a test whose assertion did not hold (status
fail) — is recorded in the snapshot table but does not fail the run. Scheduled quality drift is a trend to watch, not a page in the middle of the night.
So the Flight runs dbt tolerating its non-zero exit, always writes the snapshot,
and only then re-raises if any node actually errored. The one hard failure with
nothing to record is dbt not producing run_results.json at all (a parse or
compile error before any node ran).
The config-override pattern
A Flight's config is a MAP(VARCHAR, VARCHAR) of non-secret values, injected
as environment variables. You override it per run without editing or
re-versioning the Flight. Source selection is not part of config.
-- create once with default config
FROM MD_CREATE_FLIGHT(
name := 'dbt_build',
source_code := '...flight.py...',
requirements_txt := '...requirements.txt...',
config := MAP {
'RUN_MODE': 'build',
'DBT_TARGET': 'prod',
'DB_ENV_VAR': 'MOTHERDUCK_DATABASE',
'MODELS_DATABASE': 'analytics_flight',
'SNAPSHOT_DATABASE': 'analytics_flight',
'SNAPSHOT_TABLE': 'dbt_run_results'
}
);
-- run with a one-off override: test-only, same Flight
FROM MD_RUN_FLIGHT(
flight_id := '…',
config := MAP {'RUN_MODE': 'test'}
);
The override is merged over the stored config — provided keys win, omitted keys keep the Flight default. Keys must already exist on the Flight; a per-run override changes values, it cannot introduce a new key.
The snapshot table
Every run appends one row per dbt node, so the table is a time series of build
and test health. The full node is stored as JSON, so the schema survives any
project or node selection, while typed columns make the common queries easy. To
find tests that are failing across the most recent runs:
SELECT run_at, unique_id, status, failures
FROM dbt_churn_flight.dbt_run_results
WHERE resource_type = 'test' AND status = 'fail'
ORDER BY run_at DESC;
resource_type (model, seed, test, snapshot, …), execution_time, and
rows_affected are all columns too, so the same table answers "which model is
getting slower?" and "what did last night's build touch?".
Questions to answer
- Which repository commit and subdirectory hold the dbt project?
- Should each run build the project (
RUN_MODE=build) or only test an already-built one (RUN_MODE=test)? - What target does the project's
profiles.ymldefine (DBT_TARGET), and which env var does that target read the database name from (DB_ENV_VAR)? - Which database and table should hold the models (
MODELS_DATABASE) and therun_resultshistory (SNAPSHOT_DATABASE/SNAPSHOT_TABLE)? - Should the build run on a schedule, and at what cadence?
Caveats
- Run-time network to GitHub only. The Flight downloads the project archive over HTTPS at run time, so it needs egress to GitHub — and the archive endpoints are GitHub-specific. For a non-GitHub host (GitLab, Bitbucket, self-hosted) use the git-clone sibling flight-dbt-build-git instead.
- A private repo needs a
GIT_TOKENsecret. Without one, the public…/archive/<ref>.tar.gzURL 404s on a private repo. Store a token in aTYPE flightssecret (see Deploy as a Flight); the Flight then uses the authenticated API endpoint. - The build runs every time, and concurrent builds conflict. With
RUN_MODE=build, each run doesdbt buildagainst the same database, so a large project is slower and parallelbuildruns collide on the shared tables (MotherDuck rejects the losers with a write-write conflict). For fan-out useRUN_MODE=test. It does not build models, but it still writes the snapshot. - Build mode seeds before it builds. The default project's staging models
read the seed tables as dbt
source()s, and dbt does not sequence a model after a seed it only reads as a source. SoRUN_MODE=buildrunsdbt seedfirst and thendbt build, which is what makes a cold first run on a brand-newMODELS_DATABASEsucceed rather than error on models that ran before the seeds.RUN_MODE=testassumes the tables already exist and does no seeding. - Test failures are recorded but do not fail the Flight — model errors do. A
failing test assertion (status
fail) lands in the snapshot table and the run stays green; a model, seed, or snapshot that errors (statuserror) fails the run. Watch quality drift by querying the table, not by watching for red runs. - The project's profile must match your config. Its
profiles.ymlmust expose the target database throughenv_var()matchingDB_ENV_VAR, and it must define the target you pass asDBT_TARGET. The defaultdbt-churn-predictionreadsMOTHERDUCK_DATABASEand has aprodtarget, which is whyDB_ENV_VAR/DBT_TARGETdefault to those values. - Keep the token out of config. The runtime attaches a MotherDuck token and
injects it as
MOTHERDUCK_TOKEN; never place a token inconfig.
What you'll adjust
read_config() reads the per-run settings. Set them as Flight config. Change
PROJECT_REPO, PROJECT_REF, or PROJECT_SUBDIR in flight.py and deploy a
new version to build another project.
| Config key | Default | Purpose |
|---|---|---|
RUN_MODE | build | build: dbt build (seed + run + test). test: dbt test when another job owns the build. |
DBT_TARGET | prod | The target in the project's profiles.yml to run against (dbt --target). |
DB_ENV_VAR | MOTHERDUCK_DATABASE | The env var the profile's target reads its database name from, using env_var(). |
MODELS_DATABASE | dbt_churn_flight | Database the models build into; fed to the profile through DB_ENV_VAR. |
SELECT | (empty) | Optional dbt node selection, e.g. tag:nightly. Empty runs the whole project. |
SNAPSHOT_DATABASE | dbt_churn_flight | Database holding the run_results history. Created if missing. |
SNAPSHOT_TABLE | dbt_run_results | Append-only table of node results (run_at, config, typed columns, node JSON). |
GIT_TOKEN is not a config key — it is a secret. Store it in a TYPE flights secret (see Deploy as a Flight) so it never lands
in the Flight's config MAP or the logs. Public repos need no token at all.
MOTHERDUCK_TOKEN is likewise not config: the runtime injects it automatically
at run time.
Run it
You need a MotherDuck account and an access token. The default repo/ref make a fresh deploy produce a successful run with no other credentials.
Smoke-test locally before deploying (this downloads the project over HTTPS,
builds it in your account, and appends one batch of rows to dbt_run_results):
export MOTHERDUCK_TOKEN=your_token_here
uv run --with-requirements requirements.txt flight.py
Override a run setting inline. To use another project, change the PROJECT_*
constants and deploy a new Flight version. For a private repo, set GIT_TOKEN as
a bare environment variable locally.
RUN_MODE=test \
uv run --with-requirements requirements.txt flight.py
Deploy as a Flight
Create the Flight with MD_CREATE_FLIGHT (no deploy SQL is checked in; adapt the
arguments), passing:
name: a Flight name, for exampledbt_buildsource_code: the contents offlight.pyrequirements_txt: the contents ofrequirements.txtconfig: aMAPofRUN_MODE,DBT_TARGET/DB_ENV_VAR,MODELS_DATABASE, andSNAPSHOT_DATABASE/SNAPSHOT_TABLEflight_secret_names(private repos only): theTYPE flightssecrets to inject, e.g.['git_auth']. Required for a private repo — see below.
A MotherDuck token is attached automatically and injected at run time as
MOTHERDUCK_TOKEN; no token argument is needed. For a private dbt repo, two
steps are both required — create the secret and attach it to the Flight.
First create a TYPE flights secret holding a GitHub token with Contents:
Read-only on that repo. Flights-secret params go inside a PARAMS MAP (a bare
GIT_TOKEN '…' property is rejected with Unknown parameter 'git_token'):
CREATE SECRET git_auth IN motherduck (
TYPE flights,
PARAMS MAP {'GIT_TOKEN': 'github_pat_...'}
);
Then attach it by passing flight_secret_names := ['git_auth'] to
MD_CREATE_FLIGHT. At run time the runtime injects each param as
<secret_name>_<PARAM> (here git_auth_GIT_TOKEN); resolve_secret in
flight.py matches any env var ending in _GIT_TOKEN, so the secret name is yours
to choose. Only when the secret is both created and attached does the Flight
take the authenticated GitHub API archive endpoint — miss either step and it falls
back to the public endpoint, which 404s on a private repo. If the repo lives in a
SAML-protected org, the token must also be SSO-authorized for that org (an
unauthorized token 403s).
Create the Flight without a schedule first, trigger one manual run with
MD_RUN_FLIGHT(flight_id := …) (the id is returned by MD_CREATE_FLIGHT and
listed by MD_LIST_FLIGHTS()), and confirm the snapshot database and a batch of
dbt_run_results rows appear. Once green, add a schedule by updating
schedule_cron with MD_UPDATE_FLIGHT (0 6 * * *, 06:00 UTC daily, is a
reasonable default); schedule updates are metadata-only and do not create a new
version.
Security
- Identifier safety.
SNAPSHOT_DATABASEandSNAPSHOT_TABLEflow intoCREATE/INSERTstatements that cannot be parameterized, so each is double-quote-escaped (_ident) before any SQL runs. - Parameterized data. Every value written into the snapshot row (the git
repo/ref, the run mode, and the node results parsed from
run_results.json) is passed as a bound parameter, never string-formatted into SQL. - Pinned source. The Flight downloads the commit in
PROJECT_REF. Per-run config cannot change the code it executes. Change aPROJECT_*constant and deploy a new version to use another source. - Token in a secret, in the header. A private repo's
GIT_TOKENbelongs in aTYPE flightssecret, never inconfig(which is stored on the Flight and logged). At run time the token is sent in theAuthorizationheader of the GitHub API request, not in the URL, so it does not reach the Flight logs.
Learn more
- Flight mechanics (creating, running, scheduling, secrets): the MotherDuck MCP
get_flight_guidetool. - The local, terminal-driven dbt example this Flight builds by default: dbt-churn-prediction.
- The MetricFlow sibling of this template, which builds the same fetch engine
around
mf query: flight-dbt-metricflow. - The git-clone sibling of this template, which fetches with
gitinstead of the GitHub archive endpoint (any git host, per-commitgit_shaprovenance): flight-dbt-build-git. - Deeper MotherDuck or DuckDB questions: the
ask_docs_questionMCP tool. - Files in this template:
flight.py(the single-file Flight that downloads the project over HTTPS, runs dbt, and snapshotsrun_results.json) andrequirements.txt(dbt-core,dbt-duckdb,duckdb).