Two related questions, answered separately because they have different answers:
- "We use GitHub Actions. How do pipelines get from a merge onto a server?" That works today, and there are templates to copy.
- "We already run Airflow / Dagster / Temporal. Can they run Duckle pipelines?" Yes, through a documented CLI and HTTP surface. There are no provider packages yet, and this page is honest about what you would be writing yourself.
Duckle pipelines are plain JSON files, so they live in your repository and travel through the same review you already use. Two jobs cover it.
| Job | When | Touches your data? | Needs a credential? |
|---|---|---|---|
| Validate | every commit, every fork | No | No |
| Deploy | merges to your default branch only | No | Yes, an admin key |
Validate compiles every pipeline to SQL. It opens no source, writes no sink, and needs no DuckDB binary, no credentials and no network, which is why it is safe to run on pull requests from forks.
Deploy installs those pipelines onto a running Duckle server.
| File | For |
|---|---|
docs/ci/github-actions.yml |
validate only |
docs/ci/github-actions-deploy.yml |
validate on PRs, deploy on merge |
docs/ci/gitlab-ci.yml |
validate only |
docs/ci/gitlab-ci-deploy.yml |
validate, then deploy |
On the server:
duckle-runner console key-add github-actions --role admin --expires-days 90It prints the key once and stores only a hash. Deploying needs admin, because a deployed pipeline runs shell and SQL on that host.
Keys use the base64url alphabet, so they can contain
-and_. If you pipe one through a script, do not assume it is alphanumeric.
| Secret | Value |
|---|---|
DUCKLE_URL |
https://duckle.internal (no trailing slash) |
DUCKLE_KEY |
the key from step 1 |
On GitLab, mark DUCKLE_KEY masked and protected so it is only exposed to protected branches.
That is the whole setup. The deploy job posts each pipeline to /api/deploy:
curl --fail-with-body -sS -X POST "$DUCKLE_URL/api/deploy" \
-H "Authorization: Bearer $DUCKLE_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -c --arg n "$name" '{name: $n, pipeline: .}' "$f")"A success looks like this:
{"deployed":"orders_etl","replaced":false,"schedule":{"saved":true,"enabled":false}}replaced tells you whether this overwrote an existing pipeline of that name.
A deployed schedule always arrives switched off. Send a schedule with a pipeline and the server forces enabled: false. A cadence that someone merged cannot start firing the moment it lands; turning it on is a separate act by a person.
Shipping code and starting it need different roles. Deploying is admin; enabling a schedule is operator. So a CI key can ship without being able to start anything:
| Role | /api/run |
/api/deploy |
|---|---|---|
viewer |
403 | 403 |
operator |
200 | 403 |
admin |
200 | 200 |
A refusal explains itself: a mis-scoped key gets {"error":"this needs the admin role; you have viewer"}, which is why the templates use curl --fail-with-body.
A deploy is atomic. The server writes through a temporary file and renames it into place, so a scheduler tick on the far end can never read half a pipeline.
- Turn schedules on. See above.
- Run your pipelines. A green deploy means the file is installed, not that it works against production data.
- Roll back. The previous version is whatever is in git; redeploy the earlier commit.
There are no Airflow, Dagster, Temporal or Prefect provider packages today. If you want a DuckleRunOperator, you would be writing it. The good news is that it is thin, because the two surfaces underneath are stable and documented.
A. The command line - for a BashOperator, a KubernetesPodOperator, or a Temporal activity.
duckle-runner --pipeline pipelines/orders_etl.jsonThe binary is a static musl executable with no shared-library dependencies, so it runs on any base image with no packages installed. Exit codes are stable and documented:
| Code | Means |
|---|---|
0 |
success |
1 |
the work ran and reported failure. A real finding. |
2 |
the runner could not start the work: bad usage, unreadable file, missing engine. Not a finding about your data. |
That 1 versus 2 split is the useful part: a task can retry on 2 and alert on 1.
B. The HTTP API - for an HttpOperator or a Dagster resource, against a running console.
curl -X POST "$DUCKLE_URL/api/run" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"file":"orders_etl.json"}'file is a path inside the server's workspace, and is required - the canonical form is pipelines/<id>.json. params is optional and supplies values for ${...} placeholders. Needs the operator role.
A failed pipeline still answers
HTTP 200. The failure is only in the body:{"id":"orders_etl","status":"error","durationMs":41, "error":"DuckDB engine isn't installed yet. Open Setup to install it.","nodes":{}}An operator that trusts the HTTP status alone -
resp.raise_for_status()and nothing else - will mark failed loads as successful, and nobody will find out until the downstream numbers are wrong.Check
statusyourself. It is one of"ok","error"or"cancelled"; treat anything that is not"ok"as a failure.A non-2xx status means the run could not be started at all:
400for a missing or unreadable file,401/403for a credential problem. Both cases need handling, and they are not the same case.
The response has exactly five keys - id, status, durationMs, error, nodes -
and error is present as null on success rather than omitted.
It is fully synchronous. The connection stays open for the whole run and returns when it finishes. There is no run id and nothing to poll, so any client, proxy or load-balancer timeout in front of it must be longer than your slowest pipeline, or you will lose the result of a run that actually succeeded.
One run at a time, by default. The console serialises runs; raise it with
DUCKLE_MAX_CONCURRENT_RUNS. Two tasks firing together queue rather than fail, and the
wait is unbounded - so that client timeout matters here too.
This route does not take the cross-process run lock. The scheduler does, but a manual
/api/run does not, so a run triggered here can overlap a run of the same pipeline
started from a desktop app on the same workspace. If two things might trigger one
pipeline, let one of them own it.
An empty string parameter is dropped, not sent. {"params":{"month":""}} falls
through to the workspace default rather than overriding it with blank. If params is not
a JSON object it is ignored silently.
This is the part that decides your design, and it is not obvious:
CLI (--pipeline) |
HTTP (/api/run) |
|
|---|---|---|
| Needs a running server | No | Yes |
| Records run history | No | Yes |
| Updates the metrics file | No | Yes |
| Visible in the console | No | Yes |
| Alerts fire | No | Yes |
A headless CLI run is invisible to Duckle's own observability. It writes no run history, so it never appears in the console's Runs tab, never updates the Prometheus textfile, and never raises an alert. That is fine if your orchestrator is your source of truth for what ran; it is not fine if you expected the console to show it.
If you want your orchestrator to own the schedule and Duckle to keep its own history, use the HTTP route against a console.
If Airflow owns the schedule, Duckle's own scheduler and Plans are redundant, and you should leave them switched off rather than run both. Two schedulers pointed at one workspace will not corrupt anything - every run takes a lock on its pipeline, and the second is refused rather than doubled - but you will have two places that believe they decide when things run, and only one of them will be right.
Every recorded run rewrites a textfile at:
<workspace>/logs/duckle_metrics.prom
It is written atomically, so a scrape never reads a half-written file. Point node_exporter's textfile collector or Grafana Alloy at it; there is no HTTP server and no agent inside Duckle, which keeps headless and air-gapped deployments covered.
| Metric | Type | Meaning |
|---|---|---|
duckle_run_last_status |
gauge | 1 when the most recent run succeeded, 0 when it failed or was cancelled |
duckle_run_last_duration_seconds |
gauge | how long the most recent run took |
duckle_run_last_rows |
gauge | rows the most recent run wrote |
duckle_run_last_timestamp_seconds |
gauge | when the most recent run finished |
duckle_runs_window |
gauge | how many runs are in the retained window |
These are windowed, not lifetime, counters. All series are derived from the retained run history, which is a rolling window per pipeline, so
duckle_runs_windowis a count of what is retained rather than everything that has ever run. The metric names say so deliberately.
It is written when a run is recorded, which means console runs, scheduled runs and desktop runs. A headless
duckle-runner --pipelinerun does not update it. See the table above.
For a dashboard of your own, the console exposes GET /api/runs, GET /api/schedules, GET /api/summary and GET /api/catalog, all with the viewer role. That is the right role for a monitoring integration: it can read everything and start nothing.
If you want native integrations, this is the honest ranking by effort against reach:
- A GitHub Actions deploy template. Done - it is in
docs/ci/. - An Airflow provider (
DuckleRunOperator). A few hundred lines of Python over the CLI or the HTTP API, publishable to PyPI alongside the existingpip install duckle. Biggest installed base. - A Dagster resource. Same shape, smaller audience.
- A Temporal activity. Thinnest of the three, because Temporal expects you to write the activity anyway.
None of these need changes inside Duckle. The reason they are cheap is that the CLI has stable exit codes and the HTTP API already exists, which was the hard part.
- Running Duckle on a Server - standing a server up and connecting the studio to it
- Scheduler & Automation - Duckle's own scheduler, if you are not bringing one
- Full cloud recipes: https://duckle.org/deploy.html