Originally published on kuryzhev.cloud
Someone fixes a latency panel at 2 a.m., saves it, and goes back to bed. Three weeks later the Grafana container is rebuilt, the SQLite volume is gone, and the panel's old query is back. Keeping dashboards as code in Git and loading them through provisioning removes that failure mode. It also makes dashboards reviewable like any other change.
Grafana 13.3 was released on 2026-10-20 (what's new). An upgrade is a good moment to do this, because version upgrades are when UI-only dashboards break quietly and nobody can say what changed. Before you roll it out, read the release notes for dashboard schema and provisioning changes. Details differ between 12.x and 13.x, so verify against your exact version.
Short answer:
- Commit dashboard JSON to Git and mount it into Grafana.
- Load it with a file provider that has
allowUiUpdates: false. - Pin datasource UIDs so the JSON does not depend on instance-specific IDs.
Use this when more than one person edits dashboards, or when Grafana must be rebuildable from scratch. Skip it if you are still exploring on one throwaway instance.
What dashboards as code actually does
Grafana stores dashboards as JSON documents in its database. Provisioning adds a second source. At startup, and then on a polling interval, Grafana reads YAML provider definitions from /etc/grafana/provisioning/dashboards/. Each provider points at a directory of JSON files. Grafana creates or updates the matching dashboards, identified by the uid field inside each file.
Grafana marks provisioned dashboards as such. The API returns meta.provisioned: true for them, and the UI shows a notice when you try to save one. The file is the source of truth. The database copy is a cache that gets overwritten.
Datasources work the same way, from /etc/grafana/provisioning/datasources/. Provision both together. A dashboard that references a hand-created datasource is only half reproducible.
How people use it wrong
Exporting for external sharing. This is the most common failure. The option is labelled "Export for sharing externally" in many versions, and the wording varies. It rewrites datasource references into ${DS_PROMETHEUS} placeholders meant for the import dialog. File provisioning does not run the import dialog, so panels load with a missing datasource. Use the plain JSON model instead.
Leaving the numeric id in the file. The id is local to one database. Set it to null and rely on a stable, human-chosen uid. If the uid changes, links break: every bookmark and alert link that points at the old uid stops working, and you may be left with a stale copy to clean up.
Allowing UI edits and expecting them to stick. With allowUiUpdates: true, people save in the UI and the next change to the file overwrites their work. Until then, the two copies silently diverge. Pick one direction.
Dumping the whole instance with a one-off script. This produces hundreds of unreviewed files with auto-generated uids and no folder structure. That is a backup, not a workflow.
The correct approach
Start with datasources, because dashboards depend on them. Pinning the uid is what lets the same dashboard JSON work in staging and production.
# /etc/grafana/provisioning/datasources/prometheus.yaml apiVersion: 1 datasources: - name: Prometheus uid: prometheus-main # dashboards reference this uid, never the name type: prometheus access: proxy url: http://prometheus:9090 isDefault: true editable: false # blocks UI edits of the datasource Next comes the dashboard provider. It tells Grafana where the files live and how strictly to treat them.
# /etc/grafana/provisioning/dashboards/default.yaml apiVersion: 1 providers: - name: team-dashboards orgId: 1 type: file disableDeletion: true # removing a file will not delete the dashboard allowUiUpdates: false # UI saves are rejected; Git is the source updateIntervalSeconds: 30 # how often Grafana rescans the path options: path: /var/lib/grafana/dashboards foldersFromFilesStructure: true # subdirectories become Grafana folders The day-to-day workflow:
- Build or tweak the panel in a scratch Grafana and copy the JSON model.
- Set
"id": nulland give the dashboard a fixeduid. - Replace datasource references with
{"type": "prometheus", "uid": "prometheus-main"}. - Commit, and open a pull request so the change gets a review.
To verify it worked, query the API after startup. Use a service account token rather than an admin password:
# list dashboards with their folders curl -s -H "Authorization: Bearer $GF_TOKEN" "http://localhost:3000/api/search?type=dash-db" \ | jq '.[] | {uid, title, folderTitle}' # confirm one is file-managed: expect "provisioned": true curl -s -H "Authorization: Bearer $GF_TOKEN" "http://localhost:3000/api/dashboards/uid/api-latency" \ | jq '.meta | {provisioned, provisionedExternalId}' If a dashboard is missing, check the Grafana server log for provisioning errors. The usual causes are malformed JSON and duplicate uids across files.
Advanced patterns
Generate instead of hand-edit. Large dashboard JSON produces noisy diffs. Two tools let you define panels in code and render JSON in CI:
- Grafonnet, which uses Jsonnet.
- The Grafana Foundation SDK, which provides typed builders for several languages.
Grafana's newer CLI, grafanactl, targets pushing and pulling resources. Check the Grafana docs for its status in your version before you depend on it.
Kubernetes. The Grafana Helm chart can run a sidecar that watches ConfigMaps carrying a label (grafana_dashboard: "1" by default) and loads them. Combine it with Argo CD and a merged pull request becomes a deployed dashboard. Watch the size limits:
- A ConfigMap is limited to 1 MiB.
- Client-side
kubectl applyfails much earlier, around 256 KiB, because of the last-applied annotation. Use server-side apply (Argo CD'sServerSideApply=truesync option) for large dashboards. - Very large dashboards may still need generation or splitting.
Terraform or OpenTofu. The Grafana provider's grafana_dashboard resource manages dashboards through the API. This suits Grafana Cloud, where you cannot mount files, but it adds state to manage. Do not mix it with file provisioning for the same uids.
Gate it in CI. Before Grafana ever sees a file, fail the build on any of these:
- invalid JSON
- a non-null
id - a missing or empty uid
- duplicate uids
#!/usr/bin/env bash # ci/check-dashboards.sh — run on every pull request set -euo pipefail shopt -s globstar nullglob files=(dashboards/**/*.json) [ "${#files[@]}" -gt 0 ] || { echo "no dashboard files found"; exit 1; } for f in "${files[@]}"; do jq -e '(.uid | type == "string" and length > 0 and length <= 40) and (.id == null)' "$f" >/dev/null \ || { echo "bad uid/id: $f"; exit 1; } done # uids must be unique across the repo dups=$(jq -r '.uid' "${files[@]}" | sort | uniq -d) [ -z "$dups" ] || { echo "duplicate uids: $dups"; exit 1; } The globstar option makes ** recurse into subdirectories. The nullglob option plus the empty-array guard stop jq from waiting on stdin when nothing matches. Grafana's dashboard-linter project adds opinionated checks on top.
Performance notes
Provisioning itself is cheap. Grafana rescans the path every updateIntervalSeconds and only rewrites dashboards whose file changed. Even with thousands of files, the bigger problem is usually repo hygiene rather than CPU, but measure on your own instance if it matters. A longer interval, such as 60 seconds or more, is fine if you deploy through restarts anyway.
The real cost sits in what the dashboards query. Code review is the chance to catch the usual offenders:
- Use
$__rate_intervalrather than hard-coded ranges inrate(). - Avoid template variables built from unbounded
label_values()on high-cardinality labels. - Set sensible default refresh intervals. A 5-second refresh multiplied by every open browser tab adds up.
- Move expensive expressions into Prometheus recording rules and chart the result. Prometheus 3.16 is current; see the Prometheus docs.
The approach has real costs:
- Reviewing JSON diffs is tedious without generation.
- There is a delay between an idea and a live panel.
- People who liked clicking will complain.
If your team is two people on one instance, a nightly API export to Git may be enough. Move to files once dashboards are shared, alerts link to them, or rebuilds happen. For more infrastructure workflows like this, browse kuryzhev.cloud. The reference for every provider option is the Grafana provisioning documentation.

