<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Evgenii Timofeev</title>
    <description>The latest articles on DEV Community by Evgenii Timofeev (@eu_ti_f127c5b5d7535b7174f).</description>
    <link>https://dev.to/eu_ti_f127c5b5d7535b7174f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102419%2Fd5567118-12e1-4443-add0-48855e37440c.jpg</url>
      <title>DEV Community: Evgenii Timofeev</title>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eu_ti_f127c5b5d7535b7174f"/>
    <language>en</language>
    <item>
      <title>Self-Hosting Datanika with Docker Compose</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Sun, 20 Sep 2026 21:17:18 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/self-hosting-datanika-with-docker-compose-2gob</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/self-hosting-datanika-with-docker-compose-2gob</guid>
      <description>&lt;p&gt;Most data platforms make self-hosting a footnote — a "contact sales for the on-prem option" link that goes nowhere, or an open-source core so stripped-down it's really just a demo for the paid cloud. Datanika's open-source core is the actual product: extract (&lt;code&gt;dlt&lt;/code&gt;), transform (&lt;code&gt;dbt-core&lt;/code&gt;), scheduling, a visual pipeline builder, every connector, multi-org RBAC, nine languages. It runs from one Docker Compose file, and self-hosting it costs &lt;strong&gt;$0 forever&lt;/strong&gt; — the AGPL-3.0 core has no license key, no seat count, and no GB meter.&lt;/p&gt;

&lt;p&gt;This is the walkthrough: what you get, the five-minute quick start, the configuration that actually matters, and — because this is the part most tutorials skip — an honest checklist for running it in production, where &lt;em&gt;you&lt;/em&gt; own the pager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-host at all
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://app.datanika.io/" rel="noopener noreferrer"&gt;managed version at app.datanika.io&lt;/a&gt; exists because plenty of teams would rather not run infrastructure. Self-hosting is the right call when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data residency / compliance.&lt;/strong&gt; Your customer data never leaves your VPC. No third-party processor, no data-processing addendum to negotiate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost at volume.&lt;/strong&gt; The managed plan meters bytes processed; self-hosted meters nothing. If you're moving terabytes a month, a $12 VPS beats any per-GB bill. (We did that math in &lt;a href="https://datanika.io/blog/real-cost-modern-data-stack/" rel="noopener noreferrer"&gt;The Real Cost of Your Modern Data Stack&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No vendor lock-in.&lt;/strong&gt; It's &lt;code&gt;dlt&lt;/code&gt; + &lt;code&gt;dbt-core&lt;/code&gt; under an open UI. If Datanika vanished tomorrow, your pipelines are standard dlt sources and dbt models — they keep running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already have infra.&lt;/strong&gt; A spare box, a Kubernetes cluster, a managed Postgres — drop Datanika next to them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is real and we'll be straight about it below: self-hosting means you own upgrades, backups, and the 3 AM page when a disk fills up. For a lot of teams that's a fair trade. For some it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Engine 24+&lt;/strong&gt; and &lt;strong&gt;Docker Compose v2&lt;/strong&gt; (&lt;code&gt;docker compose&lt;/code&gt;, not the old &lt;code&gt;docker-compose&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 GB RAM&lt;/strong&gt; minimum, &lt;strong&gt;8 GB&lt;/strong&gt; recommended&lt;/li&gt;
&lt;li&gt;That's it. Postgres 16 and Redis 7 ship inside the Compose file — you don't install them separately unless you want to bring your own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The five-minute quick start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/datanika-io/datanika-core.git
&lt;span class="nb"&gt;cd &lt;/span&gt;datanika-core
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# edit .env — at minimum set SECRET_KEY and ENCRYPTION_KEY (see below)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Compose pulls the images, starts four containers, and runs the database migrations on first boot. Give it a minute, then open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/strong&gt; — the app (frontend)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http://localhost:8000&lt;/code&gt;&lt;/strong&gt; — the API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create your account on the first-run screen and you're in. Your &lt;a href="https://datanika.io/docs/getting-started/" rel="noopener noreferrer"&gt;first pipeline&lt;/a&gt; — a source, a destination, a run — takes about five more minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually running
&lt;/h2&gt;

&lt;p&gt;Four containers, and it's worth knowing what each one does before you put it in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;datanika&lt;/td&gt;
&lt;td&gt;3000, 8000&lt;/td&gt;
&lt;td&gt;The Reflex app — frontend + API in one process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;celery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;datanika&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Background worker: runs your extracts, loads, and dbt builds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;postgres:16&lt;/td&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;Application database (your orgs, connections, run history — &lt;strong&gt;not&lt;/strong&gt; your warehouse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;redis:7&lt;/td&gt;
&lt;td&gt;6379&lt;/td&gt;
&lt;td&gt;Task broker for Celery + cache&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing that trips people up: &lt;strong&gt;&lt;code&gt;postgres&lt;/code&gt; here is Datanika's own metadata database, not your data warehouse.&lt;/strong&gt; Your extracted data lands wherever you point it — BigQuery, Snowflake, a separate Postgres, DuckDB on the same box. This container just holds Datanika's bookkeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration that actually matters
&lt;/h2&gt;

&lt;p&gt;Most of &lt;code&gt;.env&lt;/code&gt; has sane defaults. Two variables you &lt;strong&gt;must&lt;/strong&gt; set to real random values before anything touches production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# JWT signing key — anyone who has this can forge login tokens&lt;/span&gt;
&lt;span class="nv"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Fernet key — encrypts every stored connector credential at rest&lt;/span&gt;
&lt;span class="nv"&gt;ENCRYPTION_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; is the one to guard: it's the Fernet key Datanika uses to encrypt every source and destination credential in the metadata DB. &lt;strong&gt;If you lose it, every stored credential becomes unrecoverable and you'll re-enter them all.&lt;/strong&gt; Back it up somewhere that isn't the same box.&lt;/p&gt;

&lt;p&gt;The rest, with their defaults:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metadata Postgres connection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgresql+asyncpg://datanika:datanika@postgres:5432/datanika&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REDIS_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Celery broker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;redis://redis:6379/0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SMTP_HOST&lt;/code&gt; / &lt;code&gt;SMTP_PORT&lt;/code&gt; / &lt;code&gt;SMTP_USER&lt;/code&gt; / &lt;code&gt;SMTP_PASSWORD&lt;/code&gt; / &lt;code&gt;EMAIL_FROM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Email for run-failure alerts and invites&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RECAPTCHA_SITE_KEY&lt;/code&gt; / &lt;code&gt;RECAPTCHA_SECRET_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Bot protection on signup (leave empty to disable)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Configure SMTP if you want failure notifications — a self-hosted pipeline that fails silently is worse than no pipeline. Everything else can wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations and first boot
&lt;/h2&gt;

&lt;p&gt;Migrations run automatically the first time the &lt;code&gt;app&lt;/code&gt; container starts. If you ever need to run them by hand — say after pulling a new version — it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Taking it to production (the honest part)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt; gets you a working instance. It does &lt;strong&gt;not&lt;/strong&gt; get you a production-grade one. Here's the checklist we'd actually run through, in order of how much it'll hurt to skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Put a reverse proxy in front and terminate TLS.&lt;/strong&gt; Nginx or &lt;a href="https://caddyserver.com/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; (Caddy does automatic Let's Encrypt certs in about four lines). Bind the app containers to &lt;code&gt;127.0.0.1&lt;/code&gt; and let the proxy be the only thing on &lt;code&gt;:443&lt;/code&gt;. Never expose &lt;code&gt;:3000&lt;/code&gt;/&lt;code&gt;:8000&lt;/code&gt; to the internet directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the metadata Postgres.&lt;/strong&gt; A nightly &lt;code&gt;pg_dump&lt;/code&gt; is the difference between "restore in ten minutes" and "rebuild every connection by hand." Ship it off the box:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; datanika datanika | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; datanika-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back up &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; alongside it — a database dump full of credentials you can no longer decrypt is not a backup.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lock down the network.&lt;/strong&gt; Change the default Postgres/Redis passwords, restrict container ports to localhost, and put a firewall (ufw / security group) in front. Redis with no password on a public interface is a classic way to get owned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point monitoring at it.&lt;/strong&gt; The Compose file ships optional Grafana + Prometheus profiles; wire them up or point your existing stack at the app's metrics. You want to know a pipeline is failing before your stakeholders tell you the dashboard is stale — the &lt;a href="https://datanika.io/blog/slack-alerts-pipeline-failures/" rel="noopener noreferrer"&gt;Slack-alerts setup&lt;/a&gt; is the fastest win here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-size the box.&lt;/strong&gt; 8 GB RAM / 4 vCPU is a comfortable floor for real workloads. Extract jobs are memory-hungry in bursts; Celery is where that shows up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is Datanika-specific — it's the standard "I now run a stateful service" checklist. But it's real work, and it's the honest cost of the $0 license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring your own database
&lt;/h2&gt;

&lt;p&gt;The bundled Postgres and Redis are convenient for getting started and fine for a small single-box deploy. For anything you care about, point Datanika at managed instances instead — set &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;REDIS_URL&lt;/code&gt; to your managed endpoints and the bundled containers become dead weight you can remove from the Compose file. Managed Postgres gets you backups, failover, and point-in-time recovery without you building any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;datanika-core
git pull origin master
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;app&lt;/code&gt; container runs &lt;code&gt;alembic upgrade head&lt;/code&gt; on startup, so migrations apply themselves. Pin to a tagged release rather than tracking &lt;code&gt;master&lt;/code&gt; if you want change control — and take a &lt;code&gt;pg_dump&lt;/code&gt; before every upgrade, because "roll back the database" is a lot easier than "figure out what the half-applied migration did."&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes, if that's your world
&lt;/h2&gt;

&lt;p&gt;A minimal Helm chart ships in-tree at &lt;code&gt;deploy/helm/datanika/&lt;/code&gt; — same image, one &lt;code&gt;app&lt;/code&gt; Deployment, one &lt;code&gt;celery&lt;/code&gt; Deployment, optional ingress. A few sharp edges to know before you &lt;code&gt;helm install&lt;/code&gt;: it needs a &lt;strong&gt;ReadWriteMany&lt;/strong&gt; storage class (the &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;celery&lt;/code&gt; pods share a &lt;code&gt;dbt_projects&lt;/code&gt; volume), migrations run on every pod start (so keep &lt;code&gt;app.replicaCount=1&lt;/code&gt; until an HA migration hook lands), and you should disable the bundled single-replica Postgres/Redis in favor of managed ones. The &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-hosting docs&lt;/a&gt; have the full &lt;code&gt;values.yaml&lt;/code&gt; walkthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosted vs. managed — the honest split
&lt;/h2&gt;

&lt;p&gt;Everything in the product is in the open-source core. What you're &lt;em&gt;not&lt;/em&gt; getting by self-hosting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Billing / metering&lt;/strong&gt; (Paddle integration) — irrelevant unless you're reselling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed infrastructure and automatic updates&lt;/strong&gt; — you run &lt;code&gt;git pull&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Priority support with an SLA&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSO (SAML/OIDC)&lt;/strong&gt; — gated to the Enterprise plan on managed cloud, though the SSO code itself lives in the open-source core&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those matter to you, self-hosting isn't a downgrade — it's the same platform on your terms. This blog, and the pipelines behind it, run on exactly this stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-hosting docs&lt;/a&gt;&lt;/strong&gt; — the canonical reference: full env-var table, Helm &lt;code&gt;values.yaml&lt;/code&gt;, production notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://datanika.io/docs/getting-started/" rel="noopener noreferrer"&gt;Getting Started&lt;/a&gt;&lt;/strong&gt; — your first source → destination → run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture&lt;/a&gt;&lt;/strong&gt; — how &lt;code&gt;dlt&lt;/code&gt;, &lt;code&gt;dbt-core&lt;/code&gt;, Celery, and Reflex fit together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;Browse all connectors&lt;/a&gt;&lt;/strong&gt; — 31 sources and 9 destinations, all included, no plan gating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://datanika.io/blog/saas-12-euros/" rel="noopener noreferrer"&gt;The €12/mo stack, as it stood in April 2026&lt;/a&gt;&lt;/strong&gt; — the bill for running real software on one small VPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clone it, run &lt;code&gt;docker compose up -d&lt;/code&gt;, and you own your data pipeline stack end to end. Or if you'd rather we run it — &lt;a href="https://app.datanika.io/" rel="noopener noreferrer"&gt;the managed free tier&lt;/a&gt; is one click and includes 10 GB/month.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>selfhosting</category>
      <category>docker</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Four Identical Red Runs: One Was Our Watchdog Working, Three Were Its Corpse</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Tue, 15 Sep 2026 22:47:57 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/four-identical-red-runs-one-was-our-watchdog-working-three-were-its-corpse-3b60</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/four-identical-red-runs-one-was-our-watchdog-working-three-were-its-corpse-3b60</guid>
      <description>&lt;p&gt;A cron in one of our repositories stopped firing on 21 June and nobody noticed for ten weeks. Its only job was to rebuild this site each morning so that blog posts whose publish date had arrived would actually appear. So for ten weeks, scheduled posts did not publish. The failure mode of a cron is silence, and silence is the one thing no dashboard renders.&lt;/p&gt;

&lt;p&gt;We built a watchdog for it. The watchdog has four scheduled runs in its history and all four are red.&lt;/p&gt;

&lt;p&gt;Here is what makes this worth writing down: &lt;strong&gt;the first red was the watchdog working exactly as designed&lt;/strong&gt;, and the other three were it dying before it checked anything. From the outside they are indistinguishable — not merely the same colour, but the same run conclusion and the same step-by-step breakdown, line for line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;33330840430  2026-08-30T19:25:44Z  failure
33442245077  2026-08-31T21:36:52Z  failure
33550257729  2026-09-01T19:34:08Z  failure
33673601655  2026-09-02T19:29:21Z  failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expand any of them and you get the same two lines that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;step 5  Check every scheduled workflow in both public repos  = success
step 6  File an issue when a schedule has stopped            = failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is run 1. It is also run 2, run 3 and run 4. We checked all four against the API rather than trusting the screen; they agree to the character.&lt;/p&gt;

&lt;p&gt;On 30 August, step 6 was red because the watchdog &lt;strong&gt;had found a stopped cron and was filing an issue about it&lt;/strong&gt;. The issue exists, machine-authored, timestamped 19:26:02Z, and it is correct in every particular:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;datanika-landing :: .github/workflows/daily-rebuild.yml&lt;/code&gt; last ran on a &lt;code&gt;schedule&lt;/code&gt; event at 2026-06-21T09:54:45+00:00 — 70.4 days ago. Its cron (&lt;code&gt;0 6 * * *&lt;/code&gt;) should have fired within 38h. It is &lt;code&gt;active&lt;/code&gt;, so this is NOT the 60-day disable; something else is stopping it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On 31 August, 1 September and 2 September, step 6 was red because the watchdog had crashed in step 5 and there was nothing to report. It verified nothing on any of those nights.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke it, and it was not a commit
&lt;/h2&gt;

&lt;p&gt;Nothing in our repository changed between 30 and 31 August. What changed was a repository &lt;em&gt;setting&lt;/em&gt;: Dependabot became active on the repo. And when Dependabot is on, GitHub starts listing an extra workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;gh api repos/OWNER/REPO/actions/workflows &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.workflows[].path'&lt;/span&gt;
.github/workflows/ci.yml
.github/workflows/deploy-pointer.yml
...
dynamic/dependabot/update-graph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one has &lt;code&gt;state: active&lt;/code&gt; and a display name of "Dependency Graph". It is not a file. We did not write it, it is not in the tree, and it is not in any branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repos/OWNER/REPO/contents/dynamic/dependabot/update-graph?ref=master"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"documentation_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://docs.github.com/rest/repos/contents#get-repository-content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"404"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub synthesises it. The workflows API returns it; the contents API has never heard of it. It is not documented as an exception anywhere we could find, and it appears in &lt;strong&gt;both&lt;/strong&gt; of our public repositories.&lt;/p&gt;

