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