Full GitOps Flow: Deploy, Promote, Rollback
Three months on, Elin faces Johan's Monday-morning problem — and the loop closes: automated deploys, dev-to-prod promotion, and real rollback.
Part 5 of 5— GitOps with Snowflake & dbt
- Why DevOps for Data?
- Environment Setup: dbt + Snowflake Basics
- Version Control: Monorepo & CODEOWNERS
- Automating with CI: GitHub Actions for dbt
- Full GitOps Flow: Deploy, Promote, Rollback
Three months after that first broken dashboard, Elin found herself in almost the identical situation Johan had faced — a number looked wrong right before a Monday leadership meeting.
She didn’t touch Snowflake directly. She opened a PR with a fix, watched CI run automatically, got sign-off from the
right CODEOWNERS-designated reviewer within the hour, merged to main — and watched the fix deploy itself to
production.
“What changed, and can we undo it if it’s wrong?” Mikael asked. The same question as three months ago. This time Elin pulled up the commit, the PR discussion, and the test results, all in one place, and answered both halves of it before her coffee went cold.
Elin, Johan, Mikael, and Nordicvinter Retail are fictional — this is where their story, begun in Part 1, concludes. The lessons are real.
That’s the full loop this series has been building toward. This final part connects the last pieces.
Merge-to-Main Triggers Deployment
In Part 4, our GitHub Actions workflow triggered on
pull_request — running tests before merge. Deployment is a second workflow, triggered on push to main:
name: dbt Deploy
on:
push:
branches: [main]
jobs:
dbt-deploy:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dbt
run: pip install dbt-snowflake
- name: Deploy to production
run: dbt build --target prod
env:
DBT_SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
DBT_SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_PROD_USER }}
DBT_SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PROD_PASSWORD }}
DBT_SNOWFLAKE_ROLE: ${{ secrets.SNOWFLAKE_PROD_ROLE }}
DBT_SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_PROD_WAREHOUSE }}
DBT_SNOWFLAKE_DATABASE: ${{ secrets.SNOWFLAKE_PROD_DATABASE }}
Notice this is almost identical to the CI workflow from Part 4 — same shape, different trigger, different target. That similarity is deliberate: the exact same process that validated the change is the one deploying it. There’s no separate, manually run “deploy script” that could drift from what CI actually tested.
Environment Promotion: Dev → Staging → Prod
Back in Part 2, the profiles.yml on Elin’s laptop had a single target:
dev, pointed at her personal schema. Part 4 added a committed, env-var-driven ci target. A real promotion path is
just more outputs in that same committed file — same models, same code, different destinations (connection fields
abbreviated here; they’re all env_var lookups, as in Part 4):
nordicvinter_project:
target: dev
outputs:
dev:
type: snowflake
schema: elin_dev
database: NORDICVINTER_DEV
# ...
ci:
type: snowflake
schema: ci_build
database: NORDICVINTER_CI
# ...
staging:
type: snowflake
schema: staging
database: NORDICVINTER_STAGING
# ...
prod:
type: snowflake
schema: analytics
database: NORDICVINTER_PROD
# ...
This is the payoff of dbt’s declarative approach from
Part 1: the model files never change between
environments. The same sales_orders.sql compiles and runs against dev, staging, or prod — only the target flag
changes which database it lands in. A model that “works in dev” is the same code that runs in prod, not a hand-adjusted
variant of it.
A common pattern: PRs merged to main deploy straight to staging automatically; a manual approval step (or a
scheduled promotion) then pushes staging to prod — giving one last human checkpoint before customer-facing
dashboards see the change.
Is the Scheduled Job Actually Working?
Deployment isn’t a one-time event — most dbt projects also run on a schedule (nightly, hourly) to refresh data. Monitoring that this is actually happening is part of the loop, not an afterthought:
- dbt Cloud (mentioned back in Part 2) includes built-in job scheduling and alerting on failure out of the box.
- A simple cron + GitHub Actions alternative, if you’re not on dbt Cloud:
on:
schedule:
- cron: '0 5 * * *' # 5 AM UTC daily
Either way, the question to always be able to answer is: did last night’s run succeed, and if not, who got notified? A silently failing nightly job is just Johan’s unreviewed edit wearing a different disguise — a change nobody’s watching.
Rollback: Two Different Problems
“Rollback” actually means two different things, and beginners often conflate them:
1. Reverting a Bad Commit
If a merged change to main turns out to be wrong, Git makes this direct:
git revert <commit-hash>
This creates a new commit that undoes the previous one — preserving history rather than erasing it — and pushes it through the exact same CI/CD pipeline as any other change. The deploy workflow picks it up automatically, same as always. No special “rollback mode” needed; it’s just another change, validated and deployed the normal way.
2. Reverting Bad Data
This is the harder one, and it’s a genuinely different problem: reverting code doesn’t undo data that already got written with the old logic. If a bad model ran last night and populated a table with incorrect values, fixing the model going forward doesn’t retroactively fix what’s already there.
Snowflake’s Time Travel feature helps here — it can query or restore a table as it existed at a specific point in the past, within a retention window. Combined with dbt’s ability to do a full-refresh rebuild of an affected table, this covers most practical scenarios. But be explicit about it: “revert the commit” and “fix the data” are two separate steps, not one action.
The Full Loop, End to End
Here’s the complete picture the whole series has been assembling, piece by piece:
- A developer edits a
.sqlfile describing what a model should look like (Part 2). - They branch, commit, and open a PR (Part 3).
- CODEOWNERS ensures the right team reviews it (Part 3).
- CI runs
dbt buildautomatically, blocking merge if anything fails (Part 4). - Merging to
maintriggers automatic deployment (Part 5). - The change promotes through dev → staging → prod using the same code, different targets (Part 5).
- If something’s still wrong,
git reverthandles the code; Time Travel and a rebuild handle the data (Part 5).
Every step traces back to the same principle from Part 1: the desired state lives in Git, and everything else is automation that makes reality match it.
Where Nordicvinter Ended Up
In late autumn, Johan booked another two weeks in the fjords. Nobody blinked. Everything he’d shipped before leaving lived in Git — reviewed, tested, deployed by the same pipeline as everyone else’s work. On his first morning off-grid, the nightly run went green, and for the first time since spring, it didn’t matter to anyone where he was.
Elin’s team never had another incident like the one that opened this series. Not because everyone became more careful — people don’t reliably become more careful, that was never the fix — but because the process made the old failure mode structurally difficult to repeat. A direct edit to a production view isn’t just discouraged now; there’s no path for it to happen without going through Git, review, and CI first.
That’s what GitOps actually buys a data team: not perfection, but a system where mistakes get caught by the process, not by luck.
This concludes the 5-part series. If you’re building this out for your own team, Part 3’s monorepo/CODEOWNERS material and Part 4’s CI scoping are worth revisiting once you’re operating at real scale — and dbt Mesh, mentioned briefly in Part 3, is a natural next research topic once a single monorepo starts to strain.