&lt;p&gt;Our watchdog walks every workflow the API returns and reads each one's YAML off the default branch to extract its cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_gh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_parse_crons&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_gh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repos/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/contents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?ref=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--jq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check=True&lt;/code&gt; turns the 404 into &lt;code&gt;CalledProcessError&lt;/code&gt;. Nothing catches it. The script dies mid-collection — and because it collects the repos in order, it died on the first one and never reached the second, which is &lt;em&gt;the repo the watchdog was built to watch&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you have any tooling that enumerates workflows through the API and then reads their files, run this against your repos now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/OWNER/REPO/actions/workflows &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.workflows[] | select(.path | startswith(".github/workflows/") | not) | .path'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything it prints will 404 on &lt;code&gt;contents/&lt;/code&gt;. &lt;code&gt;dynamic/pages/pages-build-deployment&lt;/code&gt; shows up the same way once GitHub Pages is enabled, so this is a family, not a one-off.&lt;/p&gt;

&lt;p&gt;The fix is a whitelist, not a &lt;code&gt;dynamic/&lt;/code&gt; blacklist — GitHub only ever executes workflows out of &lt;code&gt;.github/workflows/&lt;/code&gt;, so anything outside that path cannot be a workflow you own, and whatever prefix GitHub invents next year is handled without a code change.&lt;/p&gt;

