dagron/ docs
Operate

Admin & maintenance

The database is the only state in a dagron install; everything else is a stateless process in front of it. This page covers day-to-day operation: health, backups, dead letters, stuck runs, tokens, and what to check when something misbehaves.

What you are operating

A full dagron stack is at most five moving parts, and only one of them holds state:

There are two HTTP surfaces with two trust levels, and keeping them straight is most of the security story:

SurfaceAuthPlacement
engine ops API (API_ADDR)None — unauthenticated by designBind to localhost or a cluster-private interface only. Never expose publicly. Self-describing: Swagger UI at /docs, spec at /openapi.yaml.
dagron-api (/api/*)HS256 session JWT (HttpOnly dagron_session cookie or Authorization: Bearer), or a dgp_ personal access tokenThe public edge. This is what the console, SDKs, and CI talk to.

Deployment shapes

Three ways to run it; pick by how much you are operating. See the overview for the tour and Scaling & HA for multi-node.

Compose (local/dev, full UI stack)

podman compose up --build from the module root (docker compose works the same) brings up postgresengine (ops API on 8787, cluster-internal) → dagron-api (127.0.0.1:8080) → frontend (127.0.0.1:3000). Sign in at http://localhost:3000 with the seeded admin (admin@local / dagron-admin). Before any non-local deploy: change DAGRON_JWT_SECRET (≥ 32 chars), change the admin password, and leave DAGRON_COOKIE_SECURE at its default (true).

Helm (Kubernetes)

The chart is on Artifact Hub. The admin-relevant values:

ValueDefaultWhat it controls
engine.replicas1Engine count. Multi-node needs Postgres; nodes coordinate through the DB.
engine.executork8sk8s runs each task as a one-shot Pod in the namespace; local runs tasks in-process (no pod-create RBAC needed).
engine.workerCount4Concurrent tasks per engine pod (the engine binary's own default is WORKER_COUNT=16).
engine.maxInflightRuns64Admission cap: 429 + Retry-After above it. 0 disables.
engine.resourcesrequests 100m/128Mi, limit 512MiEngine pod resources.
postgres.enabledtrueA throwaway single-replica Postgres for testing — 1Gi volume, insecure default password, no backups. Set false for anything real.
externalDatabaseSecret.name""A pre-existing Secret holding DATABASE_URL (key configurable via externalDatabaseSecret.key). Preferred over externalDatabaseUrl, which inlines the password into helm history.
gitops.enabledfalseDeploys the GitOps worker. Without it, repos can be registered but nothing polls them.
envSecrets.existingSecret.name""A pre-created Secret holding DAGRON_ENV_SECRET_KEY, shared by dagron-api and the engine.

Single binary

The default build (sqlite,ops) needs no other infrastructure: dagron dev for a resident server with Swagger on 127.0.0.1:8787/docs, or dagron <file.yaml> for a one-shot run that exits when the run drains. Setting any of API_ADDR, CRON_CONFIG, GC_RETENTION_SECS, or DB_SCHEDULES=1 keeps the process resident; with none set, a file run drains and exits 0. Flags and env vars are on CLI & configuration.

What the state is

Back up the database; everything else is either an env var to keep safe or a re-creatable input. Executors, workers, and dagron-api are stateless — they coordinate only through the DB.

BackendWhat to back up
SQLite (default)The single DB file (default workflow.db) plus its -wal/-shm sidecars. Never copy just the .db from a running daemon.
PostgresThe whole database — the engine's tables (workflow_runs, task_runs, dead_letters, schedules, …) and the dagron-api-owned tables (users, environments, environment_secrets, git_repos, …).

DAGRON_ENV_SECRET_KEY is not stored in the database. The ciphertext of every stored environment secret is in the DB; the key is an env var on the engine and dagron-api. Lose the key and those secrets are permanently unrecoverable — recovery is re-entering each secret and rotating the underlying credentials. Back the key up in a secret manager, kept apart from the database dumps (a backup holding both together is a backup where one stolen artifact yields plaintext secrets).

Restore the whole database, never a table subset. dagron-api creates its own tables (users, environments, environment_secrets, git_repos, …) at startup with CREATE TABLE IF NOT EXISTS. Restore a dump that omitted them and dagron-api starts happily and recreates them empty — every user, environment, and stored secret silently gone, with no error, and the bootstrap admin re-seeding from env so login still works.

What is not state: the engine/worker/dagron-api processes; WORKFLOW_DIR (an input directory — the specs it ingests are stored in the DB); GitOps-synced repos (the repo is the source of truth and re-syncs). Two directories sit in between: the artifact store (DAGRON_ARTIFACT_DIR or object store) holds run outputs and checkpoints tasks wrote, and the GC archive (GC_ARCHIVE_DIR/GC_ARCHIVE_URL) holds runs the retention GC moved out of the DB — back both up if you cannot recompute their contents. Enterprise builds can encrypt artifacts at rest under a KEK provider; that key material (or the KMS key + version it references) must survive alongside the database, or restored ciphertext artifacts are unreadable even after a clean DB restore.

Monitoring & health

Alert on:

A ready-to-run Prometheus + Grafana stack with a bundled dashboard ships in examples/monitoring.

Backups

SQLite

SQLITE BACKUP
$ sqlite3 workflow.db ".backup 'workflow-backup.db'"   # safe while running (WAL)

Never copy workflow.db alone from a running daemon — the -wal/-shm sidecars carry committed data that has not been checkpointed. Either use .backup, or stop the daemon and copy all three files.

Postgres

The floor is a scheduled pg_dump -Fc — nightly, plus before every upgrade. The repo ships wrapper scripts that write timestamped custom-format dumps, refuse to leave a truncated file looking like a backup, and restrict permissions on the output:

SCRIPTED BACKUP
$ ./scripts/backup-postgres.sh                       # uses $DATABASE_URL
$ ./scripts/backup-postgres.sh -d /var/backups/dagron
$ PGPASSWORD=… ./scripts/backup-postgres.sh -H db -U dagron -n workflow

# Writes <dir>/workflow-<UTC timestamp>.dump in pg_dump custom format (-Fc).
# -k/--retain N deletes dumps older than N days.

Keep the dumps somewhere that is not the database host. The restore script insists on the drop-and-recreate path — without it, pg_restore layers the dump on top of whatever is already there and you get a "successful" restore of a corrupt database:

RESTORE / REHEARSAL
# rehearse into a scratch DB (safe)
$ ./scripts/restore-postgres.sh -f workflow-2026….dump -n workflow_dr_test --create

# real recovery into a fresh database
$ ./scripts/restore-postgres.sh -f workflow-2026….dump -n workflow --create

# --create DROPs the target first, WITH (FORCE), so it succeeds while
# clients are connected — and prompts for the database name before doing it.

Restoring is only half of it. Afterwards, start one engine against the database and confirm it reaches worker pool ready with exit 0 — migrations are applied at engine startup and there is no dagron migrate command, so a starting engine is the only proof the schema is usable. The restore script prints row counts (runs, tasks, workflows, users, migrations) to compare against the source: a restore reporting zero users or zero workflows restored a partial dump. A backup you have not restored is not a backup — rehearse this against a scratch database; it takes minutes.

Point-in-time recovery

A nightly dump means up to 24 h of lost runs. For a real RPO, use continuous WAL archiving. Three AWS shapes, in increasing order of ops burden:

Whichever you pick: the bundled chart's Postgres is a test shape with no backups — production starts with postgres.enabled=false and an external database. And after any PITR restore, the dagron-specific steps still apply: repoint DATABASE_URL, start one engine first, make sure DAGRON_ENV_SECRET_KEY matches the era of the restored data, and cancel any runs that must not re-execute before the engine reclaims their stale leases. Full walkthrough with terraform wiring and verified restore drills: BACKUP_AWS.md.

Day-to-day operations

Dead letters

The dead-letter queue holds submissions that never became runs — a payload arrived from a source, could not be turned into a workflow, and was parked so ingestion could carry on. Unparseable specs park immediately; transient create_run failures are retried and park after the configured number of attempts. A run that failed is not a dead letter — its recovery lives on the run itself (rerun, retry, clear).

Every dead letter carries the original payload, the last error, the source, and the failure count. Inspect and act from the console (Dead letters in the sidebar) or over REST:

DLQ OVER REST (dagron-api)
$ curl -s localhost:8080/api/dead-letters -b cookies.txt          # list
$ curl -s -X POST localhost:8080/api/dead-letters/<id>/redrive -b cookies.txt
$ curl -s -X DELETE localhost:8080/api/dead-letters/<id> -b cookies.txt

The same three operations exist unauthenticated on the engine ops API (GET /dead-letters, POST /dead-letters/{id}/redrive, DELETE /dead-letters/{id}) for in-cluster tooling. Redrive fixes nothing by itself — it re-attempts the same payload, so it only helps once the cause is gone; redriving into an unfixed cause just parks it again with the failure count one higher.

The retry policy — how many delivery attempts before parking — defaults to the engine's DEAD_LETTER_MAX_ATTEMPTS env value (default 3) and can be overridden at runtime, no restart:

RETRY POLICY
$ curl -s localhost:8080/api/settings/dead-letters -b cookies.txt
$ curl -s -X PUT localhost:8080/api/settings/dead-letters -b cookies.txt \
    -H 'content-type: application/json' -d '{"max_attempts":5}'

max_attempts counts total attempts including the first — 1 parks on the first failure, 5 allows 4 retries. 0 is rejected with 400, and the PUT requires an admin session. It does not apply to parse failures, which are deterministic and park on the first attempt regardless. For watching depth, GET /api/health returns dead_letters — the number to alert on.

Stuck or wrong runs

The run and task controls, available on both surfaces (paths shown for dagron-api; the engine ops API has the same operations without the /api prefix):

EndpointDoes
POST /api/runs/{id}/cancelCancel a run.
POST /api/runs/{id}/rerunRerun, optionally {from?} a given task.
POST /api/runs/{id}/resubmitFresh run from the same spec (201 with a new run_id).
POST /api/runs/{id}/tasks/{tid}/retryRetry one task.
POST /api/runs/{id}/tasks/{tid}/clearClear a completed task plus its downstream cone; 409 if the task is not completed.
POST /api/runs/{id}/tasks/{tid}/approveApprove a type: approval gate — the task succeeds and the DAG proceeds.
POST /api/runs/{id}/tasks/{tid}/rejectReject a gate — the task fails and all_success downstream skips.

GET /api/approvals lists every task parked in awaiting_approval, oldest first — the human-in-the-loop worklist. A task that ran twice after a crash or restart is expected crash-recovery behaviour, not a bug to fix here: leases expire (default 30 s) and a surviving node reclaims and re-dispatches, with version fencing rejecting the stale attempt's write. Make tasks idempotent.

API tokens for CI

POST /api/login mints a session token meant for a browser. Automation should use a personal access token instead: named, individually revocable, optionally expiring.

MINT AND USE A TOKEN
# Create one (requires a password session — see below).
$ curl -s -X POST localhost:8080/api/tokens -b cookies.txt \
    -H 'content-type: application/json' \
    -d '{"name":"nightly-ci","expires_in_days":90}'
# -> 201 {"id":"…","name":"nightly-ci","prefix":"dgp_…","token":"dgp_…","expires_at":"…"}

# Use it anywhere the session bearer would go — no cookie jar, no password.
$ curl -s localhost:8080/api/runs -H "Authorization: Bearer $DAGRON_TOKEN"

# List (prefix, last use, expiry — never the secret) and revoke.
$ curl -s localhost:8080/api/tokens -b cookies.txt
$ curl -s -X DELETE localhost:8080/api/tokens/<id> -b cookies.txt

Three properties to know before handing these out:

  1. Copy the token at creation. Only its SHA-256 is stored; no endpoint can show it again, and a database dump yields no working credential.
  2. Token management requires a password session. Requests to /api/tokens carrying a dgp_ bearer get 403 — a token that could mint tokens would replace itself faster than you could revoke it.
  3. A token carries its owner's permissions, read live. There is no per-token scoping yet; a token belonging to an admin is an admin. Give automation its own user, and check last_used_at before revoking to see whether a token is still wired into something.

Secrets and environment variables

Two layers, both living in a named environment a workflow opts into with environment: <name>: plain variables (substituted into the spec, stored unencrypted) and secrets (AES-256-GCM encrypted at rest, decrypted only at task dispatch). One shared key on both dagron-api (encrypts on write) and the engine (decrypts at dispatch):

ENABLE SECRET STORAGE
$ export DAGRON_ENV_SECRET_KEY="$(openssl rand -base64 32)"   # same value for api + engine

Without a key, writing a secret returns 503. Values are write-only: GET /api/environments lists variables in full but secrets by name only — values never leave the server. The Helm chart wires the key via envSecrets.key / envSecrets.existingSecret; compose sets one already. Remember the red-lamp notice above: this key is state your database backups do not contain.

Other admin-only routes on dagron-api worth knowing: POST /api/users and GET /api/users (user management), GET /api/audit (audit rows), and the instance-wide notification settings under /api/settings/notifications — all require the admin group. Where artifact encryption at rest is configured Enterprise, POST /api/artifacts/rotate (admin only) re-keys every artifact from the previous KEK to the current one; quiesce artifact writes while it runs, since rotation does not coordinate with concurrent PUTs.

Security posture

Vulnerability disclosure: SECURITY.md.

Troubleshooting

Symptom-first. The full table lives in OPERATIONS.md; these are the ones that page people.

You seeCauseFix
POST /runs → 429 with Retry-AfterAdmission valve: active runs ≥ MAX_INFLIGHT_RUNS (default 64). Deliberate backpressure, not an error.Wait/retry after the hint, or raise MAX_INFLIGHT_RUNS.
dagron-api exits: DAGRON_JWT_SECRET must be set and at least 32 charactersMissing/short secret.Set a ≥ 32-char secret (compose ships a dev-only one — change it).
Login succeeds but the browser stays logged out (over http://)Secure cookie is not stored on plain HTTP.DAGRON_COOKIE_SECURE=false for local dev only.
Every /api/* call → 401Missing/expired session (default TTL 7 days) or wrong DAGRON_JWT_SECRET between mint and verify.Re-login; keep the secret identical across replicas.
Task killed at ~25 sDefault per-task timeout_secs is 25 (sits inside the 30 s lease).Set timeout_secs on the task.
Task ran twice after a crash/restartLease expiry + reclaim (default 30 s) re-dispatches; version fencing rejects the stale attempt's write. Expected crash-recovery behaviour.Make tasks idempotent.
Submissions vanish without a runThey were dead-lettered (unparseable spec immediately; transient failures after DEAD_LETTER_MAX_ATTEMPTS, default 3).GET /api/dead-letters, fix the cause, redrive. Alert on the count.
Cron/schedules/GC not firing on any nodeNot leader, or cron config invalid — cron disabled in the log.Check the leadership lease (one node owns ops loops, lease 30 s) and validate the CRON_CONFIG YAML.
SQLite: database is locked / stalls under write loadSQLite backend is deliberately single-writer; a second process on the same file contends.One daemon per SQLite file. For concurrency or multi-node, build with postgres.
One-shot run hangs instead of exiting (or exits when you wanted a server)Residency is driven by config: API_ADDR/CRON_CONFIG/GC_RETENTION_SECS/DB_SCHEDULES keep the process up.Unset them for one-shot runs; set one (or use dagron dev) for a server.
dagron dev errors: requires building with the ops featureLean build without the management API.Build with default features (or --features ops).

Upgrades have their own page — migrations are embedded and run at engine startup, they are forward-only, and rollback means restoring the pre-upgrade backup. See Update & upgrade before touching versions.