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...
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
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:
---
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.