A "push code → deploy" pipeline is a demo, not a production strategy. What separates a toy CI/CD setup from one that survives a real data platform is the gates — the checkpoints that stop bad code, bad data, and bad permissions from ever reaching production. This post covers the actual strategy mature Databricks teams run, with full implementation using Asset Bundles, GitHub Actions, and a Service Principal.
1. The environment & branching strategy
Map Git branches → Databricks Asset Bundle targets → physical workspaces. Never deploy dev code straight to prod — always promote through a fixed path.
| Git branch | Bundle target | Workspace | Who deploys | Approval needed |
|---|---|---|---|---|
feature/* |
dev |
Shared dev workspace | Any dev, on push | No |
main |
staging |
Staging workspace (prod-like data subset) | CI/CD, on merge | No (auto) |
release/* tag |
prod |
Production workspace | CI/CD, on tag | Yes — manual gate |
This is the backbone of everything below: promotion is one-directional and gated, never a direct write to prod from a feature branch.
feature/xyz → PR → main (staging auto-deploy) → tag v1.4.0 → prod (manual approval gate)
2. The gates, in order
Think of this as a pipeline where each gate can hard-stop the deployment. This is what "production-ready" actually means — not that the code works, but that it cannot reach prod unless it passes every gate.
Gate 1 — Static checks (lint + format)
Catches syntax and style issues before anything runs.
black --check .
ruff check .
sqlfluff lint ./sql --dialect databricks
Gate 2 — Secret scanning
No credentials, tokens, or connection strings should ever enter git history.
detect-secrets scan --baseline .secrets.baseline
Gate 3 — Unit tests
Test transformation logic locally (PySpark local session or chispa/pytest), not against a live cluster — keeps this gate fast (seconds, not minutes).
pytest tests/unit --cov=src --cov-fail-under=80
Gate 4 — Bundle validate
Confirms the databricks.yml and all resource definitions are syntactically and semantically correct for the target.
databricks bundle validate -t staging
Gate 5 — Integration test on a real (dev) workspace
Deploy to an ephemeral/dev target, run the job for real, assert on the output, then tear down. This is the gate that catches "works on my laptop" failures.
databricks bundle deploy -t dev
databricks bundle run integration_test_job -t dev
python scripts/assert_output.py
databricks bundle destroy -t dev --auto-approve
Gate 6 — Data quality gate
Enforce data contracts before data lands in trusted tables. Use Delta Live Tables expectations or Great Expectations as a hard gate, not a warning.
@dlt.expect_or_fail("valid_id", "id IS NOT NULL")
@dlt.expect_or_drop("valid_amount", "amount >= 0")
expect_or_fail halts the pipeline — this is your data-layer gatekeeper, equivalent to a failed test in code.
Gate 7 — Governance-as-code (Unity Catalog grants)
Permissions are declared in the bundle, not clicked in the UI, so access control is reviewed in the same PR as the code.
resources:
grants:
- object: catalog/analytics.sales
principal: "data-engineers"
privileges: ["SELECT", "MODIFY"]
Gate 8 — Manual approval before prod
The only human-in-the-loop gate. Implemented as a GitHub Environment with required reviewers — the pipeline literally pauses and waits.
Gate 9 — Canary / phased job rollout
Don't flip 100% of prod jobs to the new bundle at once. Deploy to a canary job first (e.g., a single non-critical pipeline or a subset of data), watch it for one run cycle, then promote the rest.
Gate 10 — Post-deploy verification + auto-rollback
After prod deploy, run a smoke-test job. If it fails, auto-revert to the last known-good bundle deployment (databricks bundle deploy is idempotent per Git commit, so rollback = redeploy the previous tag).
3. Service Principal auth with static secrets (GitHub + Bitbucket)
Create a Service Principal per environment, generate its OAuth client_id / client_secret, and store those as encrypted secrets in whichever CI/CD tool you use. The Databricks CLI picks them up automatically as environment variables — no interactive login, no personal token.
DATABRICKS_HOST=https://<workspace-url>
DATABRICKS_CLIENT_ID=<sp-client-id>
DATABRICKS_CLIENT_SECRET=<sp-client-secret>
Treat these secrets like production passwords:
- One Service Principal per environment (dev/staging/prod) — never share one SP across environments
- Rotate the
client_secreton a schedule (e.g., every 90 days) and immediately on any suspected exposure - Grant the SP only the workspace/Unity Catalog permissions it actually needs — no
CAN_MANAGEon things it doesn't touch - Never print these values in logs — mask them explicitly if your CI tool doesn't do it by default
Where secrets live in each tool
-
GitHub: repo → Settings → Secrets and variables → Actions. Use Environments (
staging,production) so prod secrets are only exposed to jobs running against theproductionenvironment, and you can require reviewers before that environment's secrets are unlocked. -
Bitbucket: repo → Repository settings → Pipelines → Deployment environments (
staging,production) with environment-scoped variables (mark them Secured). Deployment environments also support required reviewers for the same manual-approval effect.
4. Full implementation
4.1 Repo structure
├── databricks.yml
├── resources/
│ ├── jobs.yml
│ ├── pipelines.yml
│ └── grants.yml
├── src/
│ └── transformations/
├── tests/
│ ├── unit/
│ └── integration/
├── .secrets.baseline
├── .github/workflows/ # if using GitHub
│ ├── ci.yml
│ └── deploy-prod.yml
└── bitbucket-pipelines.yml # if using Bitbucket
4.2 databricks.yml — three targets, production mode enforced
bundle:
name: sales_analytics_platform
include:
- resources/*.yml
targets:
dev:
mode: development
workspace:
host: https://dev-workspace.azuredatabricks.net
run_as:
service_principal_name: ${var.sp_dev}
staging:
mode: production
workspace:
host: https://staging-workspace.azuredatabricks.net
run_as:
service_principal_name: ${var.sp_staging}
prod:
mode: production
workspace:
host: https://prod-workspace.azuredatabricks.net
run_as:
service_principal_name: ${var.sp_prod}
permissions:
- group_name: data-platform-admins
level: CAN_MANAGE
- group_name: data-engineers
level: CAN_VIEW
mode: production is itself a gate — it disables bundle-managed resources from being edited manually in the UI and locks concurrent deploys.
4.3a GitHub Actions — CI workflow, gates 1 through 5, on every PR
name: CI - Validate and Test
on:
pull_request:
branches: [main]
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install black ruff sqlfluff detect-secrets
- name: Lint & format
run: |
black --check .
ruff check .
- name: Secret scan
run: detect-secrets scan --baseline .secrets.baseline
unit-tests:
needs: static-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements-test.txt
- run: pytest tests/unit --cov=src --cov-fail-under=80
bundle-validate-and-integration:
needs: unit-tests
runs-on: ubuntu-latest
environment: dev
env:
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST_DEV }}
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID_DEV }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET_DEV }}
steps:
- uses: actions/checkout@v4
- name: Install Databricks CLI
run: curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- name: Validate bundle
run: databricks bundle validate -t dev
- name: Deploy to dev (ephemeral)
run: databricks bundle deploy -t dev
- name: Run integration test job
run: databricks bundle run integration_test_job -t dev
- name: Assert output correctness
run: python scripts/assert_output.py
- name: Tear down dev deployment
if: always()
run: databricks bundle destroy -t dev --auto-approve
4.3b Bitbucket Pipelines — same gates
image: python:3.11
definitions:
steps:
- step: &static-checks
name: Lint, format, secret scan
script:
- pip install black ruff sqlfluff detect-secrets
- black --check .
- ruff check .
- detect-secrets scan --baseline .secrets.baseline
- step: &unit-tests
name: Unit tests
script:
- pip install -r requirements-test.txt
- pytest tests/unit --cov=src --cov-fail-under=80
- step: &validate-and-integration
name: Bundle validate + integration test
deployment: dev
script:
- curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- export PATH="$HOME/bin:$PATH"
- databricks bundle validate -t dev
- databricks bundle deploy -t dev
- databricks bundle run integration_test_job -t dev
- python scripts/assert_output.py
- databricks bundle destroy -t dev --auto-approve
pipelines:
pull-requests:
'**':
- step: *static-checks
- step: *unit-tests
- step: *validate-and-integration
The deployment: dev key ties the step to Bitbucket's dev deployment environment, so DATABRICKS_HOST, DATABRICKS_CLIENT_ID, and DATABRICKS_CLIENT_SECRET are pulled automatically from that environment's secured variables — same idea as GitHub's environment: key.
4.4a GitHub Actions — prod deploy, gates 6 through 10, only on release tags
name: Deploy to Production
on:
push:
tags:
- 'release/v*'
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
env:
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST_STAGING }}
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID_STAGING }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET_STAGING }}
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- run: databricks bundle validate -t staging
- run: databricks bundle deploy -t staging
- name: Run data quality (DLT expectations) gate
run: databricks bundle run quality_check_pipeline -t staging
approval-gate:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production-approval # GitHub Environment with required reviewers configured
steps:
- run: echo "Approved for production deployment"
deploy-prod-canary:
needs: approval-gate
runs-on: ubuntu-latest
environment: production
env:
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST_PROD }}
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID_PROD }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET_PROD }}
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- run: databricks bundle deploy -t prod
- name: Run canary job only
run: databricks bundle run canary_pipeline_job -t prod
- name: Smoke test canary output
run: python scripts/smoke_test.py --scope canary
deploy-prod-full:
needs: deploy-prod-canary
runs-on: ubuntu-latest
environment: production
env:
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST_PROD }}
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID_PROD }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET_PROD }}
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- name: Trigger all remaining prod jobs
run: databricks bundle run all_jobs -t prod
- name: Post-deploy smoke test
run: python scripts/smoke_test.py --scope full
- name: Auto-rollback on failure
if: failure()
run: |
git checkout $(git describe --tags --abbrev=0 HEAD^)
databricks bundle deploy -t prod
4.4b Bitbucket Pipelines — same gates, tag-triggered
pipelines:
tags:
'release/v*':
- step:
name: Deploy to staging + data quality gate
deployment: staging
script:
- curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- export PATH="$HOME/bin:$PATH"
- databricks bundle validate -t staging
- databricks bundle deploy -t staging
- databricks bundle run quality_check_pipeline -t staging
- step:
name: Manual approval for production
deployment: production
trigger: manual # pipeline pauses here until someone clicks "Deploy"
script:
- echo "Approved for production deployment"
- step:
name: Canary rollout to prod
deployment: production
script:
- curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- export PATH="$HOME/bin:$PATH"
- databricks bundle deploy -t prod
- databricks bundle run canary_pipeline_job -t prod
- python scripts/smoke_test.py --scope canary
- step:
name: Full prod rollout + smoke test + rollback
deployment: production
script:
- curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
- export PATH="$HOME/bin:$PATH"
- databricks bundle run all_jobs -t prod
- >
python scripts/smoke_test.py --scope full ||
(git checkout $(git describe --tags --abbrev=0 HEAD^) && databricks bundle deploy -t prod)
Bitbucket's trigger: manual on a step tied to the production deployment environment is the direct equivalent of GitHub's required-reviewers Environment — the pipeline literally stops and waits for a human to click Deploy in the UI, and only then are the production environment's secured variables (DATABRICKS_HOST_PROD, etc.) made available to later steps.
Note the shape either way: staging deploy → data quality gate → human approval → canary → full rollout → smoke test → auto-rollback. Every arrow is a place the pipeline can stop.
4.5 Governance-as-code example (resources/grants.yml)
resources:
registered_models:
sales_model:
name: sales_forecast_model
grants:
- principal: data-scientists
privileges: ["EXECUTE"]
schemas:
sales_schema:
catalog_name: analytics
name: sales
grants:
- principal: data-engineers
privileges: ["USE_SCHEMA", "CREATE_TABLE"]
- principal: analysts
privileges: ["USE_SCHEMA", "SELECT"]
Permission changes now go through the exact same PR review + gates as code changes — no more "who clicked Grant in the UI last Tuesday."
5. Rollback strategy
Because Asset Bundles deploy is idempotent and declarative, rollback is not a special code path — it's just deploying an older commit:
git checkout release/v1.3.0
databricks bundle deploy -t prod
Keep every release tagged (release/v1.3.0, release/v1.4.0...) so "last known good" is always one git checkout away.
6. Observability as the final gate
Wire job failure alerts and Unity Catalog system tables (system.access.audit, system.lakeflow.job_run_timeline) into your monitoring stack (e.g., Datadog, PagerDuty). A deployment isn't "done" when bundle deploy exits 0 — it's done when the first few production runs are confirmed healthy.
7. Summary — the gate checklist
- [ ] Branch → target → workspace mapping enforced
- [ ] Lint, format, secret scan on every PR
- [ ] Unit tests with coverage threshold
- [ ]
bundle validatebefore any deploy - [ ] Real integration test on ephemeral dev deployment
- [ ] Data quality gate (DLT expectations / Great Expectations)
- [ ] Governance (grants) declared as code, reviewed in PR
- [ ] Manual approval gate before prod (GitHub Environment reviewers)
- [ ] OIDC federated Service Principal auth — no static secrets
- [ ] Canary rollout before full prod rollout
- [ ] Post-deploy smoke test with auto-rollback
- [ ] System-table-based monitoring wired to alerting
This is the difference between "we have CI/CD" and "we have a production-ready, auditable, self-healing deployment strategy."
Top comments (0)