Deployment & Infrastructure

CI/CD for Metabase - Testing and Deploying Dashboard Changes

CI/CD for Metabase means applying the same continuous integration and delivery practices used for application code — automated testing, environment pr...

šŸ“…
šŸ“–8 min read

CI/CD for Metabase: Testing and Deploying Dashboard Changes

CI/CD for Metabase means applying the same continuous integration and delivery practices used for application code — automated testing, environment promotion, and reproducible deployments — to Metabase dashboards, questions, and configuration. Rather than making dashboard changes directly in production and hoping they work, changes flow through a pipeline: develop in a dedicated environment, validate automatically, promote to staging for QA, and deploy to production on merge.

This is especially important for teams where analytics are customer-facing — an embedded dashboard breaking in production is a user-visible outage, not just an internal inconvenience.

---

Why CI/CD for Analytics Configuration

Teams without CI/CD for Metabase experience predictable problems:

  • Changes made directly in production break dashboards for all users simultaneously
  • There's no way to test a question change before it affects customers
  • Configuration drift between environments means "works in dev, broken in prod" issues
  • Rollback after a bad change requires manually reversing the edit
  • There's no record of why a query was changed or who approved it
  • A CI/CD pipeline solves all of these by making the development workflow for analytics match the development workflow for code.

    ---

    Environment Architecture

    The foundation of CI/CD for Metabase is separate instances per environment, each connected to appropriate data sources:

    ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
    

    │ DEV Metabase │ │ • Connected to: dev database / sample data │ │ • Access: individual developers │ │ • Changes: freely made, frequently exported │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ export + PR ā–¼ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ STAGING Metabase │ │ • Connected to: staging database (production-like) │ │ • Access: QA, stakeholders │ │ • Changes: applied via CI on PR merge │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ promotion PR ā–¼ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ PRODUCTION Metabase │ │ • Connected to: production database │ │ • Access: all users / customers │ │ • Changes: applied via CI on approved merge │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

    Each environment has its own:

  • Metabase instance (separate Docker container or Cloud instance)
  • Application database (stores Metabase config)
  • Database connections (pointing to the appropriate data environment)
  • API key for automation
  • ---

    Repository Structure

    analytics/
    

    ā”œā”€ā”€ metabase-config/ # Serialized Metabase content (YAML) │ ā”œā”€ā”€ collections/ │ ā”œā”€ā”€ data_model/ │ └── settings.yaml ā”œā”€ā”€ scripts/ │ ā”œā”€ā”€ export.sh # Export from DEV → metabase-config/ │ ā”œā”€ā”€ import.sh # Import metabase-config/ → target instance │ ā”œā”€ā”€ validate.js # Validate YAML structure and query syntax │ └── healthcheck.js # Verify dashboards load after deployment ā”œā”€ā”€ .github/ │ └── workflows/ │ ā”œā”€ā”€ export.yml # Triggered manually or on schedule │ ā”œā”€ā”€ staging.yml # Deploy to staging on PR merge to main │ └── production.yml # Deploy to production on release tag └── README.md

    ---

    Export Script

    The export script pulls the current state of DEV Metabase into the repository:

    bash
    

    #!/bin/bash <h1 class="text-4xl font-bold mb-6 text-slate-900">scripts/export.sh</h1>

    set -e

    METABASE_URL="${METABASE_DEV_URL}" API_KEY="${METABASE_DEV_API_KEY}" EXPORT_DIR="./metabase-config"

    echo "Exporting from $METABASE_URL..."

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Trigger export via API</h1> RESPONSE=$(curl -s -w "\n%{http_code}" \ -X POST \ -H "x-api-key: $API_KEY" \ "$METABASE_URL/api/ee/serialization/export?all_collections=true&data_model=true&settings=true" \ -o /tmp/metabase-export.zip)

    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)

    if [ "$HTTP_CODE" != "200" ]; then echo "Export failed with HTTP $HTTP_CODE" exit 1 fi

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Unpack export</h1> rm -rf "$EXPORT_DIR" mkdir -p "$EXPORT_DIR" unzip -q /tmp/metabase-export.zip -d "$EXPORT_DIR"

    echo "Export complete. Files written to $EXPORT_DIR" echo "Changed files:" git diff --name-only "$EXPORT_DIR" || true

    ---

    Import Script

    The import script applies the repository state to a target Metabase instance:

    bash
    

    #!/bin/bash <h1 class="text-4xl font-bold mb-6 text-slate-900">scripts/import.sh</h1>

    set -e

    TARGET_URL="${1:-$METABASE_STAGING_URL}" API_KEY="${2:-$METABASE_STAGING_API_KEY}" CONFIG_DIR="./metabase-config"

    echo "Importing to $TARGET_URL..."

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Package config directory</h1> cd "$CONFIG_DIR" zip -q -r /tmp/metabase-import.zip . cd -

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Apply import</h1> HTTP_CODE=$(curl -s -o /tmp/import-response.json -w "%{http_code}" \ -X POST \ -H "x-api-key: $API_KEY" \ -F "file=@/tmp/metabase-import.zip" \ "$TARGET_URL/api/ee/serialization/import")

    cat /tmp/import-response.json

    if [ "$HTTP_CODE" != "200" ]; then echo "Import failed with HTTP $HTTP_CODE" exit 1 fi

    echo "Import complete."

    ---

    Validation Step

    Before deploying to production, validate the exported YAML for common issues:

    javascript
    

    // scripts/validate.js const fs = require("fs"); const path = require("path"); const yaml = require("js-yaml"); const glob = require("glob");

    const CONFIG_DIR = path.join(__dirname, "../metabase-config");

    function validateDashboard(filePath, content) { const errors = [];

    if (!content.name) { errors.push("Missing required field: name"); }

    if (!Array.isArray(content.dashcards)) { errors.push("dashcards must be an array"); } else if (content.dashcards.length === 0) { errors.push("Dashboard has no cards"); }

    // Check for cards that reference questions by entity_id for (const card of content.dashcards || []) { if (!card.card?.entity_id) { errors.push(Card at row=${card.row}, col=${card.col} missing entity_id); } }

    return errors; }

    function validateCard(filePath, content) { const errors = [];

    if (!content.name) { errors.push("Missing required field: name"); }

    if (!content.dataset_query) { errors.push("Missing dataset_query"); } else { const { type, native, query } = content.dataset_query; if (type === "native" && !native?.query) { errors.push("Native query missing SQL"); } if (type === "query" && !query?.["source-table"]) { errors.push("Query missing source-table"); } }

    return errors; }

    async function main() { const dashboardFiles = glob.sync(${CONFIG_DIR}/<em class="italic"></em>/*.dashboard.yaml); const cardFiles = glob.sync(${CONFIG_DIR}/<em class="italic"></em>/*.card.yaml);

    let totalErrors = 0;

    for (const file of dashboardFiles) { const content = yaml.load(fs.readFileSync(file, "utf8")); const errors = validateDashboard(file, content); if (errors.length > 0) { console.error(\nāŒ ${path.relative(CONFIG_DIR, file)}); errors.forEach((e) => console.error( - ${e})); totalErrors += errors.length; } }

    for (const file of cardFiles) { const content = yaml.load(fs.readFileSync(file, "utf8")); const errors = validateCard(file, content); if (errors.length > 0) { console.error(\nāŒ ${path.relative(CONFIG_DIR, file)}); errors.forEach((e) => console.error( - ${e})); totalErrors += errors.length; } }

    if (totalErrors > 0) { console.error(\nValidation failed: ${totalErrors} error(s)); process.exit(1); } else { console.log(āœ“ Validated ${dashboardFiles.length} dashboards, ${cardFiles.length} questions — no errors); } }

    main();

    ---

    Health Check After Deployment

    After deploying, verify that key dashboards are actually loading:

    javascript
    

    // scripts/healthcheck.js async function checkDashboard(baseUrl, apiKey, dashboardId, name) { try { const res = await fetch(${baseUrl}/api/dashboard/${dashboardId}, { headers: { "x-api-key": apiKey }, });

    if (!res.ok) { console.error(āŒ Dashboard '${name}' (id: ${dashboardId}): HTTP ${res.status}); return false; }

    const dashboard = await res.json();

    // Verify the dashboard has cards if (!dashboard.dashcards?.length) { console.error(āŒ Dashboard '${name}' loaded but has no cards); return false; }

    console.log(āœ“ Dashboard '${name}' (${dashboard.dashcards.length} cards)); return true; } catch (err) { console.error(āŒ Dashboard '${name}': ${err.message}); return false; } }

    async function runHealthChecks(baseUrl, apiKey) { // Define critical dashboards to check const criticalDashboards = [ { id: 1, name: "Sales Overview" }, { id: 2, name: "Customer Health" }, { id: 3, name: "Embedded: Customer Portal" }, ];

    const results = await Promise.all( criticalDashboards.map((d) => checkDashboard(baseUrl, apiKey, d.id, d.name) ) );

    const passed = results.filter(Boolean).length; const failed = results.length - passed;

    console.log(\nHealth check: ${passed}/${results.length} passed);

    if (failed > 0) { process.exit(1); } }

    runHealthChecks( process.env.METABASE_SITE_URL, process.env.METABASE_API_KEY );

    ---

    Complete GitHub Actions Workflows

    Staging Deploy (on PR merge to main)

    yaml
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">.github/workflows/staging.yml</h1> name: Deploy to Staging

    on: push: branches: [main] paths: ["metabase-config/<em class="italic"></em>"]

    jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: "20" } - run: npm ci - run: node scripts/validate.js

    deploy-staging: needs: validate runs-on: ubuntu-latest environment: staging steps: - uses: actions/checkout@v4

    - name: Import to staging run: bash scripts/import.sh env: METABASE_STAGING_URL: ${{ vars.METABASE_STAGING_URL }} METABASE_STAGING_API_KEY: ${{ secrets.METABASE_STAGING_API_KEY }}

    - name: Health check run: node scripts/healthcheck.js env: METABASE_SITE_URL: ${{ vars.METABASE_STAGING_URL }} METABASE_API_KEY: ${{ secrets.METABASE_STAGING_API_KEY }}

    Production Deploy (on release tag)

    yaml
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">.github/workflows/production.yml</h1> name: Deploy to Production

    on: push: tags: ["v*"]

    jobs: deploy-production: runs-on: ubuntu-latest environment: production # Requires manual approval in GitHub

    steps: - uses: actions/checkout@v4

    - name: Validate config run: node scripts/validate.js

    - name: Import to production run: bash scripts/import.sh env: METABASE_STAGING_URL: ${{ vars.METABASE_PROD_URL }} METABASE_STAGING_API_KEY: ${{ secrets.METABASE_PROD_API_KEY }}

    - name: Health check run: node scripts/healthcheck.js env: METABASE_SITE_URL: ${{ vars.METABASE_PROD_URL }} METABASE_API_KEY: ${{ secrets.METABASE_PROD_API_KEY }}

    - name: Notify Slack on failure if: failure() uses: slackapi/slack-github-action@v1 with: payload: '{"text": "āš ļø Metabase production deploy failed: ${{ github.ref_name }}"}' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

    ---

    Rollback Strategy

    When a deployment causes issues, rollback by reverting the git commit and re-deploying:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Identify the last good commit</h1> git log --oneline metabase-config/

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Revert to previous state</h1> git revert HEAD git push

    <h1 class="text-4xl font-bold mb-6 text-slate-900">CI will automatically redeploy the reverted config</h1>

    For immediate rollback without waiting for CI:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Apply the previous commit's config directly</h1> git show HEAD~1:metabase-config/ > /tmp/previous-config bash scripts/import.sh "$METABASE_PROD_URL" "$METABASE_PROD_API_KEY"

    ---

    Summary

    CI/CD for Metabase requires three environment instances (dev, staging, production), a git repository storing serialized YAML configuration, and pipeline scripts for export, validation, import, and health checks. The workflow mirrors standard software delivery: develop freely in dev, export changes to git, run automated validation on PR, deploy to staging automatically, and promote to production through a gated process requiring approval. Health checks after deployment verify that critical dashboards are intact. The result is Metabase configuration that has the same auditability, reviewability, and rollback capabilities as application code.