Deployment & Infrastructure

Managing Metabase as Code - A GitOps Approach

Managing Metabase as code means storing your Metabase configuration — dashboards, questions, collections, and permissions — in version control and app...

šŸ“…
šŸ“–8 min read

Managing Metabase as Code: A GitOps Approach

Managing Metabase as code means storing your Metabase configuration — dashboards, questions, collections, and permissions — in version control and applying changes through automated pipelines rather than manual UI edits. This gives Metabase deployments the same properties as application code: change history, peer review, rollback, and reproducibility across environments.

Metabase supports this pattern through two mechanisms: its Serialization feature (Pro/Enterprise), which exports content to YAML files that can be committed and applied like migrations, and the Metabase API, which can be scripted to manage any configuration that the UI exposes.

---

Why GitOps for Metabase

Without a code-based management approach, Metabase configuration drifts:

  • Dashboards are created and modified manually with no record of what changed or why
  • Permissions are adjusted one-off without documentation
  • Recreating a Metabase instance from scratch (after a failure, for a new environment) requires manual reconstruction
  • Dev, staging, and production diverge — a dashboard that works in staging doesn't exist in production
  • GitOps solves these problems by making the git repository the source of truth. Changes flow through pull requests with review and approval, and the repository state always reflects what's deployed.

    ---

    Approach 1: Metabase Serialization (Pro/Enterprise)

    Metabase's Serialization feature (available in Pro and Enterprise) exports collections, dashboards, questions, and settings to YAML files on disk. These files can be committed to git and applied to other instances to replicate configuration.

    Exporting (Serializing)

    From the CLI on your Metabase host:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Export all content to a directory</h1> java -jar metabase.jar export /path/to/export/directory

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Or via Docker:</h1> docker exec metabase java -jar /app/metabase.jar export /tmp/metabase-export docker cp metabase:/tmp/metabase-export ./metabase-config

    Or via the API:

    bash
    

    POST /api/ee/serialization/export

    The export produces a directory structure:

    metabase-config/
    

    ā”œā”€ā”€ collections/ │ ā”œā”€ā”€ root/ │ │ ā”œā”€ā”€ collection.yaml │ │ ā”œā”€ā”€ dashboards/ │ │ │ └── sales_overview.dashboard.yaml │ │ └── cards/ │ │ ā”œā”€ā”€ monthly_revenue.card.yaml │ │ └── active_users.card.yaml ā”œā”€ā”€ settings.yaml └── data_model/ └── ...

    YAML File Structure

    A dashboard YAML file looks like:

    yaml
    

    name: Sales Overview description: Key sales metrics for the team collection_id: root parameters: [] dashcards: - id: 1 card: entity_id: abc123def456 row: 0 col: 0 size_x: 12 size_y: 6 parameter_mappings: [] visualization_settings: {}

    Questions use entity_id (a stable UUID) rather than numeric IDs, which makes the files portable across Metabase instances.

    Importing (Deserializing)

    Apply a serialized export to another Metabase instance:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">From CLI</h1> java -jar metabase.jar import /path/to/export/directory

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Via Docker</h1> docker cp ./metabase-config metabase:/tmp/metabase-import docker exec metabase java -jar /app/metabase.jar import /tmp/metabase-import

    Or via API:

    bash
    

    POST /api/ee/serialization/import

    Git Workflow for Serialization

    Developer makes changes in Metabase staging UI
    

    │ ā–¼ Run: metabase export ./metabase-config │ ā–¼ git diff metabase-config/ ← review what changed │ ā–¼ git add + git commit + git push │ ā–¼ PR review / approval │ ā–¼ CI pipeline: metabase import ./metabase-config (on production)

    This creates a clear audit trail: every dashboard change is a git commit with a message, author, and timestamp.

    ---

    Approach 2: API-Based Configuration as Code

    For teams on the open-source edition (where serialization isn't available), or for configuration that serialization doesn't cover (users, permissions, database connections), the API is the tool.

    Project Structure

    metabase-config/
    

    ā”œā”€ā”€ config/ │ ā”œā”€ā”€ databases.json # Database connection definitions │ ā”œā”€ā”€ groups.json # Permission group definitions │ └── permissions.json # Permissions graph snapshot ā”œā”€ā”€ scripts/ │ ā”œā”€ā”€ apply.js # Main apply script │ ā”œā”€ā”€ databases.js # Database provisioning │ ā”œā”€ā”€ groups.js # Group provisioning │ └── permissions.js # Permissions management ā”œā”€ā”€ dashboards/ # Dashboard definitions as JSON │ ā”œā”€ā”€ sales_overview.json │ └── customer_health.json └── package.json

    Config Files

    databases.json

    json
    

    [ { "name": "Production DB", "engine": "postgres", "details": { "host": "${DB_HOST}", "port": 5432, "dbname": "${DB_NAME}", "user": "${DB_USER}", "ssl": true } } ]

    groups.json

    json
    

    [ { "name": "Analytics Team", "permissions": { "data": "all", "collections": "curate" } }, { "name": "Executives", "permissions": { "data": "read", "collections": "read" } }, { "name": "Customer Portal", "permissions": { "data": "sandboxed", "collections": "read" } } ]

    The Apply Script

    javascript
    

    // scripts/apply.js const fs = require("fs"); const path = require("path");

    const BASE_URL = process.env.METABASE_SITE_URL; const API_KEY = process.env.METABASE_API_KEY;

    const headers = { "x-api-key": API_KEY, "Content-Type": "application/json", };

    async function api(method, endpoint, body = null) { const res = await fetch(${BASE_URL}${endpoint}, { method, headers, body: body ? JSON.stringify(body) : null, });

    if (!res.ok) { const err = await res.text(); throw new Error(${method} ${endpoint} → ${res.status}: ${err}); } return res.json(); }

    async function applyGroups() { const desired = JSON.parse( fs.readFileSync(path.join(__dirname, "../config/groups.json")) ); const existing = await api("GET", "/api/permissions/group"); const existingByName = Object.fromEntries(existing.map((g) => [g.name, g]));

    for (const group of desired) { if (existingByName[group.name]) { console.log( Group '${group.name}' already exists — skipping); } else { const created = await api("POST", "/api/permissions/group", { name: group.name }); console.log( Created group '${group.name}' (id: ${created.id})); } } }

    async function main() { console.log("Applying Metabase configuration...\n");

    console.log("Groups:"); await applyGroups();

    console.log("\nDone."); }

    main().catch(console.error);

    ---

    CI/CD Pipeline Integration

    GitHub Actions: Export on Change

    Run an export whenever Metabase content changes, committing the result back to the repository:

    yaml
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">.github/workflows/metabase-export.yml</h1> name: Export Metabase Config

    on: schedule: - cron: "0 2 <em class="italic"> </em> *" # Daily at 2am workflow_dispatch: # Manual trigger

    jobs: export: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4

    - name: Export Metabase configuration run: | curl -X POST \ -H "x-api-key: ${{ secrets.METABASE_API_KEY }}" \ "${{ vars.METABASE_SITE_URL }}/api/ee/serialization/export" \ --output metabase-export.zip

    unzip -o metabase-export.zip -d metabase-config/

    - name: Commit changes run: | git config user.name "Metabase Bot" git config user.email "bot@yourcompany.com" git add metabase-config/ git diff --staged --quiet || git commit -m "chore: sync Metabase config [$(date +%Y-%m-%d)]" git push

    GitHub Actions: Apply on Merge

    Apply configuration changes to production when a PR is merged to main:

    yaml
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">.github/workflows/metabase-apply.yml</h1> name: Apply Metabase Config

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

    jobs: apply: runs-on: ubuntu-latest environment: production # requires approval for production deployments steps: - uses: actions/checkout@v4

    - name: Apply configuration to production run: | zip -r metabase-config.zip metabase-config/

    curl -X POST \ -H "x-api-key: ${{ secrets.METABASE_PROD_API_KEY }}" \ -F "file=@metabase-config.zip" \ "${{ vars.METABASE_PROD_SITE_URL }}/api/ee/serialization/import"

    - name: Notify on success run: echo "Metabase configuration applied to production"

    Environment Promotion

    The standard flow for promoting configuration changes across environments:

    Developer edits dashboard in DEV Metabase
    

    │ ā–¼ Export from DEV → commit to git branch │ ā–¼ PR review → merge to main │ ā–¼ CI applies to STAGING Metabase │ ā–¼ QA verification │ ā–¼ Promotion PR → merge to production branch │ ā–¼ CI applies to PRODUCTION Metabase

    ---

    Handling Secrets and Environment-Specific Config

    Serialized YAML files may contain references to database IDs and connection details that differ between environments. Use environment-specific config files and substitute values at apply time:

    javascript
    

    // scripts/apply-with-env.js function substituteEnvVars(config) { return JSON.parse( JSON.stringify(config).replace(/\$\{(\w+)\}/g, (match, key) => { const value = process.env[key]; if (!value) throw new Error(Missing env var: ${key}); return value; }) ); }

    Never commit database passwords or API keys to the config repository. Use environment variable references (${DB_PASSWORD}) in config files and inject real values from a secrets manager at apply time.

    ---

    Diffing Configuration Changes

    Before applying changes to production, always review the diff:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">See what changed in the Metabase config since last export</h1> git diff metabase-config/

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Review a specific dashboard change</h1> git diff metabase-config/collections/root/dashboards/sales_overview.dashboard.yaml

    Well-structured YAML diffs are readable: you can see exactly which questions were added or removed from a dashboard, which filter was changed, or which SQL was updated.

    ---

    Limitations and Considerations

    Serialization doesn't include users, passwords, or connection credentials. These must be managed separately (via the API or environment variables) and are intentionally excluded from exports for security reasons.

    Numeric IDs vs. entity IDs. The serialization format uses stable entity_id UUIDs, but the API uses numeric IDs. Be careful when mixing both approaches — a dashboard created via API has a numeric ID; a dashboard exported via serialization has an entity ID.

    Merge conflicts in YAML. If two people modify the same dashboard through the UI simultaneously and both export, you'll get a YAML merge conflict. Resolve it like any other git conflict.

    Serialization imports are additive. Importing a serialized export creates or updates content — it doesn't delete dashboards that were removed from the export directory. Deletions must be handled separately via the API.

    ---

    Summary

    Managing Metabase as code involves two complementary tools: Serialization (Pro/Enterprise) for exporting dashboard and collection content to version-controllable YAML files, and the API for managing users, permissions, and database connections programmatically. The GitOps workflow — export → commit → PR → CI apply — gives Metabase configuration the same auditability, review process, and reproducibility as application code. CI/CD integration enables automatic synchronization across dev, staging, and production environments, eliminating configuration drift and enabling reliable disaster recovery.