&lt;p&gt;It specifically must &lt;strong&gt;not&lt;/strong&gt; become "swallow the 404". A 404 on a real &lt;code&gt;.github/workflows/*.yml&lt;/code&gt; means a file you were asked to check is unreadable — a token scope, a rename, an API change — and that has to stay fatal. Turning it into "no crons found" would make the watchdog report health from an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that cost us three nights
&lt;/h2&gt;

&lt;p&gt;The bug above is a fifteen-minute fix. The reason it survived three nights is a design problem, and it is the transferable half.&lt;/p&gt;

&lt;p&gt;Look at the reporting step. Every path through it ends the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; problems.md &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::The watchdog exited non-zero but wrote no problems.md."&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::That means it failed to RUN, not that it found a fault."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;gh issue comment &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--body-file&lt;/span&gt; /tmp/comment.md
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;gh issue create &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Scheduled workflow stopped firing"&lt;/span&gt; &lt;span class="nt"&gt;--body-file&lt;/span&gt; /tmp/body.md
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes — &lt;em&gt;filed a new finding&lt;/em&gt;, &lt;em&gt;added to an existing finding&lt;/em&gt;, &lt;em&gt;could not run at all&lt;/em&gt; — collapsed into one signal. The run had to be red for the first two, because that is how a monitor gets your attention. So the third inherited the same colour, and the watchdog's own catastrophic failure was camouflaged by its success case.&lt;/p&gt;

&lt;p&gt;We have written before about &lt;a href="https://datanika.io/blog/github-actions-pipefail-exit-code/" rel="noopener noreferrer"&gt;a green that proves nothing&lt;/a&gt; and about &lt;a href="https://datanika.io/blog/alerts-that-could-never-fire/" rel="noopener noreferrer"&gt;alerts that could not fire at all&lt;/a&gt;, and about the inverse case, &lt;a href="https://datanika.io/blog/a-red-that-proves-nothing/" rel="noopener noreferrer"&gt;a red that means "I found nothing"&lt;/a&gt;. This is a fourth shape and it is the meanest of them, because the signal is &lt;em&gt;not&lt;/em&gt; useless — it is genuinely informative, one night in four. It just does not carry which thing it means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A monitor has three states, not two: ran and clean, ran and found something, did not run.&lt;/strong&gt; If two of those share a colour, the pair that shares it is the pair you will conflate — and you will conflate it in the direction of "working", because that is the reading that requires no action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostics were perfect and nobody read them
&lt;/h2&gt;

&lt;p&gt;This is the detail that stings. Open the log of any of the three dead runs and the workflow tells you, in plain English, exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;##[error]The watchdog exited non-zero but wrote no problems.md.
##[error]That means it failed to RUN, not that it found a fault.
##[error]Check the step above -- most likely the token cannot
##[error]read one of the repos, or the workflows API changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whoever wrote that anticipated this precise failure and left a message that names the right layer and distinguishes the two cases the conclusion collapses. It was there all three nights. It went unread, because a red tick on a default branch that carries other standing reds is not a thing anyone clicks into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnostics one level below the signal are not diagnostics.&lt;/strong&gt; They are an artifact you will find during the postmortem and feel bad about. If the message needs to be read, it has to travel through the same channel as the finding it disambiguates.&lt;/p&gt;

&lt;p&gt;So that is what the fix does. The watchdog now files an issue about &lt;em&gt;itself&lt;/em&gt; when it cannot run, under a deliberately different title, with a line stating what its own silence is now worth:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;While this issue is open, the absence of a "Scheduled workflow stopped firing" issue means nothing: the detector is down, not the crons proven up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both paths still exit non-zero — the workflow needs that — but the two conditions are now one glance apart in the issue list instead of one log dive apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-six green tests, none of which touched the broken part
&lt;/h2&gt;

&lt;p&gt;The watchdog had a test suite. Twenty-six tests, green throughout all three dead nights.&lt;/p&gt;

&lt;p&gt;Every one of them exercised the comparator: given these crons and these last-run timestamps, which of them are overdue? That logic was never wrong. Not one test exercised &lt;em&gt;collection&lt;/em&gt; — the loop that asks the API what workflows exist and fetches each one's file. Collection was the thin shell around &lt;code&gt;gh api&lt;/code&gt; that the module's own docstring described as not the part worth testing.&lt;/p&gt;

&lt;p&gt;There is a second, quieter version of the same mistake in there. The suite did check that collection found the expected number of workflows — but it asserted a &lt;strong&gt;total&lt;/strong&gt; across both repos. Our first repo has enough workflows on its own to satisfy that total, so the count passed while the second repo was never reached. The fix asserts per repo, which is the assertion that would have failed.&lt;/p&gt;

&lt;p&gt;Whenever you decide some part of a program is too thin to test, you have made a claim about where failure lives. Write it down as a claim, because it is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last twist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 15 September 2026.&lt;/strong&gt; This section describes the morning of 3 September, when it was written, and it stopped being true that evening. The fix reached the default branch that day: the scheduled run that night completed cleanly, and so did the five after it. Since 9 September the watchdog has been red every night again, and not with the crash described here — it runs, and its most recent report names its own failed runs as the schedule that stopped. The original text follows as it was published.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;schedule:&lt;/code&gt; triggers only ever run the copy of a workflow on the repository's &lt;strong&gt;default branch&lt;/strong&gt;. Our fix is merged to the integration branch and has not been promoted. Tonight's scheduled run will crash again, in exactly the way described above, and there is no branch we could put the fix on that would change that.&lt;/p&gt;

&lt;p&gt;Which means the fix cannot be proven by the mechanism it fixes. The only successful run in this watchdog's entire history is a &lt;code&gt;workflow_dispatch&lt;/code&gt; we triggered by hand against the fix branch. That proves the code runs. It does not prove the schedule fires, and it does not prove the schedule fires &lt;em&gt;this&lt;/em&gt; code.&lt;/p&gt;

&lt;p&gt;Our own workflow header had already warned about this trap, from the last time it bit us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not read a green run here as proof that it works. The thing under test is whether an &lt;em&gt;unattended&lt;/em&gt; &lt;code&gt;event=schedule&lt;/code&gt; run appears at all; a &lt;code&gt;workflow_dispatch&lt;/code&gt; proves only that the dispatch step is sound.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The query that answers it honestly filters on the event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="s2"&gt;"repos/OWNER/REPO/actions/workflows/NAME.yml/runs?per_page=100"&lt;/span&gt; &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.workflow_runs[] | select(.event=="schedule") | .created_at'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against the cron this whole story started with, and the gap is unmissable: daily &lt;code&gt;schedule&lt;/code&gt; runs from 16 April to 21 June, then nothing until 31 August. Seventy-one days. During that window a hand-triggered dispatch went green — which is precisely why a green dispatch was never evidence.&lt;/p&gt;

&lt;p&gt;One honest footnote while we are counting. The remedy we applied for that cron was to move it off the top of the hour, on the theory that GitHub's queue is shortest away from &lt;code&gt;:00&lt;/code&gt;. Three scheduled runs since: 4h49m, 5h17m and 7h48m late. Three samples is not a refutation, but it is not the improvement we told ourselves we were buying either, and we would rather print that than quietly drop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things to check in your own repositories
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List every workflow whose path is not under &lt;code&gt;.github/workflows/&lt;/code&gt;.&lt;/strong&gt; If anything prints, every tool you have that reads workflow files by path is one API call from a crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For each monitor you run, ask what a red means.&lt;/strong&gt; If "found a problem" and "could not run" share a conclusion, you own one signal doing two jobs, and it will fail in the direction that looks fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the clean path something to emit.&lt;/strong&gt; A monitor that is only ever heard from when it has bad news is indistinguishable from one that has died — the useful assertion is on a heartbeat going stale, not on an alarm being absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask when each scheduled workflow last ran on an &lt;code&gt;event=schedule&lt;/code&gt;&lt;/strong&gt;, not when it last ran. Manual and dispatched runs will happily paper over a cron that has been dead for two months.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then take the monitor you trust most and break it on purpose — not the system it watches, the monitor itself — and see whether anything looks different.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source data platform that runs dlt extract-and-load and dbt-core transformations behind one UI, with scheduling, run history and a REST API. Every workflow quoted here lives in our public repositories, defects included. It is AGPL-3.0 and self-hostable — see &lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;the architecture&lt;/a&gt;, or &lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;browse the connectors&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ci</category>
      <category>githubactions</category>
      <category>monitoring</category>
      <category>observability</category>
    </item>
    <item>
      <title>The Check That Went Red for Doing Its Job</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:26:07 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/the-check-that-went-red-for-doing-its-job-ek3</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/the-check-that-went-red-for-doing-its-job-ek3</guid>
      <description>&lt;p&gt;We have written twice recently about checks that could not fail — &lt;a href="https://datanika.io/blog/alerts-that-could-never-fire/" rel="noopener noreferrer"&gt;four alerts that could never fire&lt;/a&gt; and &lt;a href="https://datanika.io/blog/github-actions-pipefail-exit-code/" rel="noopener noreferrer"&gt;a nightly suite that passed for eight nights while twelve tests failed&lt;/a&gt;. Both are the same defect: a green signal that would have looked identical had the thing it watches been broken.&lt;/p&gt;

&lt;p&gt;This one is the mirror image, and it is worse in a way that took us a while to articulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  One run, three levels, three answers
&lt;/h2&gt;

&lt;p&gt;Here is a single GitHub Actions run from one of our repositories, read three ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The run conclusion:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The two jobs in it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;JOB&lt;/span&gt; &lt;span class="py"&gt;parity&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;success&lt;/span&gt;
&lt;span class="err"&gt;JOB&lt;/span&gt; &lt;span class="py"&gt;config-fields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;failure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The steps inside the failing job:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 Set up job                                    = success
2 Run actions/checkout@v6                       = success
3 Fetch core's shipped connection schema        = success
4 Compare documented fields against the form    = success
5 File an issue on drift                        = failure      &amp;lt;-- the only red
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every step that does real work succeeded. The one that failed was the step that &lt;em&gt;reports&lt;/em&gt;. And here is its entire log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Already tracked in #449; not filing again.
##[error]Process completed with exit code 1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check went red because it found nothing new to say.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is four lines and looks completely reasonable
&lt;/h2&gt;

&lt;p&gt;The workflow compares two catalogues that live in different repositories and files an issue when they disagree. Filing the same issue every morning would be useless, so it suppresses duplicates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh issue list &lt;span class="nt"&gt;--state&lt;/span&gt; open &lt;span class="nt"&gt;--search&lt;/span&gt; &lt;span class="s2"&gt;"in:title Connector config field drift"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[0].number // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Already tracked in #&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="s2"&gt;; not filing again."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that in review and nothing jumps out. &lt;code&gt;exit 1&lt;/code&gt; after "we found drift" feels right — drift &lt;em&gt;is&lt;/em&gt; a problem, and a red check is how a cron gets anyone's attention.&lt;/p&gt;

&lt;p&gt;But this branch is not "we found drift." It is &lt;strong&gt;"we found drift, and it is the same drift as yesterday, already filed, already assigned."&lt;/strong&gt; Nothing changed. Nothing needs doing. And because the issue will be open for as long as it takes to fix a 36-page documentation mismatch, this branch runs &lt;strong&gt;every day until then&lt;/strong&gt;, painting the check red every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a permanent red is worse than a permanent green
&lt;/h2&gt;

&lt;p&gt;This is the part worth taking away, and it is not symmetric with the "green that proves nothing" story.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A green that proves nothing &lt;strong&gt;fails to inform&lt;/strong&gt;. A red that repeats forever &lt;strong&gt;destroys the channel.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A useless green leaves you no worse off than having no check. A permanent red actively trains every human near the repository to stop reading that signal — and the steps above it in the same job are the reason the cron exists. In our case those steps fetch a schema from another repository and parse a data file by regex. Both are exactly the kind of thing that breaks quietly when someone renames a field. The job even has explicit guards for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;::error::Parsed $slugs slugs out of connectors.ts — the file shape changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That guard reports through the channel the duplicate-suppression branch was jamming. Had the file shape actually changed, the message would have arrived on a check that everyone had already learned to ignore, and it would have looked exactly like yesterday.&lt;/p&gt;

&lt;p&gt;There is a second cost, which we hit within the hour. The red job sat next to a &lt;em&gt;succeeding&lt;/em&gt; one and a &lt;em&gt;stale&lt;/em&gt; auto-filed issue titled "Connector count drift." Glanced at, the repository said the connector count was broken. It was not — the counts agree, on both sides, in the workflow's own log, and on the live site. We nearly spent a session re-fixing something that was already fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line we drew
&lt;/h2&gt;

&lt;p&gt;The distinction that resolves it is not "is there a problem" but &lt;strong&gt;"did anything change that needs a human?"&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;branch&lt;/th&gt;
&lt;th&gt;exit&lt;/th&gt;
&lt;th&gt;reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;filed a &lt;strong&gt;new&lt;/strong&gt; issue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;something changed today; a red run is a standing signal that is easy to see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an issue &lt;strong&gt;already exists&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing changed, nothing to do, and the open issue is already the tracking mechanism&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that we did not simply make the cron always green. The &lt;code&gt;exit 1&lt;/code&gt; after a genuine &lt;code&gt;gh issue create&lt;/code&gt; stays exactly as it was. A rule that removes the check's ability to ever go red would be the original defect arriving from the other direction, so the test we added asserts the narrow version &lt;em&gt;and&lt;/em&gt; asserts that both jobs still exit non-zero after really filing something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the level that answers your question
&lt;/h2&gt;

&lt;p&gt;The same run demonstrates a second thing, and it is free.&lt;/p&gt;

&lt;p&gt;Three conclusions were available — run, job, step — and they were &lt;code&gt;failure&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;/&lt;code&gt;failure&lt;/code&gt;, and one red step among five. &lt;strong&gt;Every one is a true statement about a different question.&lt;/strong&gt; If you ask the run whether your drift check is healthy, the answer is a confident no, for a reason that has nothing to do with drift.&lt;/p&gt;

&lt;p&gt;Fetch step-level data whenever the question is about a particular thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/actions/runs/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.jobs[] | {name} + {steps: [.steps[] | {number, name, conclusion}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two related traps we have hit: a job's own conclusion can be red because of an artifact upload rather than anything it tested, and a &lt;em&gt;fetched log&lt;/em&gt; can silently omit steps entirely while the API reports them as &lt;code&gt;success&lt;/code&gt;. &lt;strong&gt;Read outcomes from the API, and never conclude from an absence.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks you can run on your own repositories
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep your automation for &lt;code&gt;exit 1&lt;/code&gt; in a de-duplication or "already handled" branch.&lt;/strong&gt; Anywhere a script's happy path for "nothing new" is an error exit. This includes retry guards, lockfile checks, and "skip because it is already deployed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List the checks that have been red for more than a week.&lt;/strong&gt; For each, ask what would happen if a &lt;em&gt;different&lt;/em&gt; thing in that job broke tomorrow. If the answer is "the colour would not change", the job has no remaining signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For every scheduled workflow, ask what its steady state should be.&lt;/strong&gt; A cron that watches for drift is supposed to be green almost always and red on the day something moves. If yours is red most days by design, the design is wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  One note on writing the test
&lt;/h2&gt;

&lt;p&gt;We pinned the fix with a test that parses the workflow file, and the first run of its mutation harness reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M1 :: MUTATION DID NOT APPLY
M2 :: MUTATION DID NOT APPLY
M3 :: MUTATION DID NOT APPLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The working copy of the workflow is CRLF and the harness's multi-line anchors used bare &lt;code&gt;\n&lt;/code&gt;, so they matched nothing. That is worth stating because of what the alternative looks like: a mutation tool that silently changes nothing will report every mutation as "the test caught it", and you will believe you have a proven guard when you have an untested one. &lt;strong&gt;Make a harness assert that its mutation actually landed&lt;/strong&gt; — we compare the file's hash before and after — and treat "did not apply" as a distinct outcome from "passed".&lt;/p&gt;

&lt;p&gt;With the anchors corrected, all four mutations went red: both duplicate-suppression branches restored to &lt;code&gt;exit 1&lt;/code&gt;, the scope control that deletes a legitimate &lt;code&gt;exit 1&lt;/code&gt;, and a positive control that renames the log line the matcher keys on.&lt;/p&gt;




&lt;p&gt;Datanika is an open-source data platform — extraction, loading, transformation and scheduling in one UI. The workflows described here are in our public repositories, defects included. &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Try it free&lt;/a&gt;, or &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;read the self-hosting guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ci</category>
      <category>githubactions</category>
      <category>monitoring</category>
      <category>testing</category>
    </item>
    <item>
      <title>Four Alerts That Could Never Fire, and How We Found Them</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:21:05 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/four-alerts-that-could-never-fire-and-how-we-found-them-5gh8</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/four-alerts-that-could-never-fire-and-how-we-found-them-5gh8</guid>
      <description>&lt;p&gt;We spent a week auditing our own alerting, and the useful question turned out not to be &lt;em&gt;"is anything alerting?"&lt;/em&gt; It was &lt;strong&gt;"would this alert look any different if the thing it watches had failed?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For four of them the answer was no. Not "it fires late", not "the threshold is loose" — these could not produce a red signal at all, in the exact circumstance each was written for. Every dashboard was green the entire time, and the green was not evidence of anything.&lt;/p&gt;

&lt;p&gt;Here they are with the mechanisms, because each one is a shape you can go and check for in your own stack in about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The rule that could only fire while the system was healthy
&lt;/h2&gt;

&lt;p&gt;We wanted an alert for &lt;em&gt;"a scheduled maintenance task has stopped arriving."&lt;/em&gt; The natural expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(celery_tasks_total{task="datanika.run_maintenance"}[2h]) &amp;lt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it in English and it is obviously right: fire when fewer than one run happened in two hours. It evaluates cleanly. It shows up in the rule list. Its health reads &lt;code&gt;ok&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It cannot fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In PromQL, a bare comparison between a vector and a scalar is a &lt;em&gt;filter&lt;/em&gt;, not a boolean.&lt;/strong&gt; &lt;code&gt;X &amp;lt; 1&lt;/code&gt; does not return true or false. It returns &lt;em&gt;&lt;code&gt;X&lt;/code&gt;'s own value&lt;/em&gt;, for every series where the condition holds, and drops the rest. So the number that reaches the alerting threshold is not &lt;code&gt;1&lt;/code&gt; and not &lt;code&gt;true&lt;/code&gt; — it is whatever &lt;code&gt;increase()&lt;/code&gt; computed, which by the definition of the filter is &lt;strong&gt;strictly less than 1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our rules pair that with a Grafana threshold of &lt;code&gt;gt [0]&lt;/code&gt;: fire when the reduced value is greater than zero. Now do the arithmetic for the case the rule exists to catch. A task that has completely stopped arriving has &lt;code&gt;increase(...) == 0&lt;/code&gt;. Zero passes the &lt;code&gt;&amp;lt; 1&lt;/code&gt; filter, so a series &lt;em&gt;is&lt;/em&gt; produced — and then &lt;code&gt;0 &amp;gt; 0&lt;/code&gt; is &lt;strong&gt;false&lt;/strong&gt;, so nothing fires.&lt;/p&gt;

&lt;p&gt;The rule is live only in the open interval &lt;code&gt;(0, 1)&lt;/code&gt;. It detects a task that has partially stopped and is structurally blind to one that has entirely stopped.&lt;/p&gt;

&lt;p&gt;Demonstrated against the engine rather than argued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vector(0) &amp;lt; 1        -&amp;gt; series=1 value=0     # gt [0] on 0 is FALSE -&amp;gt; dead rule
vector(0) &amp;lt; bool 1   -&amp;gt; series=1 value=1     # gt [0] on 1 is TRUE  -&amp;gt; fires
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bool&lt;/code&gt; is the fix: it converts the filter into the 1/0 comparison everyone assumed they were writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part worth stealing is how it was found.&lt;/strong&gt; The engineer writing that rule used &lt;code&gt;&amp;lt; bool 1&lt;/code&gt; deliberately, and then &lt;em&gt;mutated their own correct code&lt;/em&gt; to the naive &lt;code&gt;&amp;lt; 1&lt;/code&gt; to check whether the linter would have caught the mistake had they made it.&lt;/p&gt;

&lt;p&gt;It did not. &lt;strong&gt;196 tests passed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four other mutations run the same way — a dropped scrape job, a dropped deploy step, a drifted subquery step, a bare staleness comparison — all went correctly red, which is what proves the harness was armed and this one check simply does not look. A satisfiability check existed; it recognised the &lt;code&gt;== N&lt;/code&gt; shape and skipped silently on everything else, and a skip is a pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The exporter that was scraped, and received nothing
&lt;/h2&gt;

&lt;p&gt;Our task metrics were collected by code that had been in the repo since the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;celery_tasks_total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was an alert on that counter. It had never fired. There had also never been a task failure it should have caught, so nobody thought about it.&lt;/p&gt;

&lt;p&gt;Two things were wrong, and each on its own is fatal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The counter incremented in one process and was served from another.&lt;/strong&gt; &lt;code&gt;celery_tasks_total&lt;/code&gt; is incremented inside the &lt;strong&gt;Celery worker&lt;/strong&gt;. &lt;code&gt;/metrics&lt;/code&gt; is a route in the &lt;strong&gt;web&lt;/strong&gt; process. Two processes, two &lt;code&gt;prometheus_client&lt;/code&gt; registries, no shared state. The counter the worker maintains is not in the payload the web process serves, and never was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And Prometheus was not scraping Celery at all.&lt;/strong&gt; Seven targets configured, none of them the worker. So even a correctly located counter had no path to the time series database.&lt;/p&gt;

&lt;p&gt;The alert's query returned &lt;strong&gt;zero series&lt;/strong&gt;, forever. And here is the part that makes this a false-negative rather than a visible outage: our alert rules run with &lt;code&gt;noDataState: OK&lt;/code&gt;, which is deliberate and correct for filtering expressions — a healthy system genuinely produces no rows. So &lt;em&gt;"the metric does not exist"&lt;/em&gt; and &lt;em&gt;"nothing is wrong"&lt;/em&gt; arrive at the alert engine as the same signal.&lt;/p&gt;

&lt;p&gt;The general form, which we now have written down:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An exporter that is scraped but has stopped receiving events looks exactly like a quiet system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was not to move the counter. It was to stop asking the application to report on itself and read the &lt;strong&gt;broker's own event stream&lt;/strong&gt; instead, with a dedicated exporter and the worker started with &lt;code&gt;-E&lt;/code&gt;. Which introduced its own trap, so it goes in the list too: an exporter pointed at a worker &lt;em&gt;without&lt;/em&gt; &lt;code&gt;-E&lt;/code&gt; still emits worker-liveness metrics and zero task metrics — indistinguishable from a worker that simply has not run a task yet. You tell them apart by waiting for a scheduled firing and re-querying, never by reading the exporter's own health.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;curl -sf&lt;/code&gt; succeeds on an HTML error page
&lt;/h2&gt;

&lt;p&gt;This one is the cheapest to reproduce and probably the most widespread.&lt;/p&gt;

&lt;p&gt;A verification runbook, gating a pricing change, contained this step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; https://app.example.com/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"bytes_processed|bytes_quota"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under it, three checkboxes to tick when the metrics appear. Measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;http_code=200   size=5106   content_type=text/html;charset=utf-8
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/metrics&lt;/code&gt; had no entry in the reverse-proxy config. Our proxy routes a specific list of paths to the backend and sends &lt;strong&gt;everything else&lt;/strong&gt; to the single-page app, so an unrouted backend path does not 404 — it silently resolves to the frontend and returns the app shell with a cheerful &lt;strong&gt;200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl -f&lt;/code&gt; fails on HTTP status codes at or above 400. A 200 carrying an HTML page is not a status error, so &lt;strong&gt;&lt;code&gt;-f&lt;/code&gt; does not trigger and &lt;code&gt;curl&lt;/code&gt; exits 0&lt;/strong&gt;. The pipe then hands &lt;code&gt;grep&lt;/code&gt; five kilobytes of HTML, &lt;code&gt;grep&lt;/code&gt; matches nothing, and the operator sees no output and no error.&lt;/p&gt;

&lt;p&gt;The checkboxes under that command could never be ticked, no matter what the application did. And the runbook's own troubleshooting table sent the reader to the wrong layer — &lt;em&gt;"no metrics after 2h → check the worker logs"&lt;/em&gt; — for a problem that was one line of proxy config.&lt;/p&gt;

&lt;p&gt;Two rules came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-f&lt;/code&gt; is a status check, not a content check.&lt;/strong&gt; If you are grepping a response, assert on the &lt;code&gt;Content-Type&lt;/code&gt; or on a string you know must be present, and fail the step when it is absent. &lt;code&gt;curl -sf … | grep -q 'expected'&lt;/code&gt; at least fails on the pipe's own exit status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any backend route outside your proxied prefixes needs its own entry, or it becomes your SPA.&lt;/strong&gt; The failure is silent, returns 200, and is invisible to anything that only checks status codes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The pager that rang for something that had not happened
&lt;/h2&gt;

&lt;p&gt;The other direction belongs in the same audit, because an instrument that manufactures incidents costs you the same credibility as one that hides them — and it burns it faster.&lt;/p&gt;

&lt;p&gt;Our end-to-end suite has an auto-filer: on failure it opens a tracker issue and pages. It fires on a &lt;strong&gt;job-level&lt;/strong&gt; &lt;code&gt;failure()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One night it filed and paged for a failed end-to-end run. Reading the step outcomes from the API rather than the log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run gating E2E specs&lt;/td&gt;
&lt;td&gt;✅ success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detect flaky gating specs&lt;/td&gt;
&lt;td&gt;✅ success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run informational E2E specs&lt;/td&gt;
&lt;td&gt;✅ success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assert the specs were actually collected&lt;/td&gt;
&lt;td&gt;✅ success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Upload test report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🔴 &lt;strong&gt;failure&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram alert on failure&lt;/td&gt;
&lt;td&gt;fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File issue on failure&lt;/td&gt;
&lt;td&gt;fired&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only error anywhere in the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attempt 1..4 of 5 failed with error: Request timeout:
  /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every spec passed.&lt;/strong&gt; The job was red because an artifact upload timed out.&lt;/p&gt;

&lt;p&gt;What makes this worse than a cosmetic false alarm is what the filer writes into the issue it creates: a sentence stating that only the gating tier can open this report. That sentence became affirmatively false at the moment it was filed — and it is exactly the line a reader uses to decide how much to trust what they are reading. The tracker asserted a test failure that had not occurred, in a thread whose entire purpose is to be believed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;if: failure()&lt;/code&gt; at job level means &lt;em&gt;"any step failed"&lt;/em&gt;. If you want &lt;em&gt;"the tests failed"&lt;/em&gt;, key the alert on the test step's own outcome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run specs&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;specs&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest ...&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Page on real failure&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.specs.outcome == 'failure'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pattern under all four
&lt;/h2&gt;

&lt;p&gt;Every one of these measured the &lt;strong&gt;instrument&lt;/strong&gt; rather than the system:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what was green&lt;/th&gt;
&lt;th&gt;what it actually recorded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the alert rule's health&lt;/td&gt;
&lt;td&gt;that the expression parses and evaluates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the scrape target being &lt;code&gt;up&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;that an HTTP endpoint answered, not that it carried our data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl -sf&lt;/code&gt; exiting 0&lt;/td&gt;
&lt;td&gt;that some server returned a status below 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the E2E job's red&lt;/td&gt;
&lt;td&gt;that some step in the job failed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them is broken in the sense of throwing an error. Each answers a real question accurately. The question is just not the one anybody thought was being asked.&lt;/p&gt;

&lt;p&gt;So the check we now run on every new alarm is a single sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Would this signal look different if the thing it watches had failed?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the only honest way to answer it is to make the thing fail. Not in a synthetic fixture built to satisfy the assertion — against the real artifact, in the real failure mode. Break the rule and watch it go red. Stop the worker and watch the metric vanish, then check what your &lt;code&gt;noDataState&lt;/code&gt; does with a vanished series. Point the &lt;code&gt;curl&lt;/code&gt; at a path you know is unrouted and confirm the step fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A passing check is not evidence until you have seen it fail.&lt;/strong&gt; We keep relearning that in a new costume roughly once a fortnight — most recently as &lt;a href="https://datanika.io/blog/github-actions-pipefail-exit-code/" rel="noopener noreferrer"&gt;a nightly CI job that reported success over twelve failing tests for eight nights&lt;/a&gt;, because a pipe to &lt;code&gt;tee&lt;/code&gt; discarded the exit code and GitHub Actions' default shell does not set &lt;code&gt;pipefail&lt;/code&gt;. Same family, different layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things to go and check in your own stack
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep your alert expressions for a bare &lt;code&gt;&amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt; against a scalar&lt;/strong&gt;, and check what value actually reaches your threshold evaluator. In PromQL, add &lt;code&gt;bool&lt;/code&gt; unless you specifically want the filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For every counter you alert on, confirm the process that increments it is the process that serves it.&lt;/strong&gt; With any pre-fork or multi-process server, that is not automatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find every &lt;code&gt;curl -sf&lt;/code&gt; in your runbooks and CI&lt;/strong&gt;, and make each one assert on content, not just status. Then point one at a deliberately wrong path and confirm it fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your alert conditions for scope.&lt;/strong&gt; &lt;code&gt;if: failure()&lt;/code&gt; and &lt;code&gt;on: failure&lt;/code&gt; are usually broader than the thing you meant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then take one working alert and break the underlying system on purpose. If nothing turns red, you have found the fifth one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source data platform that runs dlt extract-and-load and dbt-core transformations behind one UI, with scheduling, run history and a REST API. It is AGPL-3.0 and self-hostable with a single &lt;code&gt;docker compose up&lt;/code&gt; — see &lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;the architecture&lt;/a&gt;, or &lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;browse the connectors&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>prometheus</category>
      <category>alerting</category>
      <category>observability</category>
    </item>
    <item>
      <title>Our Nightly CI Passed for Eight Nights While Twelve Tests Failed</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:40:46 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/our-nightly-ci-passed-for-eight-nights-while-twelve-tests-failed-35l7</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/our-nightly-ci-passed-for-eight-nights-while-twelve-tests-failed-35l7</guid>
      <description>&lt;p&gt;Our nightly connector smoke suite reported &lt;code&gt;success&lt;/code&gt; eight nights running. Here is what it actually printed on each of those nights:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12 failed, 9 passed, 4 warnings in 44.01s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve of twenty-one probes failing, every night, for at least eight consecutive nights — the log retention window is the only reason the count stops at eight. The step reported &lt;code&gt;completed/success&lt;/code&gt;. The job reported &lt;code&gt;success&lt;/code&gt;. The workflow reported &lt;code&gt;success&lt;/code&gt;. The &lt;code&gt;Telegram alert on failure&lt;/code&gt; step, guarded by &lt;code&gt;if: failure()&lt;/code&gt;, was &lt;code&gt;skipped&lt;/code&gt; every single night, because &lt;code&gt;failure()&lt;/code&gt; never became true.&lt;/p&gt;

&lt;p&gt;Nobody had broken anything that week. The suite had been lying since long before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism is four words of shell
&lt;/h2&gt;

&lt;p&gt;Here is the step, near enough verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run connector smoke tests&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;pytest tests/test_connector_smoke/ -v --tb=short -rs | tee /tmp/smoke.log&lt;/span&gt;
    &lt;span class="s"&gt;if grep -qE '[0-9]+ skipped' /tmp/smoke.log; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "::error::Smoke probes were SKIPPED"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shell pipeline's exit status is the exit status of its &lt;strong&gt;last&lt;/strong&gt; command. The last command here is &lt;code&gt;tee&lt;/code&gt;, and &lt;code&gt;tee&lt;/code&gt; succeeded — it wrote the file it was asked to write. &lt;code&gt;pytest&lt;/code&gt; exited non-zero into a void.&lt;/p&gt;

&lt;p&gt;That much is ordinary POSIX shell, and most people who have written a CI pipeline know it. The part that catches you is the second half.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions gives you &lt;code&gt;pipefail&lt;/code&gt; only if you ask for it, and asking looks like a no-op
&lt;/h2&gt;

&lt;p&gt;Bash has a flag for exactly this. &lt;code&gt;set -o pipefail&lt;/code&gt; makes a pipeline return the rightmost non-zero exit status, so &lt;code&gt;pytest ... | tee&lt;/code&gt; fails when &lt;code&gt;pytest&lt;/code&gt; fails.&lt;/p&gt;

&lt;p&gt;GitHub Actions runs &lt;code&gt;run:&lt;/code&gt; steps on Linux and macOS with a default shell of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-e&lt;/code&gt; but no &lt;code&gt;-o pipefail&lt;/code&gt;. Now write the step as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run connector smoke tests&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the runner invokes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &lt;span class="nt"&gt;--noprofile&lt;/span&gt; &lt;span class="nt"&gt;--norc&lt;/span&gt; &lt;span class="nt"&gt;-eo&lt;/span&gt; pipefail &lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming the shell you were already using turns on &lt;code&gt;pipefail&lt;/code&gt;. That is a real asymmetry in the product, it is documented, and it reads as a no-op in a diff. A reviewer looking at &lt;code&gt;shell: bash&lt;/code&gt; on a step that was already running bash sees tidying, not a behavioural change — which cuts both ways: it is easy to add without argument, and easy for someone to delete later as noise.&lt;/p&gt;

&lt;p&gt;We had &lt;strong&gt;no &lt;code&gt;shell:&lt;/code&gt;, no &lt;code&gt;defaults:&lt;/code&gt; and no &lt;code&gt;pipefail&lt;/code&gt;&lt;/strong&gt; anywhere in that workflow file. So the only thing left that could fail the step was the &lt;code&gt;grep&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part actually worth writing down
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;grep&lt;/code&gt; is not a mistake. It exists for a good reason and it was working.&lt;/p&gt;

&lt;p&gt;Our connector probes need live credentials. A probe with no credentials, or one whose client library will not import, used to &lt;em&gt;skip&lt;/em&gt; — and a suite that skips everything passes loudly while testing nothing. So we changed &lt;code&gt;conftest.py&lt;/code&gt; to convert both cases from &lt;strong&gt;skip&lt;/strong&gt; into &lt;strong&gt;fail&lt;/strong&gt;, on the reasoning that a healthy run should have zero skips, which makes "any skip at all" a usable alarm. Then we added the &lt;code&gt;grep&lt;/code&gt; to enforce it.&lt;/p&gt;

&lt;p&gt;Read those two decisions in isolation and both are right. Read them together and this falls out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the outcome the guard watches for — &lt;strong&gt;skip&lt;/strong&gt; — is now the rare one, by construction;&lt;/li&gt;
&lt;li&gt;the outcome that actually happens — &lt;strong&gt;fail&lt;/strong&gt; — is the one the pipe throws away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The guard was aimed at the hole that existed &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;conftest&lt;/code&gt; change. The &lt;code&gt;conftest&lt;/code&gt; change moved every real failure into the blind spot. No commit introduced the bug; the second correct change walked the failure mode into the first correct change's shadow.&lt;/p&gt;

&lt;p&gt;That is the shape to look for, and it is not rare. When you tighten one behaviour, the checks written against the old behaviour do not fail — they go quiet, and quiet and healthy look identical from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was underneath
&lt;/h2&gt;

&lt;p&gt;The twelve failures were all real, and none was caused by this bug. A couple of trial accounts had lapsed and were returning &lt;code&gt;401&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt;. A message-broker probe was pointed at a cluster that no longer matched the one our credentials were minted against. Several probes were missing environment variables entirely, because the credential bundle handed to CI had drifted from the copy on disk — two copies of the same list, edited independently, which is its own recurring lesson.&lt;/p&gt;

&lt;p&gt;Every one of those is a five-minute fix once you can &lt;em&gt;see&lt;/em&gt; it. They had been invisible for over a week, and the instrument that was supposed to show them was reporting green the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;Any one of these is sufficient. They are not equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Name the shell.&lt;/strong&gt; The smallest diff, and it fixes every pipeline in the step at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run connector smoke tests&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;pytest tests/test_connector_smoke/ -v --tb=short -rs | tee /tmp/smoke.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Set the flag explicitly.&lt;/strong&gt; More obvious to a reader, and it survives someone deleting &lt;code&gt;shell: bash&lt;/code&gt; as redundant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;set -o pipefail&lt;/span&gt;
  &lt;span class="s"&gt;pytest ... | tee /tmp/smoke.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Capture the status you care about.&lt;/strong&gt; Verbose, but it is the only form that lets you act on &lt;code&gt;pytest&lt;/code&gt;'s code specifically — useful when a tool distinguishes "tests failed" (&lt;code&gt;1&lt;/code&gt;) from "the run was misconfigured" (&lt;code&gt;2&lt;/code&gt;, &lt;code&gt;4&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest ... | &lt;span class="nb"&gt;tee&lt;/span&gt; /tmp/smoke.log
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Stop piping.&lt;/strong&gt; Write the log to a file and &lt;code&gt;cat&lt;/code&gt; it afterwards, or emit machine-readable output and read that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest ... &lt;span class="nt"&gt;--junitxml&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/smoke.xml | &lt;span class="nb"&gt;tee&lt;/span&gt; /tmp/smoke.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;grep&lt;/code&gt;, in every case. It still catches the case it was written for. It just must not be the &lt;em&gt;only&lt;/em&gt; thing that can fail the step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not trust the green afterwards
&lt;/h2&gt;

&lt;p&gt;This is the step that gets skipped, and it is the one that matters.&lt;/p&gt;

&lt;p&gt;Once you have applied the fix, re-run the job &lt;strong&gt;unchanged, against the still-broken system&lt;/strong&gt;, and confirm it goes &lt;strong&gt;red&lt;/strong&gt;. If it goes green, your fix did not land — a check that has never been observed failing has never been shown able to fail. Our own rule, from a file of rules that each cost us an incident, is blunt about it: &lt;em&gt;a passing check is not evidence until you have seen it fail.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We have paid for that rule more than once. A monthly database restore drill asserted that a table of seed rows survived the restore — while &lt;code&gt;pg_dump&lt;/code&gt; writes tables alphabetically and puts &lt;code&gt;users&lt;/code&gt; at the very end of the file, which makes it the first thing a truncation destroys and the last thing that check would notice. It printed &lt;code&gt;PASS&lt;/code&gt; beside an empty user table. Alert rules that were structurally unable to fire. A test suite that mocked the very unit under test. In &lt;a href="https://datanika.io/blog/green-tests-broken-connectors/" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt; we wrote up 2,300 passing tests sitting beside a CSV import that loaded exactly one row.&lt;/p&gt;

&lt;p&gt;The through-line is one question, and it is worth asking of any signal before you rely on it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Would this look different if the thing it watches had failed?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you cannot answer that from the code, the green tells you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit your own workflows in one command
&lt;/h2&gt;

&lt;p&gt;If you pipe test output anywhere in CI — to &lt;code&gt;tee&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, a log shipper — check whether your exit code survives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; .github/workflows/&lt;span class="k"&gt;*&lt;/span&gt;.yml&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'pipefail'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'shell: bash'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Hn&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'\|\s*(tee|grep|head|tail|jq|awk|sed|sort|uniq)'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line it prints is a pipeline whose left-hand exit code is being discarded. Some of those are deliberate. The ones that are not are the ones running your tests.&lt;/p&gt;

&lt;p&gt;Two things worth knowing while you read the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -e&lt;/code&gt; does not save you.&lt;/strong&gt; &lt;code&gt;-e&lt;/code&gt; aborts on a failing &lt;em&gt;command&lt;/em&gt;; a pipeline whose last command succeeded has not failed, so there is nothing for &lt;code&gt;-e&lt;/code&gt; to abort on. Having &lt;code&gt;-e&lt;/code&gt; is what makes this feel safe when it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows runners differ.&lt;/strong&gt; The default shell there is PowerShell, which has its own rules for &lt;code&gt;$LASTEXITCODE&lt;/code&gt; and native-command failure. A fix applied to a Linux matrix leg does not necessarily apply to a Windows one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alerting on the pipelines you actually run
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part of this story is not that a workflow was misconfigured. It is that we had an alert wired up, pointed at the right workflow, with the right condition — and it was silent for eight nights because the condition it tested was never reached. The alert was fine. The thing it was reading was wrong.&lt;/p&gt;

&lt;p&gt;That generalises past CI. If you run scheduled data pipelines, the same question applies to every notification you have configured: does your failure alert read the pipeline's real outcome, or something downstream of a step that always succeeds?&lt;/p&gt;

&lt;p&gt;Datanika records the outcome of every run in its own ledger rather than inferring it from a log line, and failure notifications fire off that record. If you want them in Slack, &lt;a href="https://datanika.io/blog/slack-alerts-pipeline-failures/" rel="noopener noreferrer"&gt;that setup takes about two minutes&lt;/a&gt;. If you drive pipelines from CI, the &lt;a href="https://datanika.io/docs/api/" rel="noopener noreferrer"&gt;REST API&lt;/a&gt; hands back a run id and a status you can poll and assert on — which is a better thing to gate a deploy on than an exit code you have to hope survived a pipe.&lt;/p&gt;

&lt;p&gt;The platform is open source and self-hostable. &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Start here&lt;/a&gt;, or &lt;a href="https://datanika.io/docs/" rel="noopener noreferrer"&gt;read the docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ci</category>
      <category>githubactions</category>
      <category>testing</category>
      <category>bash</category>
    </item>
    <item>
      <title>Triggering Data Pipelines from CI/CD via the REST API</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:44:43 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/triggering-data-pipelines-from-cicd-via-the-rest-api-5027</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/triggering-data-pipelines-from-cicd-via-the-rest-api-5027</guid>
      <description>&lt;p&gt;The most common thing people want from a data platform's API is boring: &lt;strong&gt;run this pipeline, tell me if it worked.&lt;/strong&gt; Usually right after a deploy, a dbt seed change, or a nightly job that has to finish before something else starts.&lt;/p&gt;

&lt;p&gt;Here's how to do that with Datanika — and, more usefully, the one place this is easy to get wrong in a way your CI won't notice. We got it wrong ourselves; the fix is at the end of that section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--fail-with-body&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://app.datanika.io/api/v1/pipelines/1/run?wait=true&amp;amp;timeout=300"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DATANIKA_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call. Blocks until the pipeline finishes, returns the run as JSON, and &lt;strong&gt;exits non-zero if the pipeline failed&lt;/strong&gt;. In a &lt;code&gt;set -e&lt;/code&gt; CI step, that is the whole integration.&lt;/p&gt;

&lt;p&gt;The reason that last clause is worth a sentence is the rest of this section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The status code carries the run's outcome
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;?wait=true&lt;/code&gt;, the endpoint polls the run until it reaches a terminal state, then answers with a status code that describes &lt;strong&gt;the run&lt;/strong&gt;, not just the request:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The run finished successfully&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still pending or running when your timeout expired&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;408&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal, but not successful (&lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;cancelled&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;422&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The body is the serialized run in all three cases, so &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;error_message&lt;/code&gt; are still there when you want detail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pipeline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"started_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-30T14:00:02Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finished_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-30T14:00:44Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rows_loaded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"relation &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;public.orders&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; does not exist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-30T14:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That response is a &lt;strong&gt;422&lt;/strong&gt;. &lt;code&gt;curl --fail&lt;/code&gt; trips on it, &lt;code&gt;raise_for_status()&lt;/code&gt; raises on it, and your CI job goes red — which is what you wanted when you asked the API to wait.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trap this replaced, because you will meet it elsewhere
&lt;/h3&gt;

&lt;p&gt;Until August 2026, that same failed run came back as &lt;strong&gt;HTTP 200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reasoning was defensible and wrong. Your &lt;em&gt;request&lt;/em&gt; succeeded — the server did its job, found the run, waited, serialized it, and handed it to you. The run is what failed, and the body says so plainly in &lt;code&gt;status&lt;/code&gt;. Textbook HTTP.&lt;/p&gt;

&lt;p&gt;The problem is that nothing in CI reads the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The reflex every CI script has&lt;/span&gt;
curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;".../pipelines/1/run?wait=true"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--fail&lt;/code&gt; trips on 4xx and 5xx. A failed pipeline was a 200. &lt;strong&gt;Exit code 0.&lt;/strong&gt; The job goes green, the dashboard downstream is stale, and nobody finds out until someone notices the numbers are yesterday's. A step that reports success on a failed load is worse than no step at all, because it converts a loud failure into a silent one.&lt;/p&gt;

&lt;p&gt;What settled it was noticing the endpoint had &lt;strong&gt;already&lt;/strong&gt; decided the question. It returned &lt;strong&gt;408&lt;/strong&gt; when the run was still going at the timeout — and that isn't a transport failure either; the request was served perfectly. So &lt;code&gt;200 == failed&lt;/code&gt; wasn't a competing philosophy, it was an inconsistency with the endpoint's own behaviour. &lt;code&gt;?wait=true&lt;/code&gt; is the caller explicitly opting into &lt;em&gt;"block until you know the outcome."&lt;/em&gt; If the outcome doesn't reach the status line, the option is half-built.&lt;/p&gt;

&lt;p&gt;So if you are integrating some &lt;em&gt;other&lt;/em&gt; pipeline API and it offers a "wait" mode: &lt;strong&gt;check what a failed run returns before you trust &lt;code&gt;--fail&lt;/code&gt;.&lt;/strong&gt; Trigger something you know is broken and look at the exit code. It is a two-minute experiment that this post exists because we ran late.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why 422 and not 500
&lt;/h3&gt;

&lt;p&gt;A 5xx means &lt;em&gt;"our API broke, retry the request."&lt;/em&gt; Retrying this request would start a &lt;strong&gt;second pipeline run&lt;/strong&gt; — a second extract, a second load, a second set of rows. A failed pipeline is not a transport failure and must not be retried like one.&lt;/p&gt;

&lt;p&gt;422 says the opposite: the request was fine, and the thing you asked about did not succeed. The failure is in your pipeline — your credentials, your SQL, your source — not in our server. Retrying blindly is exactly the wrong move, and the status code should say so.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cancelled&lt;/code&gt; is a 422 as well. The check is &lt;em&gt;"not success"&lt;/em&gt; rather than a list of failure names, so a terminal status added later cannot quietly rejoin the success branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the body when you fail
&lt;/h3&gt;

&lt;p&gt;Plain &lt;code&gt;curl --fail&lt;/code&gt; discards the response body on an HTTP error, which throws away &lt;code&gt;error_message&lt;/code&gt; — the one thing you want in the log.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;&lt;code&gt;--fail-with-body&lt;/code&gt;&lt;/strong&gt; (curl 7.76+, so every current GitHub runner) to get the non-zero exit &lt;em&gt;and&lt;/em&gt; the body. If you're on something older, capture the status code explicitly, as the full workflow below does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three endpoints
&lt;/h2&gt;

&lt;p&gt;Runs are triggered on the &lt;strong&gt;resource&lt;/strong&gt;, not on a runs collection. There is no &lt;code&gt;POST /api/v1/runs&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you're running&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Key scope needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A pipeline&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/pipelines/{id}/run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pipelines:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A file upload&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/uploads/{id}/run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uploads:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A dbt transformation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/transformations/{id}/run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transformations:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three behave identically with respect to everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes: fire-and-forget, or wait
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Without &lt;code&gt;wait&lt;/code&gt;&lt;/strong&gt; you get an immediate &lt;code&gt;202 Accepted&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"run_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when CI's job is to &lt;em&gt;kick off&lt;/em&gt; work — a nightly ingest that takes 40 minutes and nothing downstream is blocking on it. A &lt;code&gt;202&lt;/code&gt; is about dispatch only; it says nothing about the outcome, and there is nothing to check with &lt;code&gt;--fail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With &lt;code&gt;?wait=true&lt;/code&gt;&lt;/strong&gt; the request blocks until the run is terminal, then answers 200 / 408 / 422 as above.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt; is in seconds, defaults to &lt;strong&gt;120&lt;/strong&gt;, and is clamped to &lt;strong&gt;1–300&lt;/strong&gt;. Passing &lt;code&gt;timeout=3600&lt;/code&gt; doesn't get you an hour — you get 300 seconds, then a 408. Status is polled every 2 seconds, and waiting doesn't occupy a worker, so a waiting request costs you nothing but the open connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 408 is not a failure.&lt;/strong&gt; The run is still going; you just stopped waiting. The body carries &lt;code&gt;"timed_out": true&lt;/code&gt;, and the run's own &lt;code&gt;status&lt;/code&gt; is still &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;running&lt;/code&gt;. That distinction matters because the remedy is different — a 422 means fix your pipeline, a 408 means wait longer or stop blocking on it. Treating them the same is how a slow Tuesday becomes a red build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runs longer than five minutes
&lt;/h2&gt;

&lt;p&gt;Because of that 300-second ceiling, anything longer needs the async shape: trigger, then poll.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--fail-with-body&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://app.datanika.io/api/v1/pipelines/1/run"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DATANIKA_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.run_id'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;deadline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$deadline&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"https://app.datanika.io/api/v1/runs/&lt;/span&gt;&lt;span class="nv"&gt;$run_id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DATANIKA_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.status'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;success&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
    failed|cancelled&lt;span class="p"&gt;)&lt;/span&gt;    jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.error_message // .status'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$run&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Still running after 1h — check run &lt;/span&gt;&lt;span class="nv"&gt;$run_id&lt;/span&gt;&lt;span class="s2"&gt; in the app"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /api/v1/runs/{id}&lt;/code&gt; is a plain read: it returns 200 with the run whatever its status, because here you &lt;em&gt;are&lt;/em&gt; asking about the row rather than asking "did it work?". The &lt;code&gt;.status&lt;/code&gt; field is the answer in this shape.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;case&lt;/code&gt; covers &lt;strong&gt;every&lt;/strong&gt; terminal status, not just &lt;code&gt;success&lt;/code&gt;. A loop that only watches for &lt;code&gt;success&lt;/code&gt; runs until your CI timeout and then reports the wrong cause.&lt;/p&gt;

&lt;p&gt;If you want the run's output while debugging, &lt;code&gt;GET /api/v1/runs/{id}/logs&lt;/code&gt; returns &lt;code&gt;{"run_id": …, "logs": "…"}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotent retries: the header that stops double-runs
&lt;/h2&gt;

&lt;p&gt;CI reruns. Someone clicks "Re-run failed jobs", a runner gets evicted mid-step, a network blip makes &lt;code&gt;curl&lt;/code&gt; retry. Any of those can fire the same trigger twice — and by default, twice means two runs, two loads, and two sets of rows.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;POST&lt;/code&gt; endpoint accepts an optional &lt;strong&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt;&lt;/strong&gt; header. Replay the same key and you get the original response back instead of a second run. Keys are cached for &lt;strong&gt;24 hours&lt;/strong&gt;, and it's opt-in — no header, no deduplication.&lt;/p&gt;

&lt;p&gt;The natural key in GitHub Actions is the run identity itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--fail-with-body&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://app.datanika.io/api/v1/pipelines/1/run?wait=true&amp;amp;timeout=300"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DATANIKA_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: gha-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_RUN_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_RUN_ATTEMPT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include &lt;code&gt;GITHUB_RUN_ATTEMPT&lt;/code&gt; if a manual re-run &lt;em&gt;should&lt;/em&gt; start a fresh pipeline, and leave it out if it shouldn't. That's a real decision, not a formality — decide it deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete GitHub Actions job
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Refresh analytics&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dbt/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger the Datanika pipeline&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DATANIKA_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DATANIKA_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;

          &lt;span class="s"&gt;response=$(curl -sS -X POST \&lt;/span&gt;
            &lt;span class="s"&gt;"https://app.datanika.io/api/v1/pipelines/1/run?wait=true&amp;amp;timeout=300" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Authorization: Bearer $DATANIKA_API_KEY" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Idempotency-Key: gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \&lt;/span&gt;
            &lt;span class="s"&gt;-w "\n%{http_code}")&lt;/span&gt;

          &lt;span class="s"&gt;code=$(tail -n1 &amp;lt;&amp;lt;&amp;lt;"$response")&lt;/span&gt;
          &lt;span class="s"&gt;body=$(sed '$d' &amp;lt;&amp;lt;&amp;lt;"$response")&lt;/span&gt;

          &lt;span class="s"&gt;case "$code" in&lt;/span&gt;
            &lt;span class="s"&gt;200) echo "Loaded $(jq -r '.rows_loaded' &amp;lt;&amp;lt;&amp;lt;"$body") rows" ;;&lt;/span&gt;
            &lt;span class="s"&gt;408) echo "::warning::Still running after 300s — run $(jq -r '.id' &amp;lt;&amp;lt;&amp;lt;"$body")"&lt;/span&gt;
                 &lt;span class="s"&gt;exit 1 ;;&lt;/span&gt;
            &lt;span class="s"&gt;422) echo "::error::Pipeline run $(jq -r '.status' &amp;lt;&amp;lt;&amp;lt;"$body") — $(jq -r '.error_message // "no message"' &amp;lt;&amp;lt;&amp;lt;"$body")"&lt;/span&gt;
                 &lt;span class="s"&gt;exit 1 ;;&lt;/span&gt;
            &lt;span class="s"&gt;*)   echo "::error::API returned $code"; echo "$body"; exit 1 ;;&lt;/span&gt;
          &lt;span class="s"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four branches, because there are four genuinely different situations: it worked, it's still going, your pipeline broke, or our API did. &lt;code&gt;--fail-with-body&lt;/code&gt; collapses the middle two into one non-zero exit, which is fine when you only need pass/fail — but the messages above are what you'll want at 2am, and the 408 branch is the one people most often want to handle differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancelling a run
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 15 September 2026.&lt;/strong&gt; One clause in this section is no longer true. A run cancelled while it is running now keeps the status &lt;code&gt;cancelled&lt;/code&gt; when its task finishes; it is no longer overwritten back to &lt;code&gt;success&lt;/code&gt;. Everything else here still holds: cancelling does not stop the worker, the extract and the load run to the end, and the run is billed for everything it processed. The original text follows as it was published.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your workflow is cancelled, the pipeline it started is not. A &lt;code&gt;202&lt;/code&gt; handed the work to a background worker, and CI walking away doesn't reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the API cannot currently clean it up for you — plan around that rather than around the endpoint.&lt;/strong&gt; &lt;code&gt;POST /api/v1/runs/{id}/cancel&lt;/code&gt; exists and will return &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;"status": "cancelled"&lt;/code&gt; — but that call only writes the status onto the run row. It does not stop the worker. The extract keeps reading, the load keeps writing, and when the task finishes it overwrites the status back to &lt;code&gt;success&lt;/code&gt;. Tracked as &lt;a href="https://github.com/datanika-io/datanika-core/issues/657" rel="noopener noreferrer"&gt;core#657&lt;/a&gt;, along with the reason there is no cancel button in the app either: shipping a control onto that behaviour would spread the wrong impression to every user instead of only to API callers.&lt;/p&gt;

&lt;p&gt;So don't wire a cleanup step and believe it. Until cancellation actually stops work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bound the blast radius instead of the run.&lt;/strong&gt; Smaller, more frequent pipelines beat one long job you might want to kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume anything you triggered will finish.&lt;/strong&gt; Check the app rather than your CI log for what a cancelled workflow left behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On usage-based plans, a run you "cancelled" keeps metering&lt;/strong&gt; until it completes on its own. That's the practical reason this is a limitation worth stating rather than a detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one accurate part of the old advice: the endpoint returns &lt;strong&gt;409&lt;/strong&gt; with &lt;code&gt;"not_cancellable"&lt;/code&gt; when the run has already finished, which is a perfectly normal outcome and not something to treat as an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give CI its own key, scoped down
&lt;/h2&gt;

&lt;p&gt;API keys are created in &lt;strong&gt;Settings → API Keys&lt;/strong&gt; in the app, carry the &lt;code&gt;etf_&lt;/code&gt; prefix, and are shown &lt;strong&gt;once&lt;/strong&gt;. They're hashed with SHA-256 before storage, so a lost key can't be recovered — you create a new one and revoke the old. Full details on &lt;a href="https://datanika.io/api/keys/" rel="noopener noreferrer"&gt;the API keys page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a CI key, set the scopes explicitly rather than leaving them empty (empty means full access):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pipelines:write&lt;/code&gt; — to trigger&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runs:read&lt;/code&gt; — to poll status and read logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a key that can start and observe one kind of work and cannot delete a connection, read your credentials, or create a schedule. If it leaks into a build log, the blast radius is a pipeline someone can already trigger from the UI.&lt;/p&gt;

&lt;p&gt;Set an expiry on it too, and rotate it on a calendar rather than after an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limits
&lt;/h2&gt;

&lt;p&gt;Each key is rate-limited independently, per plan, and exceeding it returns &lt;strong&gt;429&lt;/strong&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header. Current per-plan limits are in &lt;a href="https://datanika.io/api/reference/#rate-limits" rel="noopener noreferrer"&gt;the API reference&lt;/a&gt; — a fan-out matrix build that triggers one pipeline per shard is the realistic way to hit them, so back off on 429 rather than retrying immediately.&lt;/p&gt;

&lt;p&gt;The polling loop above is well inside every tier: at &lt;code&gt;sleep 15&lt;/code&gt; it costs 4 requests a minute, and &lt;code&gt;?wait=true&lt;/code&gt; polls &lt;strong&gt;server-side&lt;/strong&gt;, so a 300-second wait is one request against your budget, not 150.&lt;/p&gt;

&lt;p&gt;Note that 429 is a genuine transport-level "try again", unlike the 422 above. It's the one 4xx here you &lt;em&gt;should&lt;/em&gt; retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easier here than in a three-tool stack
&lt;/h2&gt;

&lt;p&gt;The reason this post is short is architectural. In a Fivetran + dbt Cloud + Airflow stack, "run the pipeline and tell me if it worked" is three APIs, three auth schemes, three status vocabularies, and a decision about which failure counts. Here, extract, load, and transform are the same run object with one &lt;code&gt;status&lt;/code&gt; field, so CI asks one question once.&lt;/p&gt;

&lt;p&gt;That argument, with numbers attached, is in &lt;a href="https://datanika.io/blog/datanika-vs-modern-data-stack/" rel="noopener noreferrer"&gt;Datanika vs the Modern Data Stack&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post does not cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling.&lt;/strong&gt; If you want a pipeline to run nightly, use a &lt;a href="https://datanika.io/docs/scheduling/" rel="noopener noreferrer"&gt;schedule&lt;/a&gt; instead of a cron-triggered CI job. Cron in CI gives you the worst of both — an extra dependency and no visibility in the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks back into CI.&lt;/strong&gt; There is no outbound "run finished" callback into a workflow today; poll, or use a &lt;a href="https://datanika.io/blog/slack-alerts-pipeline-failures/" rel="noopener noreferrer"&gt;notification channel&lt;/a&gt; to tell a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every endpoint.&lt;/strong&gt; The &lt;a href="https://datanika.io/api/reference/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; has the full surface, including connections, schedules, and bulk import.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Every status code, header, scope name and default here was read out of the live OpenAPI document and the shipped route handlers, not out of our own docs. That matters more than usual for this post: the 200-on-failure behaviour in "the trap this replaced" was **found while writing the first draft of it&lt;/em&gt;&lt;em&gt;, filed as &lt;a href="https://github.com/datanika-io/datanika-core/issues/663" rel="noopener noreferrer"&gt;core#663&lt;/a&gt;, and fixed before this went out. Writing the tutorial is what surfaced the bug — so if you find a discrepancy, &lt;a href="https://github.com/datanika-io/datanika-landing/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; and we'll fix the post, or the API.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>restapi</category>
      <category>cicd</category>
      <category>githubactions</category>
      <category>automation</category>
    </item>
    <item>
      <title>The Real Cost of Your Modern Data Stack in 2026</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 09:18:12 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/the-real-cost-of-your-modern-data-stack-in-2026-2m2h</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/the-real-cost-of-your-modern-data-stack-in-2026-2m2h</guid>
      <description>&lt;p&gt;Everyone has an opinion about the "modern data stack." Very few people publish the bill.&lt;/p&gt;

&lt;p&gt;The standard pitch — &lt;a href="https://datanika.io/compare/fivetran/" rel="noopener noreferrer"&gt;Fivetran&lt;/a&gt; for extract, Snowflake for warehouse, dbt Cloud for transform, Looker for BI, Monte Carlo for observability — is sold as the default for any company with more than three engineers. In 2020 that bundle cost a small team maybe $1,500/mo. By 2024 it was closer to $3,000. In 2026, with Fivetran counting deletes as MAR and enforcing a $5 per-connection minimum, it's worse.&lt;/p&gt;

&lt;p&gt;Meanwhile, something genuinely new happened in the last eighteen months: &lt;strong&gt;open-source ELT caught up to the commercial tools, and AI coding agents got good enough to own the "glue work" that used to justify half the price tag.&lt;/strong&gt; The honest cost of running a growing team's data stack in 2026 isn't $3,000/mo. If you're willing to self-host and let an agent do the connector wiring, it's closer to $15.&lt;/p&gt;

&lt;p&gt;This post is the math, not the pitch. If you disagree, the numbers are there to argue with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2024 baseline — a growing team on managed everything
&lt;/h2&gt;

&lt;p&gt;Let's pick a realistic "growing team" profile and price it out in 2024:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 sources&lt;/strong&gt; — a couple of production Postgres databases, Stripe, HubSpot, Segment, Shopify, Salesforce, Google Ads, a couple of internal APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10M rows/mo&lt;/strong&gt; ingested across those sources&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 dbt models&lt;/strong&gt; on top — staging, intermediate, marts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 analytics engineers&lt;/strong&gt; using the transform tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what that looked like on a "default" managed stack eighteen months ago:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Extract + Load&lt;/td&gt;
&lt;td&gt;Fivetran Standard&lt;/td&gt;
&lt;td&gt;~$500 (10M MAR + $5/connection minimum × 10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warehouse&lt;/td&gt;
&lt;td&gt;Snowflake (XS, ~40hr compute/mo)&lt;/td&gt;
&lt;td&gt;~$120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transform&lt;/td&gt;
&lt;td&gt;dbt Cloud Starter&lt;/td&gt;
&lt;td&gt;$300 (3 seats × $100)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BI&lt;/td&gt;
&lt;td&gt;Looker / Metabase Cloud&lt;/td&gt;
&lt;td&gt;$200–$400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Monte Carlo Entry / Elementary Cloud&lt;/td&gt;
&lt;td&gt;$200–$500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1,320 – $1,820/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Where these numbers come from&lt;/strong&gt;, because it is the weakest part of the post and worth saying plainly: they are our estimates for the profile above, not quotes. Checked in August 2026, most of these vendors publish no rate card you can compute a bill from — Fivetran points at an estimator and a consumption table with no per-MAR rate, Airbyte prices its paid tiers on compute capacity rather than data moved, and Snowflake's pricing-options page carries no dollar figure at all. dbt Cloud Starter at $100/seat/month is the one line here taken from a published price. Your own bill will differ, sometimes by a lot; run the numbers through &lt;a href="https://www.fivetran.com/pricing" rel="noopener noreferrer"&gt;Fivetran's estimator&lt;/a&gt; and &lt;a href="https://airbyte.com/pricing" rel="noopener noreferrer"&gt;Airbyte's pricing page&lt;/a&gt; for a binding figure. The Datanika rows below are exact, because &lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;our rate is published&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Nobody budgets for this up front. It creeps in one invoice at a time until someone in finance prints all five of them on the same page.&lt;/p&gt;

&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The "extract" layer is the most expensive single line.&lt;/strong&gt; Fivetran's MAR-based pricing rewards narrow pipelines and punishes the long tail of small sources. Adding a sixth source you only sync once a day still costs $5 minimum — before any data moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transform is priced per seat, not per workload.&lt;/strong&gt; dbt Cloud charges you $100/month for the third analyst, whether they push one commit or a hundred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connector count is a lock-in metric, not a utility metric.&lt;/strong&gt; Every vendor brags about "700+ connectors." Most teams use 8–12.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What changed between 2024 and 2026
&lt;/h2&gt;

&lt;p&gt;Two things, mostly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First&lt;/strong&gt;: open-source ELT became legitimately production-ready. &lt;a href="https://dlthub.com/" rel="noopener noreferrer"&gt;&lt;code&gt;dlt&lt;/code&gt;&lt;/a&gt; (the Python library, not "data loading tool" the consulting company) went from "interesting library" to "the thing you'd actually pick" over about eighteen months. Its incremental-load primitives, schema evolution, and native typing are good enough that the main reason to pay for Fivetran — "we don't want to maintain extractors" — weakened considerably. Pair that with dbt-core (still free, still open source) and you have the whole EL-and-T loop in two Python packages.&lt;/p&gt;

&lt;p&gt;The blocker used to be &lt;strong&gt;orchestration and the UI&lt;/strong&gt;. You'd end up with a half-finished Airflow deployment, a YAML graveyard, and a Grafana dashboard nobody checks. That's the piece that actually burned out small data teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;: coding agents. In 2024, writing a new source extractor with dlt meant reading the docs, writing a test, debugging pagination, and babysitting the first few runs. In 2026, you ask Claude or GPT to scaffold it, give it the API spec, and the first draft compiles. The "agent-shaped work" — wiring up credentials, mapping columns, writing the five lines of Jinja dbt needs — is the cheapest it has ever been. (We wrote about this shift in more depth in &lt;a href="https://datanika.io/blog/ai-agent-native/" rel="noopener noreferrer"&gt;our AI-agent-native positioning post&lt;/a&gt;; the short version is that the API surface you expose to agents now matters more than the connector count.)&lt;/p&gt;

&lt;p&gt;The implication is uncomfortable for the managed-ELT vendors: &lt;strong&gt;the thing you were paying Fivetran to avoid doing is now the thing an agent does in eight minutes for the cost of a few tokens.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 line-item budget for the same team
&lt;/h2&gt;

&lt;p&gt;Same 10 sources, same 10M rows/mo, same 20 dbt models, same 3 analysts. Here's a stack built in 2026 with open-source ELT, a single VPS, and an AI agent doing the glue work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS (Hetzner CPX31 — 4 vCPU, 8 GB RAM, 160 GB NVMe)&lt;/td&gt;
&lt;td&gt;Hetzner&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;€11.49&lt;/strong&gt; (~$12.50)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshots / backups&lt;/td&gt;
&lt;td&gt;Hetzner&lt;/td&gt;
&lt;td&gt;€0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extract + Load&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dlt&lt;/code&gt; (open source)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warehouse&lt;/td&gt;
&lt;td&gt;DuckDB / Postgres on the same box, or BigQuery on-demand (~$5–$20 depending on volume)&lt;/td&gt;
&lt;td&gt;$0–$20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transform&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dbt-core&lt;/code&gt; (open source)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration + UI&lt;/td&gt;
&lt;td&gt;Datanika (open source)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BI&lt;/td&gt;
&lt;td&gt;Metabase OSS (same box)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Grafana + Prometheus (same box)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS / CDN / SSL&lt;/td&gt;
&lt;td&gt;Cloudflare free tier&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactional email&lt;/td&gt;
&lt;td&gt;Resend free tier&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI agent (pipeline build + dbt model scaffolding, ~20 hours/mo)&lt;/td&gt;
&lt;td&gt;Claude or similar&lt;/td&gt;
&lt;td&gt;~$20–$40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$15 – $75/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The range comes from two variables: (a) whether you run the warehouse locally (DuckDB/Postgres, $0) or reach for BigQuery on-demand for heavier workloads, and (b) how much you let an agent drive. At the low end, with DuckDB and light agent use, you're paying &lt;strong&gt;€15/mo to run what used to cost $1,500.&lt;/strong&gt; That's a &lt;strong&gt;100× cost reduction&lt;/strong&gt; for the same team profile.&lt;/p&gt;

&lt;p&gt;If that sounds too good, it is and isn't. The cost &lt;em&gt;of the tools&lt;/em&gt; dropped by 100×. The cost &lt;em&gt;of operating the thing&lt;/em&gt; — someone who understands how pipelines fail at 3 AM — didn't drop at all. We'll come back to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apples-to-apples: where each layer actually lands in 2026
&lt;/h2&gt;

&lt;p&gt;Here's the same 10-source, 10M-row profile priced across everything a 2026 data team could actually pick:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;EL + T + Orch&lt;/th&gt;
&lt;th&gt;Warehouse&lt;/th&gt;
&lt;th&gt;BI + Obs&lt;/th&gt;
&lt;th&gt;Total / mo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fivetran + dbt Cloud + Snowflake + Looker&lt;/td&gt;
&lt;td&gt;$900&lt;/td&gt;
&lt;td&gt;$120&lt;/td&gt;
&lt;td&gt;$400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$1,420&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Airbyte Cloud + dbt Cloud + BigQuery + Metabase OSS&lt;/td&gt;
&lt;td&gt;$550&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$570&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hevo + dbt Cloud + Snowflake + Metabase OSS&lt;/td&gt;
&lt;td&gt;$540&lt;/td&gt;
&lt;td&gt;$120&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$660&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Datanika Pro ($79) + BigQuery + Metabase OSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$79&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosted Datanika + DuckDB + Metabase OSS + agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€12&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;Datanika's $79 Pro tier&lt;/a&gt; includes EL, T, scheduling, notifications, and multi-tenant dbt projects in a single app — which is why it lands where it does. The self-hosted tier is a straight VPS bill.&lt;/p&gt;

&lt;p&gt;Before anyone writes me an angry email: yes, these are rounded. Yes, "it depends." Yes, the Snowflake bill for any team that actually writes meaningful SQL is going to be more than $120. But the order-of-magnitude gap between the top row and the bottom row is the real story and it's not a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But you're paying with your time"
&lt;/h2&gt;

&lt;p&gt;Here is the honest objection. Managed stacks aren't expensive because the vendors are greedy. They're expensive because someone, somewhere, is being paid to keep the pipelines running at 3 AM when the Shopify API changes its pagination without telling anyone. That someone is a real cost, and when you self-host you inherit their job.&lt;/p&gt;

&lt;p&gt;Two things have changed about that math:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The operational surface got smaller.&lt;/strong&gt; A decade ago self-hosting "the data stack" meant running Airflow (a distributed scheduler you will fight), a warehouse you provision yourself, and a half-dozen custom extractors. In 2026 it means one Docker Compose file with six containers, running on a $12 box, with &lt;code&gt;dlt&lt;/code&gt; handling the boring parts of extract and &lt;code&gt;dbt-core&lt;/code&gt; handling transform. If the box falls over, you restore a snapshot. That's not zero work, but it's far from a full-time job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents absorb the worst part.&lt;/strong&gt; The failure mode that used to eat analytics engineers alive wasn't writing the pipeline — it was &lt;em&gt;modifying&lt;/em&gt; it. Stripe adds a field. Shopify deprecates an endpoint. HubSpot changes its OAuth scopes. In 2024 this was half your calendar. In 2026 you open Claude, paste the error, and it hands you back the patch. The schema-drift tax didn't disappear, but its price collapsed.&lt;/p&gt;

&lt;p&gt;So yes, you're "paying with your time" — but the bill is roughly one afternoon a month for a team of three, versus zero afternoons a month but $1,500. Plenty of teams should still pick managed. Plenty shouldn't. The default changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three questions that actually decide it
&lt;/h2&gt;

&lt;p&gt;If you're sitting on a 2024-era bill and wondering whether to rip it out, don't start with the tools. Start with these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How many of your sources are in the long tail?&lt;/strong&gt; If seven of your ten sources are small and only syncing once a day, Fivetran's $5-per-connection minimum is eating you alive. Self-hosting wins on long-tail pipelines and loses on high-volume ones. Look at your &lt;em&gt;distribution&lt;/em&gt; of row volume, not the total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you have anyone who can debug a Python stack trace?&lt;/strong&gt; Not "love doing it" — just "can." The agent-assisted version of self-hosting is dramatically easier than the 2020 version, but there's still a floor. If nobody on the team can read a traceback, pay for managed and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is your warehouse bill the biggest line item?&lt;/strong&gt; If yes, this post doesn't help you. Nothing in the EL/T layer moves the needle compared to rewriting a few nested CTEs. Go fix the warehouse first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers are "yes long-tail, yes Python, no warehouse isn't the biggest" — the 2026 stack at $15/mo is a real option, not a thought experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Datanika lands in this
&lt;/h2&gt;

&lt;p&gt;You can do everything in this post without us. &lt;code&gt;dlt&lt;/code&gt; is on PyPI, &lt;code&gt;dbt-core&lt;/code&gt; is on PyPI, DuckDB is on PyPI, Metabase ships a Docker image. Nothing about "open-source ELT + an agent" requires buying anything from anyone.&lt;/p&gt;

&lt;p&gt;What Datanika is, honestly, is &lt;strong&gt;the UI and orchestration layer that keeps the six-container Docker Compose from turning into Airflow&lt;/strong&gt;. We wrap &lt;code&gt;dlt&lt;/code&gt; for extract, &lt;code&gt;dbt-core&lt;/code&gt; for transform, APScheduler + Celery for scheduling, and add a multi-tenant Reflex UI so you can point-and-click the boring parts and let an agent drive the rest via our &lt;a href="https://datanika.io/ai-agents/" rel="noopener noreferrer"&gt;Agent API&lt;/a&gt;. The core is open source under AGPL-3.0; the billing layer is a paid plugin. If self-hosting scares you, Pro is $79/mo. If it doesn't, the core runs on the same €12 VPS class as everything else in this post.&lt;/p&gt;

&lt;p&gt;We are not the cheapest thing on the table — &lt;code&gt;dlt&lt;/code&gt; + &lt;code&gt;dbt-core&lt;/code&gt; hand-rolled is. We are the cheapest thing with a UI and scheduling and notifications that an agent can talk to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The punchline
&lt;/h2&gt;

&lt;p&gt;The modern data stack in 2024 was an unbundling story: one tool per layer, each one priced for the Fortune 500, all of them assuming you had a dedicated data team. The 2026 version is a rebundling story. &lt;code&gt;dlt&lt;/code&gt; + &lt;code&gt;dbt-core&lt;/code&gt; + one VPS + one agent collapses five vendor invoices into a Hetzner bill with a euro sign on it.&lt;/p&gt;

&lt;p&gt;None of this is theoretical. We publish &lt;a href="https://datanika.io/blog/saas-12-euros/" rel="noopener noreferrer"&gt;our own infrastructure bill&lt;/a&gt;, line by line, including what changed when we moved hosts. Our &lt;a href="https://datanika.io/ai-agents/" rel="noopener noreferrer"&gt;Agent API&lt;/a&gt; exists because we use agents to build pipelines on Datanika, not just because other people might.&lt;/p&gt;

&lt;p&gt;If the number on your current data-tools invoice has three commas in it and you haven't looked at this math in twelve months, look again. A lot changed while you weren't looking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source data pipeline platform — &lt;code&gt;dlt&lt;/code&gt; for extract, &lt;code&gt;dbt-core&lt;/code&gt; for transform, APScheduler for orchestration, Reflex for the UI. &lt;a href="https://app.datanika.io/" rel="noopener noreferrer"&gt;Start free&lt;/a&gt;, &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-host it&lt;/a&gt;, or &lt;a href="https://datanika.io/docs/ai-agents/" rel="noopener noreferrer"&gt;read the Agent API docs&lt;/a&gt; if you want an agent to drive the whole thing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>moderndatastack</category>
      <category>pricing</category>
      <category>cost</category>
      <category>opensource</category>
    </item>
    <item>
      <title>dbt Incremental Models Duplicate Rows When `unique_key` Is NULL — and the Fix Is Off by Default</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:29:01 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/dbt-incremental-models-duplicate-rows-when-uniquekey-is-null-and-the-fix-is-off-by-default-2pmd</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/dbt-incremental-models-duplicate-rows-when-uniquekey-is-null-and-the-fix-is-off-by-default-2pmd</guid>
      <description>&lt;p&gt;You have an incremental model. It has a &lt;code&gt;unique_key&lt;/code&gt;. You ran it twice on overlapping data, and the row count went up instead of staying flat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;materialized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'incremental'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'shop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_incremental&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="p"&gt;}})&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endif&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the table updates correctly. A small, stubborn subset duplicates on every single run, and it is always the same rows.&lt;/p&gt;

&lt;p&gt;Check whether &lt;code&gt;order_id&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; in exactly those rows. It almost certainly is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why NULL keys never match
&lt;/h2&gt;

&lt;p&gt;dbt's incremental merge strategy compiles to a &lt;code&gt;MERGE&lt;/code&gt; statement whose join predicate compares the incoming batch to the existing table. In the default macro that predicate is, at heart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;merge&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;my_model&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;DBT_INTERNAL_DEST&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;my_model__dbt_tmp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;DBT_INTERNAL_SOURCE&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DBT_INTERNAL_SOURCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DBT_INTERNAL_DEST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now recall what SQL does with &lt;code&gt;NULL = NULL&lt;/code&gt;. It does not return &lt;code&gt;TRUE&lt;/code&gt;. It does not return &lt;code&gt;FALSE&lt;/code&gt;. It returns &lt;code&gt;UNKNOWN&lt;/code&gt;, and &lt;code&gt;MERGE&lt;/code&gt; treats anything that is not &lt;code&gt;TRUE&lt;/code&gt; as "no match."&lt;/p&gt;

&lt;p&gt;So for every row whose key is &lt;code&gt;NULL&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;when matched&lt;/code&gt; is never reached.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;when not matched then insert&lt;/code&gt; fires.&lt;/li&gt;
&lt;li&gt;A second copy is appended.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run it again tomorrow and you get a third. The model is not broken in a way that errors — it is broken in a way that quietly accumulates. This is the same three-valued logic that makes &lt;code&gt;where status != 'shipped'&lt;/code&gt; silently drop &lt;code&gt;NULL&lt;/code&gt; statuses, arriving in a place where it costs you row counts instead of filter results.&lt;/p&gt;

&lt;p&gt;Two consequences worth internalising:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unique_key&lt;/code&gt; is not a uniqueness constraint.&lt;/strong&gt; dbt never enforces it. It is a join hint, and a join hint that is &lt;code&gt;NULL&lt;/code&gt; matches nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bug is proportional to your NULL rate, not your data volume.&lt;/strong&gt; A key that is &lt;code&gt;NULL&lt;/code&gt; in 0.1% of rows produces a slow leak that no one notices until a dashboard's totals drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  dbt fixed it — in 2025, behind a flag, off by default
&lt;/h2&gt;

&lt;p&gt;The upstream issue is &lt;a href="https://github.com/dbt-labs/dbt-core/issues/7597" rel="noopener noreferrer"&gt;dbt-core#7597&lt;/a&gt; / &lt;a href="https://github.com/dbt-labs/dbt-adapters/issues/159" rel="noopener noreferrer"&gt;dbt-adapters#159&lt;/a&gt;, &lt;em&gt;"[CT-2563] [Bug] Incremental updates result in duplicates if values for any &lt;code&gt;unique_key&lt;/code&gt; are &lt;code&gt;null&lt;/code&gt;"&lt;/em&gt;. It was opened in May 2023, went stale twice, and was &lt;strong&gt;closed as resolved on 2026-03-03&lt;/strong&gt; by &lt;a href="https://github.com/dbt-labs/dbt-adapters/pull/744" rel="noopener noreferrer"&gt;dbt-adapters#744&lt;/a&gt;, which merged on 2025-02-04.&lt;/p&gt;

&lt;p&gt;If you find that thread while debugging, it reads as fixed. It is fixed only if you opt in.&lt;/p&gt;

&lt;p&gt;Here is the shipped &lt;code&gt;equals&lt;/code&gt; macro on dbt-adapters' &lt;code&gt;main&lt;/code&gt;, in &lt;code&gt;macros/utils/equals.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;macro&lt;/span&gt; &lt;span class="nv"&gt;default__equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;expr1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;expr2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;adapter.behavior.enable_truthy_nulls_equals_macro.no_warn&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    case when ((&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr1&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; = &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr2&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;) or (&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr1&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; is null and &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr2&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; is null))
        then 0
        else 1
    end = 0
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    (&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr1&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; = &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;expr2&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;)
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endmacro&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NULL-aware comparison is real, and it is on the &lt;code&gt;if&lt;/code&gt; branch. The &lt;code&gt;else&lt;/code&gt; branch — the one you get by default — is still the plain &lt;code&gt;=&lt;/code&gt; that started the problem.&lt;/p&gt;

&lt;p&gt;The flag's registration in &lt;code&gt;dbt/adapters/base/impl.py&lt;/code&gt; is worth reading in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enable_truthy_nulls_equals_macro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"default": False&lt;/code&gt;, and an &lt;strong&gt;empty &lt;code&gt;docs_url&lt;/code&gt;&lt;/strong&gt;. There is no documentation page to link to, which is a fair part of why a fix that has been available since early 2025 is still an unfamiliar flag in 2026.&lt;/p&gt;

&lt;p&gt;Turn it on in &lt;code&gt;dbt_project.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enable_truthy_nulls_equals_macro&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then recompile a model and read the generated SQL in &lt;code&gt;target/run/&lt;/code&gt; before you trust it. If the predicate still says &lt;code&gt;(DBT_INTERNAL_SOURCE.order_id = DBT_INTERNAL_DEST.order_id)&lt;/code&gt; with no &lt;code&gt;is null&lt;/code&gt; branch, the flag has not taken effect — which is your signal that your installed dbt-adapters predates PR #744.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag does not cover composite keys
&lt;/h2&gt;

&lt;p&gt;This is the part that will bite people who enable the flag and assume they are done.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;unique_key&lt;/code&gt; can be a single column or a list. Those two take different paths through &lt;code&gt;default__get_merge_sql&lt;/code&gt;, and only one of them goes near the &lt;code&gt;equals&lt;/code&gt; macro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;unique_key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;sequence&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nv"&gt;unique_key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;mapping&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nv"&gt;unique_key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;string&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;unique_key&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;this_key_match&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            DBT_INTERNAL_SOURCE.&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; = DBT_INTERNAL_DEST.&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endset&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nv"&gt;predicates.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;this_key_match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;unique_key_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;get_merge_unique_key_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;source_unique_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;target_unique_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nv"&gt;predicates.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;unique_key_match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list branch writes a bare &lt;code&gt;=&lt;/code&gt; for each column and never calls &lt;code&gt;get_merge_unique_key_match&lt;/code&gt;, which is the only route to &lt;code&gt;equals()&lt;/code&gt;. So in the default merge macro, &lt;code&gt;unique_key: ['tenant_id', 'order_id']&lt;/code&gt; stays NULL-unsafe whether or not you set the flag.&lt;/p&gt;

&lt;p&gt;One honest caveat on that: this is the &lt;strong&gt;default&lt;/strong&gt; macro from dbt's global project. Adapters are free to override &lt;code&gt;get_merge_sql&lt;/code&gt; and &lt;code&gt;get_merge_unique_key_match&lt;/code&gt;, so your warehouse's adapter may behave differently. Compile the model and read the emitted predicate — that is the only answer that is true for your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fix the data, not the comparison.&lt;/strong&gt; A &lt;code&gt;NULL&lt;/code&gt; in a key column is usually telling you something upstream is wrong. Before reaching for a workaround, find out why &lt;code&gt;order_id&lt;/code&gt; is missing.&lt;/p&gt;

&lt;p&gt;When the NULL is legitimate, give the merge something real to match on. A sentinel keeps the predicate on the fast path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;materialized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'incremental'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unique_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'order_key'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;order_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For composite keys, collapse them into one surrogate column so you are back on the scalar branch — and so that a single NULL component does not silently disable the whole match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;materialized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'incremental'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'row_key'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;dbt_utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate_surrogate_key&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;row_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;generate_surrogate_key&lt;/code&gt; coalesces NULLs to a string literal before hashing, which is exactly the property you want here. It also means one column to test rather than two to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch it before a dashboard does
&lt;/h2&gt;

&lt;p&gt;The reason this bug survives so long in real projects is that nothing fails. Add the tests that turn silence into a red run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order_id&lt;/span&gt;
        &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;not_null&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unique&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;not_null&lt;/code&gt; on the key column is the one that matters — it fails at the source of the problem rather than at the symptom. &lt;code&gt;unique&lt;/code&gt; is the backstop that catches duplication arriving by any other route.&lt;/p&gt;

&lt;p&gt;If you use a composite key, test the surrogate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;row_key&lt;/span&gt;
        &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;not_null&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unique&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A model whose &lt;code&gt;unique_key&lt;/code&gt; has no &lt;code&gt;not_null&lt;/code&gt; test is a model that has assumed something it never checked. That assumption is free until the day a source system starts emitting partial rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;We run dbt-core in production, one project per tenant, so compiled SQL is something we read rather than something we take on faith. This bug is a good argument for a habit that generalises well beyond it: &lt;strong&gt;when a tool gives you a green run, ask what the green actually compared.&lt;/strong&gt; dbt's incremental run succeeded. It emitted valid SQL. It inserted rows. Every layer reported success, and the model was still wrong — because nothing in the chain was checking the thing that broke.&lt;/p&gt;

&lt;p&gt;If you would rather run dlt extraction, dbt transformation and scheduling in one place instead of gluing them together, that is what &lt;a href="https://datanika.io/" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; is. It is open source, self-hostable with &lt;code&gt;docker compose up&lt;/code&gt;, and the &lt;a href="https://datanika.io/docs/" rel="noopener noreferrer"&gt;docs&lt;/a&gt; start with a working pipeline rather than a concepts tour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; &lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;How we run a dbt project per tenant&lt;/a&gt; · &lt;a href="https://datanika.io/blog/stripe-revenue-dashboard-dbt/" rel="noopener noreferrer"&gt;Building a Stripe revenue dashboard with dbt&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dbt</category>
      <category>sql</category>
      <category>troubleshooting</category>
      <category>datamodeling</category>
    </item>
    <item>
      <title>Changelog: password reset and password change are live</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:11:44 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/changelog-password-reset-and-password-change-are-live-3n49</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/changelog-password-reset-and-password-change-are-live-3n49</guid>
      <description>&lt;p&gt;Until this release, Datanika had no way to reset a forgotten password and no way to change one. Not a hidden flow, not an admin-only flow — it did not exist. That is now fixed, and this is the changelog entry rather than a launch announcement, because a missing password reset is not a feature you get to celebrate shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/forgot-password&lt;/code&gt;&lt;/strong&gt; — enter your email, receive a reset link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/reset-password&lt;/code&gt;&lt;/strong&gt; — set a new password from that link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change your password from Settings&lt;/strong&gt;, with your current password as confirmation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the reset token works
&lt;/h2&gt;

&lt;p&gt;The interesting part of a password reset is not the form; it's the token. Six decisions went into ours, and each one is there because the obvious alternative fails in a specific way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token is stored hashed, not signed.&lt;/strong&gt; 32 bytes from &lt;code&gt;secrets.token_urlsafe&lt;/code&gt; — about 256 bits — SHA-256'd at rest. At that entropy the hash needs no stretching, and a database dump yields hashes of dead capabilities rather than live ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It expires after 60 minutes.&lt;/strong&gt; Long enough to walk away from your desk, short enough that a mail sitting in a synced archive stops being a key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validating a token never consumes it.&lt;/strong&gt; This is the one that bites people. Corporate mail scanners and link-preview bots fetch every URL in an incoming message — so if the reset page burned the token on load, the scanner would burn it and the user's own click would always land on "this link is invalid." Loading the page validates; only submitting a new password consumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rejected password doesn't burn the token either.&lt;/strong&gt; Type a password that's too short and you get an error, not another round trip through your mailbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requesting a new link invalidates the old one.&lt;/strong&gt; Two live tokens for one account doubles the window in which an intercepted email is useful — and people request a second link precisely when they think the first went astray, which is when both are floating around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consumption is atomic.&lt;/strong&gt; The token is claimed with a conditional &lt;code&gt;UPDATE&lt;/code&gt;, so two submissions racing on the same link both pass validation and exactly one wins.&lt;/p&gt;

&lt;p&gt;There's also no user-enumeration oracle: requesting a reset for an address with no account renders exactly what a real one renders. And requests are rate-limited three ways — per email address, per IP, and separately on the consume step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Password rules
&lt;/h2&gt;

&lt;p&gt;Minimum 8 characters. Maximum 72 &lt;strong&gt;bytes&lt;/strong&gt;, which is bcrypt's limit, not a preference — so a passphrase in a non-Latin script hits the ceiling sooner than its character count suggests. No composition rules: no forced symbol, no forced digit, no forced capital. Length beats punctuation, and the rules that force a &lt;code&gt;!&lt;/code&gt; on the end mostly produce passwords ending in &lt;code&gt;!&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Changing your password requires your current one. If your account was created through OAuth and has never had a password, you're setting one rather than changing it, and there is no current password to prove — the flow works that out from the account rather than asking you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting: this needs SMTP
&lt;/h2&gt;

&lt;p&gt;Worth being explicit, because it will otherwise look like a bug. &lt;strong&gt;Password reset requires an outbound mail server.&lt;/strong&gt; If &lt;code&gt;SMTP_HOST&lt;/code&gt; isn't configured on your instance, &lt;code&gt;/forgot-password&lt;/code&gt; shows an "unavailable" notice instead of the form.&lt;/p&gt;

&lt;p&gt;That notice is deliberately instance-level rather than per-account — it tells you the instance can't send mail, not whether the address you typed exists. Configure SMTP and the flow lights up. Everything else here works the same self-hosted as it does on our cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;This is one narrow thing, and it would be easy to read it as more than it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's password recovery, not account management.&lt;/strong&gt; You still cannot change the email address on an account, and there is no self-serve account or organization deletion. Both are real gaps, both are on the list, and neither ships in this release. If you need either, mail us and a human will do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not a "we're ready" post.&lt;/strong&gt; We're pre-launch with no paying customers, and there is a list of things a mature product has that we don't. Password reset was near the top of it. It is now off the list. The list is not empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it took this long
&lt;/h2&gt;

&lt;p&gt;Honestly: because we built the interesting parts first. Connectors, a metering system, an MCP surface, a benchmark — all more fun than a form that emails you a link. It took an audit that asked "what happens when a user forgets their password?" to notice the answer was "nothing, forever."&lt;/p&gt;

&lt;p&gt;That's the recurring failure mode of building fast, and it's the same one we wrote about in &lt;a href="https://datanika.io/blog/security-tests-before-launch/" rel="noopener noreferrer"&gt;the 109 security tests we ran before launch&lt;/a&gt;: the gap isn't in the code you wrote, it's in the code you never thought to write. A test suite can only fail on paths that exist.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source data pipeline platform — &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-host it&lt;/a&gt; or &lt;a href="https://app.datanika.io/" rel="noopener noreferrer"&gt;start free&lt;/a&gt;. The account docs live under &lt;a href="https://datanika.io/docs/organizations/" rel="noopener noreferrer"&gt;Organizations&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>changelog</category>
      <category>security</category>
      <category>authentication</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>My SaaS Ran on €12 a Month (April 2026). Here's the Full Stack.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:06:43 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/my-saas-runs-on-eu12-a-month-heres-the-full-stack-4b7b</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/my-saas-runs-on-eu12-a-month-heres-the-full-stack-4b7b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update — 3 September 2026.&lt;/strong&gt; This is the stack as it stood in &lt;strong&gt;April 2026&lt;/strong&gt;, and the hosting line has since changed. Production moved off Hetzner in July 2026 and now runs on a VPS with &lt;strong&gt;Pointer (&lt;code&gt;pointer.gr&lt;/code&gt;), in Athens&lt;/strong&gt; — still one box, still Docker Compose, still the EU. So &lt;strong&gt;every mention of Hetzner below is historical&lt;/strong&gt; — the cost rows, the snapshot-retention row, the DNS table, and the paragraph about what happens if that data centre goes down: €11.49 is Hetzner's price, not what we pay today, and Nuremberg is not where your data is. &lt;strong&gt;What we pay today is €22.20 a month&lt;/strong&gt; for the Pointer Gold VPS — the figure on the renewal invoice dated 6 August 2026, covering 15 August to 15 September 2026. Everything else — the container layout, the free tiers, the reasoning about when this stops working — still holds.&lt;/p&gt;

&lt;p&gt;The current provider, country and backup arrangement are listed on &lt;a href="https://datanika.io/trust/" rel="noopener noreferrer"&gt;our trust page&lt;/a&gt;, and that page is the one to believe if it ever disagrees with this one.&lt;/p&gt;

&lt;p&gt;We have left the original figures in the table rather than quietly restating them. A cost post that edits its own numbers after the fact is not worth reading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;People assume you need hundreds of dollars in cloud bills to run a SaaS. Here's what &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; actually costs to operate, line by line, with no hand-waving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bill
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner CPX31 (Nuremberg)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€11.49&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 vCPU, 8 GB RAM, 160 GB NVMe — runs the entire platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner snapshot retention&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0.20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Weekly snapshots, 30-day retention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aweb VPS (landing site)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;€0&lt;/strong&gt; marginal&lt;/td&gt;
&lt;td&gt;Sits on a shared server I already pay for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare DNS + CDN + SSL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free tier covers everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend (transactional email)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free tier (3,000 emails/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paddle (billing)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-revenue. 5% + $0.50 per transaction once we're not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operational total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€11.69&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What hits my card every month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Porkbun domains (3 × ~€10/year)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;€2.50&lt;/strong&gt; amortized&lt;/td&gt;
&lt;td&gt;datanika.io, datanika.cloud, datanika.pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All-in total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~€14.20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Including amortized one-time costs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "12 euros" headline is the &lt;strong&gt;operational&lt;/strong&gt; number — compute and bandwidth. Domains are a once-a-year purchase amortized into a monthly figure. I'm calling that out because most "cheap stack" posts conveniently leave it out, then someone notices in the comments and the post loses credibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Runs on the €11.49 Box
&lt;/h2&gt;

&lt;p&gt;The Hetzner CPX31 runs &lt;strong&gt;eight Docker containers&lt;/strong&gt; via a single &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reflex frontend + Starlette backend (ports 3000 / 8000)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-celery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background task worker for pipeline runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL 16 — app metadata + pipeline data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Celery broker + APScheduler job store + session cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-grafana&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Internal-only monitoring dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-prometheus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metrics scraper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-cadvisor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-container resource metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datanika-node-exporter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Host-level metrics (CPU, disk, network)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly 2 GB resident, leaving 6 GB headroom for query workloads, build cache, and Celery task spikes. The box is dramatically over-provisioned for current usage — but the next size down is half the RAM and the savings would be ~€5/month, not worth the migration friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No managed Postgres. No managed Redis. No Kubernetes. No CDN paid tier.&lt;/strong&gt; Just Docker Compose, nginx as reverse proxy, and Cloudflare in front.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Landing Site Costs Nothing Extra
&lt;/h2&gt;

&lt;p&gt;The marketing site at &lt;code&gt;datanika.io&lt;/code&gt; sits on a separate VPS (Aweb) that I already pay for, hosting other unrelated projects. Adding the Datanika landing site to that box was zero marginal cost — nginx serves a static Astro build from &lt;code&gt;/var/www/datanika.io/&lt;/code&gt;. If I had to spin up a dedicated VPS, it'd add maybe €5/mo. Currently it doesn't.&lt;/p&gt;

&lt;p&gt;I'm being deliberate about saying "marginal cost" instead of "free". Free implies no underlying cost exists. Marginal cost reflects the fact that adding one more static site to an already-running nginx is genuinely zero new spending — but the box itself is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS and SSL: Cloudflare Free Tier
&lt;/h2&gt;

&lt;p&gt;Three domains (datanika.io, datanika.cloud, datanika.pro) on Porkbun with Cloudflare DNS. Redirect rules handle the routing between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;datanika.io&lt;/code&gt; → landing site on Aweb&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.datanika.io&lt;/code&gt; → SaaS app on Hetzner &lt;em&gt;(Pointer, in Athens, since July 2026 — see the update at the top)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;datanika.cloud&lt;/code&gt; → 301 redirects to &lt;code&gt;app.datanika.io&lt;/code&gt; (and legal pages redirect to &lt;code&gt;datanika.io&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SSL via Cloudflare Origin Certificates on both servers. Full strict mode. Zero cost, zero maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works (And When It Will Stop)
&lt;/h2&gt;

&lt;p&gt;The trick is &lt;strong&gt;not overengineering early&lt;/strong&gt;. No Kubernetes orchestration, no managed database, no Redis cluster, no Datadog, no PagerDuty. One VPS, Docker Compose, and a monitoring stack that runs on the same box it's monitoring.&lt;/p&gt;

&lt;p&gt;Yes, that's a single point of failure. Yes, if Hetzner Nuremberg goes down, so does the SaaS. The probability is low and the alternative — multi-region active-active — would 5x the cost for an app with zero paying customers.&lt;/p&gt;

&lt;p&gt;When the bottleneck becomes infrastructure rather than users, here's what gets added (in order):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Managed Postgres&lt;/strong&gt; (~€15/mo) — when point-in-time recovery becomes a real need&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate Celery worker&lt;/strong&gt; (~€11/mo) — when one box can't run pipelines and serve UI on the same cores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read replica&lt;/strong&gt; (~€11/mo) — when SQL Editor queries start blocking app traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare paid tier&lt;/strong&gt; (~€20/mo) — only if image optimization or edge caching becomes load-bearing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's ~€50/mo of growth headroom, all deferred until usage justifies it. &lt;strong&gt;Right now the bottleneck is users, not infrastructure.&lt;/strong&gt; Every euro saved on hosting is one more month of runway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Not on the Bill
&lt;/h2&gt;

&lt;p&gt;A few things I get for free that would otherwise cost money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; — free for public repos. The &lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;open-source core&lt;/a&gt; lives there&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; — free CI minutes for public repos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error tracking&lt;/strong&gt; — none. Errors go to &lt;code&gt;docker logs&lt;/code&gt; and Grafana. Will add Sentry's free tier when I outgrow tail-the-logs debugging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging server&lt;/strong&gt; — none. The deploy pipeline runs build + tests in CI before touching production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I were spending €200/month with no paying users, every one of those services would feel "necessary". At €12/mo, the discipline of "buy nothing until it hurts" stays sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Self-Hosting Story
&lt;/h2&gt;

&lt;p&gt;Because the infrastructure is this simple, self-hosting is genuinely straightforward. The same &lt;code&gt;docker-compose.yml&lt;/code&gt; that runs production runs your self-hosted instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/datanika-io/datanika-core.git
&lt;span class="nb"&gt;cd &lt;/span&gt;datanika-core
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. App on port 3000, no Kubernetes, no cloud provider lock-in. The &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt; covers configuration, backups, and upgrades.&lt;/p&gt;

&lt;p&gt;Once the containers are up, the fastest way to verify everything works is the &lt;a href="https://datanika.io/templates/csv-to-duckdb/" rel="noopener noreferrer"&gt;CSV to DuckDB template&lt;/a&gt; — zero credentials, zero cloud accounts, first rows loaded in two minutes. No egress fees to trip over, no service-account JSON to provision.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://datanika.io/blog/open-core-plugin/" rel="noopener noreferrer"&gt;open-core architecture&lt;/a&gt; means self-hosted gets all features — the billing plugin simply doesn't load. No crippled "community edition".&lt;/p&gt;

&lt;h2&gt;
  
  
  If You're Launching
&lt;/h2&gt;

&lt;p&gt;If you're launching a SaaS and spending more than €20/mo on infrastructure before you have paying users, ask yourself why. Most "what stack should I use" discussions get answered with whatever's trending on Hacker News. The boring answer — one VPS, one database, Docker Compose, Cloudflare — works for an enormous range of indie SaaS scale. It'll get you to your first 100 paying customers without rearchitecting anything.&lt;/p&gt;

&lt;p&gt;Building in public. What does your SaaS infrastructure cost you each month? Open a &lt;a href="https://github.com/datanika-io/datanika-core/discussions" rel="noopener noreferrer"&gt;discussion on GitHub&lt;/a&gt; — I'm collecting cost breakdowns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/open-core-plugin/" rel="noopener noreferrer"&gt;How I Split Open-Source Core From Paid Cloud&lt;/a&gt; — the architecture that lets self-hosted users run the full platform for free&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt; — Docker Compose setup, environment variables, and upgrade flow&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; — what the cloud edition adds on top (Free / Pro / Enterprise)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/solo-etl-platform-18-phases/" rel="noopener noreferrer"&gt;I Built an ETL Platform Solo&lt;/a&gt; — how scope control kept the infrastructure this simple&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bootstrap</category>
      <category>infrastructure</category>
      <category>cost</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Introducing Datanika: Open-Source Data Pipelines for Everyone</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:01:41 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/introducing-datanika-open-source-data-pipelines-for-everyone-4emc</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/introducing-datanika-open-source-data-pipelines-for-everyone-4emc</guid>
      <description>&lt;h2&gt;
  
  
  Why We Built Datanika
&lt;/h2&gt;

&lt;p&gt;Building data pipelines shouldn't require stitching together five different tools. Most teams today need a data loader (Fivetran, Airbyte), a transformation tool (dbt Cloud), a scheduler (Airflow, Dagster), and a monitoring solution — each with its own learning curve, billing, and maintenance burden.&lt;/p&gt;

&lt;p&gt;Datanika takes a different approach: &lt;strong&gt;one platform for the entire pipeline&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Datanika Does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extract &amp;amp; Load&lt;/strong&gt; — Connect to 30 sources (databases, APIs, SaaS tools, files) and load data into your warehouse using &lt;a href="https://dlthub.com" rel="noopener noreferrer"&gt;dlt&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transform&lt;/strong&gt; — Write SQL models powered by &lt;a href="https://www.getdbt.com" rel="noopener noreferrer"&gt;dbt-core&lt;/a&gt;, with a built-in editor, tests, and snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrate&lt;/strong&gt; — Build visual pipelines with a DAG editor, set up schedules with cron expressions, and define dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor&lt;/strong&gt; — Track every run with streaming logs, error details, and execution history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open Source First
&lt;/h2&gt;

&lt;p&gt;Datanika's core is open source under AGPL-3.0. You can &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-host it with Docker Compose&lt;/a&gt; or use our managed platform at &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;app.datanika.io&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;Sign up for free at &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;app.datanika.io&lt;/a&gt; — no credit card required. Your first 500 model runs per month are free, forever.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/getting-started/" rel="noopener noreferrer"&gt;Getting Started Guide&lt;/a&gt; — first pipeline in 5 minutes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;35 Connectors&lt;/a&gt; — databases, SaaS APIs, files, and streaming&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; — Free / Pro / Enterprise tiers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture Overview&lt;/a&gt; — how the pieces fit together&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>announcement</category>
      <category>opensource</category>
      <category>datapipelines</category>
    </item>
    <item>
      <title>Datanika is AI-Agent Native: Build Data Pipelines with Autonomous Agents</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 19:01:35 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/datanika-is-ai-agent-native-build-data-pipelines-with-autonomous-agents-161a</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/datanika-is-ai-agent-native-build-data-pipelines-with-autonomous-agents-161a</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: AI Agents Can't Use Your Data Pipeline Tool
&lt;/h2&gt;

&lt;p&gt;AI agents are getting good at writing code, answering questions, and managing workflows. But try pointing one at Fivetran, Airbyte, or Stitch and asking it to build a data pipeline. It won't get far.&lt;/p&gt;

&lt;p&gt;These platforms weren't designed for machines. Their APIs lack discovery endpoints, config schemas are undocumented blobs, and there's no way for an agent to validate its work before executing it. The agent guesses, fails, and loops.&lt;/p&gt;

&lt;p&gt;We decided to fix this. (If you missed it, we shipped the &lt;a href="https://datanika.io/blog/datanika-rest-api-v1/" rel="noopener noreferrer"&gt;REST API v1&lt;/a&gt; last week — the agent stack builds on top of it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Shipped: 5 Tiers of Agent Compatibility
&lt;/h2&gt;

&lt;p&gt;Starting today, Datanika's API is fully agent-native. We built five tiers of capability that give an autonomous agent everything it needs to build a complete data pipeline from scratch — no human intervention required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 1 — Discover &amp;amp; Introspect
&lt;/h3&gt;

&lt;p&gt;An agent needs to know what's available before it can build anything. Our meta endpoints return full JSON Schema for every connection type, dlt config option, dbt test type, and materialization strategy. The agent doesn't guess — it reads the schema and builds valid configs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/meta/connection-types     → 32 types with config schemas
GET /api/v1/meta/dlt-config-schema    → full upload config schema
GET /api/v1/meta/dbt-tests            → test types with parameter schemas
POST /api/v1/connections/{id}/introspect → list source tables
POST /api/v1/connections/{id}/columns    → column types and metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tier 2 — Build
&lt;/h3&gt;

&lt;p&gt;Standard CRUD for every resource: connections, uploads, pipelines, transformations, schedules, and notification channels. The agent builds the pipeline piece by piece using the schemas it discovered in Tier 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 3 — Validate Before Executing
&lt;/h3&gt;

&lt;p&gt;This is where most competitor APIs fall short. Datanika lets the agent compile a dbt transformation (catching Jinja and ref errors) and preview the output — all without touching production data. Typed error codes (&lt;code&gt;compilation_error&lt;/code&gt;, &lt;code&gt;execution_error&lt;/code&gt;, &lt;code&gt;unsafe_sql&lt;/code&gt;) let the agent branch on failures instead of regex-matching error messages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v1/transformations/{id}/compile   → validate SQL + Jinja
POST /api/v1/transformations/{id}/preview   → sample output rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tier 4 — Execute with Control
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 15 September 2026.&lt;/strong&gt; "Cancel stuck runs" below says more than the endpoint does. &lt;code&gt;POST /api/v1/runs/{id}/cancel&lt;/code&gt; marks a run &lt;code&gt;cancelled&lt;/code&gt;, but it does not stop the work: the run continues to the end and is billed for everything it processed. If a run looks stuck, read &lt;code&gt;GET /api/v1/runs/{id}&lt;/code&gt; and &lt;code&gt;GET /api/v1/runs/{id}/logs&lt;/code&gt; instead of cancelling it and triggering it again, because a second trigger starts the same job beside the first, and an upload that appends would load its rows twice. The original text follows as it was published.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Trigger runs and wait for completion in a single request (&lt;code&gt;?wait=true&lt;/code&gt;). Cancel stuck runs. Retry safely with &lt;code&gt;Idempotency-Key&lt;/code&gt; headers — same key within 24 hours returns the cached response instead of creating duplicates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v1/uploads/{id}/run?wait=true     → run and wait
POST /api/v1/runs/{id}/cancel               → cancel if stuck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tier 5 — Machine-Readable Discovery
&lt;/h3&gt;

&lt;p&gt;Two unauthenticated documents that tell any LLM how to use the platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/strong&gt; — plain-text discovery file at the site root. Contains the API summary, base URL, auth format, rate limits, and the 5-tier capability list. Point your agent here first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/api/v1/agent-guide.md&lt;/code&gt;&lt;/strong&gt; — a 17-step golden-path loop that walks the agent through the entire process: discover → introspect → build → validate → execute → monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Golden-Path Loop
&lt;/h2&gt;

&lt;p&gt;Here's what an agent does after reading our discovery docs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;/llms.txt&lt;/code&gt; to learn the API surface&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;/api/v1/agent-guide.md&lt;/code&gt; for the strategy&lt;/li&gt;
&lt;li&gt;Discover available connection types with full config schemas&lt;/li&gt;
&lt;li&gt;Create source + destination connections&lt;/li&gt;
&lt;li&gt;Test both connections&lt;/li&gt;
&lt;li&gt;Introspect the source to list tables and columns&lt;/li&gt;
&lt;li&gt;Create an upload (extract + load job)&lt;/li&gt;
&lt;li&gt;Run the upload and wait for completion&lt;/li&gt;
&lt;li&gt;Browse the data catalog to see loaded tables&lt;/li&gt;
&lt;li&gt;Write SQL transformations referencing those tables&lt;/li&gt;
&lt;li&gt;Compile to validate — fix errors if any&lt;/li&gt;
&lt;li&gt;Preview the output&lt;/li&gt;
&lt;li&gt;Schedule the pipeline&lt;/li&gt;
&lt;li&gt;Monitor runs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No guessing. No hallucination loops. Every step uses typed schemas and returns typed errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Competitors Compare
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Datanika&lt;/th&gt;
&lt;th&gt;Fivetran&lt;/th&gt;
&lt;th&gt;Airbyte&lt;/th&gt;
&lt;th&gt;Stitch&lt;/th&gt;
&lt;th&gt;Hevo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt; discovery&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent strategy guide&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed config schemas in API&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source introspection via API&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compile-before-run validation&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed error codes for agents&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent retries&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wait mode on run triggers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fivetran has no agent API. Airbyte has no &lt;code&gt;/llms.txt&lt;/code&gt;. dbt Cloud has no compile-before-run via API. None of them publish a strategy guide that tells an agent how to use the platform end-to-end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It: Build a Pipeline with Your AI Agent
&lt;/h2&gt;

&lt;p&gt;Point Claude, GPT, or any LLM at your Datanika instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.datanika.io/api/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer etf_your_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Discover what's available
&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/meta/connection-types&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Create a Stripe source
&lt;/span&gt;&lt;span class="n"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/connections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe Production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Create a BigQuery destination
&lt;/span&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/connections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Analytics Warehouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bigquery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dataset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw_stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service_account_json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Introspect, build upload, run, transform...
# The agent guide at /api/v1/agent-guide.md has the full 17-step loop.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or just tell your agent: &lt;em&gt;"Read &lt;a href="https://app.datanika.io/llms.txt" rel="noopener noreferrer"&gt;https://app.datanika.io/llms.txt&lt;/a&gt; and build me a Stripe-to-BigQuery pipeline."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a head start, point the agent at our public &lt;a href="https://datanika.io/templates/" rel="noopener noreferrer"&gt;pipeline templates&lt;/a&gt; — the &lt;a href="https://datanika.io/templates/stripe-to-postgres/" rel="noopener noreferrer"&gt;Stripe → Postgres&lt;/a&gt; and &lt;a href="https://datanika.io/templates/postgres-to-bigquery/" rel="noopener noreferrer"&gt;Postgres → BigQuery&lt;/a&gt; pages ship pre-validated schemas, prerequisites, and example transformations that collapse the Tier 1 discovery loop into a single read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; the MCP server shipped. &lt;code&gt;uvx datanika-mcp&lt;/code&gt; hands Claude Desktop, Claude Code, or Cursor 25 typed tools over the same API described above — read-only until you pass &lt;code&gt;--allow-write&lt;/code&gt;. Setup is in the &lt;a href="https://datanika.io/docs/mcp-server/" rel="noopener noreferrer"&gt;MCP server guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Still on the list: agent-specific observability — tracking which pipelines were built by agents versus humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Create a free account&lt;/a&gt; — 500 model runs/month, no credit card&lt;/li&gt;
&lt;li&gt;Generate an API key in Settings&lt;/li&gt;
&lt;li&gt;Point your AI agent at &lt;code&gt;/llms.txt&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read the full &lt;a href="https://datanika.io/docs/ai-agents/" rel="noopener noreferrer"&gt;AI Agents documentation&lt;/a&gt;, explore the &lt;a href="https://datanika.io/docs/api/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;, or see how we &lt;a href="https://datanika.io/compare/airbyte/" rel="noopener noreferrer"&gt;compare to Airbyte&lt;/a&gt; and &lt;a href="https://datanika.io/compare/fivetran/" rel="noopener noreferrer"&gt;Fivetran&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
