DEV Community

Anshul Jangale
Anshul Jangale

Posted on

Production-Grade CI/CD for Databricks: The Gatekeeping Strategy Real Teams Use

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Gate 2 — Secret scanning

No credentials, tokens, or connection strings should ever enter git history.

detect-secrets scan --baseline .secrets.baseline
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Treat these secrets like production passwords:

  • One Service Principal per environment (dev/staging/prod) — never share one SP across environments
  • Rotate the client_secret on 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_MANAGE on 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 the production environment, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 validate before 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)