Key Takeaways
- Grafana provisioning is two-stage: declare a provider and path in
dashboards.yaml, then drop dashboard JSON into that path. Skip either stage and it fails silently. - Grafana 13’s v2 dashboard schema is not the same thing as the v1 JSON you’ve been exporting for years. Mixing them in a Helm chart produces a provisioned dashboard with zero panels — that’s not a bug, it’s your schema version.
- Grafonnet (Jsonnet) plus CI/CD is the 2026 default for platform teams, but if nobody on your team has written Jsonnet, start with raw JSON and GitHub Actions. Don’t adopt a heavy DSL on day one.
- Provisioned dashboards are read-only by default (
allowUiUpdates: false). Edits in the UI vanish on the next scan. This “surprise” burns a fresh batch of engineers every single year. - The real payoff of GitOps isn’t version control — it’s dashboard review. A dashboard becomes a PR. Changing one PromQL expression goes through code review. That’s the part that’s actually worth money.
Why Hand-Clicked Panels Finally Broke in 2026
Here’s a real one. Last month our core service’s P99 spiked and the on-call engineer opened Grafana to look at the latency curve. The panel was gone. Not the data — the panel. Someone had “tidied up” the dashboard the day before, dragged a panel under a different row, and all 20 versions in Grafana’s revision history got clobbered. Recovery took 40 minutes of digging through Slack screenshots.
That was the moment I made the call: dashboards must be code. Not because “version control is cool,” but because a dashboard is part of your production system. It has no meaningful difference from your deployment YAML or your Terraform files. Would you kubectl edit a manifest straight into prod and leave no trace? No. So why do you let people drag Grafana panels around in the UI?
The community’s mood is blunter than that. An August Hacker News post titled “We replaced our Grafana stack with a single Claude Code skill” pulled 3 points — low, but the title itself is the signal. Someone would rather have an LLM generate queries directly than keep fighting Grafana’s provisioning config. Meanwhile the Grafana 13.1.5 release announcement got 5 points. Attention on this topic is trending down — not because the problem got solved, but because everyone now treats dashboards-as-code as the baseline and doesn’t consider it worth discussing anymore.
So let me pull the whole 2026 picture apart: the mechanism, the v2 schema trap, whether Grafonnet is worth it, how to wire up CI/CD, and the blank-dashboard problem that eats a new team every year.
The Grafana Provisioning Mechanism: Two Stages, Silent Failure If You Skip One
Grafana’s provisioning system has existed since v5 and its core logic hasn’t changed through 13. It reads YAML config files under provisioning/ (default /etc/grafana/provisioning/), split into three categories: datasources/, dashboards/, alerting/. We’re only covering dashboards today.
Stage one: tell Grafana where to find dashboard JSON.
# /etc/grafana/provisioning/dashboards/main.yaml
apiVersion: 1
providers:
- name: 'platform-dashboards'
orgId: 1
folder: 'Platform'
folderUid: 'platform-folder'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards/platform
foldersFromFilesStructure: true
Field by field:
updateIntervalSeconds: 30— Grafana rescans the directory every 30 seconds. Edit the JSON, and it’s live within 30 seconds, no Grafana restart needed. Don’t set this too low or disk IO climbs; don’t set it too high or there’s a window after your CI deploy where “it’s changed but not live yet” and the on-call engineer starts questioning their career.allowUiUpdates: false— read-only. Edits in the UI are discarded. This must be false in production, otherwise your Git repo and your live state drift apart, and the drift is always in the direction you least expect.foldersFromFilesStructure: true— maps directory structure to Grafana folder structure. Most people don’t know this exists. With it, you don’t hand-specifyfolderUidon every dashboard.
Stage two: put the JSON files there. That’s the whole thing.
/var/lib/grafana/dashboards/platform/
├── api-gateway/
│ ├── latency-overview.json
│ └── error-budget.json
└── kafka/
└── consumer-lag.json
Now the silent-failure trap. If you write dashboards.yaml but the directory is empty, Grafana doesn’t error — it just does nothing. If a JSON file has a syntax error, Grafana logs one line, failed to load dashboard, and moves on to the next file. You will not get an alert. So the very first thing you do: add an alert rule against your Grafana logs matching the provisioning keyword. We run this internally and it’s saved us at least twice.
flowchart TD
A[Dashboard JSON in Git repo] --> B[CI: GitHub Actions]
B --> C{JSON Schema validation}
C -->|Fail| D[Block PR]
C -->|Pass| E[Generate ConfigMap / image layer]
E --> F[ArgoCD / Helm sync to cluster]
F --> G[/var/lib/grafana/dashboards inside Grafana Pod]
G --> H[Provisioning provider scans every 30s]
H --> I[Dashboard appears in Grafana UI]
I --> J[Read-only, allowUiUpdates=false]
Grafana 13’s v2 Dashboard Schema: This Year’s Biggest Trap
Grafana 13 shipped a v2 schema for dashboards. It is not the same format as the JSON you exported from v8, v9, or v10. v2 uses a resource-style definition along the lines of apiVersion: dashboard.grafana.app/v2, integrated with Grafana’s new app-platform architecture.
Someone on HN posted: “I’m trying to provision a v2 dashboard in Grafana 13, Grafana runs on k8s via the Helm chart. I created a new provider and nothing shows up.” I’ve seen that exact problem at least five times.
The cause is almost always one of two things:
- The provider config in the Helm chart still uses the v1
options.pathformat, but the dashboard files are v2 schema. Grafana tries to parse them as v1, fails, and silently skips. - v2 dashboard provisioning takes a different path — it’s not simple file scanning. It requires correct
apiVersionandkindfields, and the provider’stypehas to match.
Honestly, as of 2026 the v2 provisioning docs are still thin. My recommendation is blunt: if your dashboards don’t need v2-only capabilities (the new variable model, cross-datasource query orchestration), stay on v1 JSON. The v1 provisioning path has been hammered on for a decade. It’s rock solid. v2 exists for teams deeply integrating with the Grafana App Platform. Your average SRE team does not need to be the guinea pig.
And there’s the “Edit as code” trap. Grafana now has an “Edit as code” entry point in the UI that hands you a config — but community reports are that it sometimes provisions a blank dashboard with no panels at all. This behavior has recurred across several 13.x patches. Don’t expect UI-generated code to go straight into Git. Treat it as a reference, not a commit candidate.
Tool Selection: Raw JSON, Grafonnet, or Full GitOps?
This gets asked constantly. Here’s a table based on our team’s actual testing over six months (3-node Grafana cluster, ~140 dashboards).
| Approach | Learning curve | Maintainability at 140 dashboards | CI integration effort | Who it’s for |
|---|---|---|---|---|
| Hand-written v1 JSON | Low | Medium — duplicate panels via copy-paste, changing one PromQL means grepping the repo | Low, plain file sync | Small teams, < 30 dashboards |
| Grafonnet (Jsonnet) | High — you’re learning Jsonnet itself | High — panels as functions, change once, applies everywhere | Medium, needs jsonnet-bundler and a compile step | Platform teams, > 50 dashboards |
| Grafana Terraform Provider | Medium | Medium — state file management is a headache, dashboard JSON is still an embedded string | High, needs a Terraform state backend | Teams already running infra on Terraform |
| Raw JSON + GitHub Actions GitOps | Low | Medium-high — directory structure and lint tooling carry the weight | Low to medium | The optimal answer for most teams |
We went with raw JSON + GitHub Actions. The reasoning is plain: Grafonnet is genuinely elegant, but only 2 of our 8 engineers had written Jsonnet, and the other 6 needed help compiling every time they touched a panel. That human cost beats “copy-paste some JSON” by a mile. A tool’s value is defined by the least familiar person on the team, not the most familiar one. Put that on a wall.
My take on the Terraform Provider is even more direct: it’s fine for datasources and alert rules, wrong for dashboards. The dashboard JSON ends up as a heredoc string inside Terraform. You lose JSON schema validation, you lose readable diffs, and you get to babysit a state file. That’s the classic “wrong tool” pattern. I’d rather you just kubectl create configmap.
In Practice: Wiring the GitHub Actions Pipeline
This is our actual pipeline, minus internal details. Copy it directly.
# .github/workflows/dashboards.yml
name: Provision Grafana Dashboards
on:
pull_request:
paths: ['dashboards/**']
push:
branches: [main]
paths: ['dashboards/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate dashboard JSON
run: |
find dashboards -name '*.json' -print0 | while IFS= read -r -d '' f; do
jq empty "$f" || { echo "::error file=$f::invalid JSON"; exit 1; }
# check required fields
jq -e '.title and .panels and .schemaVersion' "$f" > /dev/null \
|| { echo "::error file=$f::missing required fields"; exit 1; }
done
- name: Lint PromQL expressions
run: |
pip install promql-parser
python scripts/lint_promql.py dashboards/
deploy:
needs: validate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sync to cluster
run: |
kubectl create configmap grafana-dashboards \
--from-file=dashboards/ \
--dry-run=client -o yaml \
| kubectl apply -f -
kubectl rollout restart deployment/grafana -n monitoring
Three things I baked in that no doc tells you:
One: PromQL linting is mandatory. A malformed PromQL expression doesn’t make the dashboard fail to load. It just renders “No data” on the panel. During an incident, “No data” is the most obnoxious state there is — you can’t tell whether there’s no data or the query is broken. We wrote lint_promql.py using promql-parser to extract every expr field and parse it. Syntax errors block the PR.
Two: jq -e '.title and .panels' has saved us. A colleague once exported a dashboard from the UI while panels were failing to load, and the exported JSON had an empty panels array. That file went into the repo, deployed, and produced a blank dashboard. With this check, that PR gets stopped in CI.
Three: don’t use rollout restart. I wrote it above for brevity, but with updateIntervalSeconds you don’t need to restart Grafana at all. A restart interrupts everyone currently looking at panels. If you genuinely need a forced refresh, delete the ConfigMap and recreate it — Grafana will rescan on its own.
FAQ
Q: I edited a provisioned dashboard in the UI and my changes disappeared on refresh. Why?
Because allowUiUpdates: false. This is designed behavior, not a bug. Grafana marks provisioned dashboards read-only; UI edits live only in the current session’s memory, and the next provider scan (default 30 seconds) overwrites with file contents. If you truly need UI editing, set allowUiUpdates: true — but I strongly advise against it in production, since you lose Git as the single source of truth.
Q: My v2 dashboard provisioning in Grafana 13 just doesn’t work. How do I debug it?
Check in this order: (1) whether the provider’s apiVersion in dashboards.yaml matches the dashboard file’s schema; (2) whether the Helm chart’s dashboards values are overriding your provider config — run helm template and inspect the rendered output; (3) grep Grafana logs for provisioning and dashboard — failures are silent but logged; (4) confirm the files are actually mounted into the Pod with kubectl exec and ls. 80% of cases are cause #2.
Q: What does Grafonnet actually buy me over hand-written JSON?
Abstraction. Say you have 20 microservices, each needing a latency/error-rate/throughput panel set. Hand-written JSON means 20 nearly identical files, and changing one query means changing it 20 times. In Grafonnet you write a servicePanel(name) function and loop. The cost is adopting the Jsonnet DSL plus a compile step. Under 30 dashboards, it isn’t worth it.
Q: Should dashboard JSON live in a ConfigMap or be baked into the image?
ConfigMap. Baking into the image means every panel change requires a rebuild, push, and pull — your CI time goes from 30 seconds to 5 minutes. The ConfigMap downside is the 1MB size limit. Single dashboards over 1MB are rare but real (giant executive dashboards); for those, use subPath mounts or switch to sidecar mode.
Q: How do I keep datasource UIDs consistent with provisioned dashboards?
Manage datasources through provisioning too, and explicitly pin the uid. Don’t rely on Grafana’s auto-generated UID — it changes every time the datasource is recreated, and then every query in every dashboard breaks. Hardcode uid: prometheus-main in datasources/*.yaml and reference the same string in dashboard JSON.
References & Community Insights
- Grafana official Provisioning docs — the authoritative source for two-stage configuration, though the v2 schema section lags behind.
- Grafana 13.1.5 Release Notes (GitHub) — the primary channel for tracking provisioning fixes; the 13.x line patches this module more often than you’d expect.
- Grafonnet official repository — the Jsonnet library. The
examplesdirectory in the README is more useful than the docs themselves. - HN discussion: We replaced our Grafana stack with a single Claude Code skill — worth reading as a cautionary tale about provisioning complexity driving people away.
- Grafana Observability as Code docs — Grafana Labs’ own positioning of as-code workflows, including the Grafana Cloud IaC path.
