ObservabilityXXIX · Grafana ProvisioningGrafanaProvisioning
Provisioning Dashboards
What you'll learn
- Write a dashboards/*.yaml provider block that declares a file, github, or s3 source with the correct schema and folder UID
- Match a dashboard JSON schemaVersion to the running Grafana version and validate the export before committing
- Explain the deletion-when-removed-from-yaml semantic and configure disableDeletion to prevent accidental removal
- Use the /api/admin/provisioning/dashboards/reload endpoint and the per-dashboard /api/dashboards/uid/{uid} endpoint to verify the live state
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
The team finally writes its first dashboard in the UI. The dashboard
is good. The team exports through Share → Export → Save. The next
deploy, the dashboard is gone. The reason is that the file-based
provisioning directory has disableDeletion: false as the default
and the new dashboard was not on disk.
This lesson is about the dashboard YAML, the dashboard JSON, the folder pattern, the schema version, and the deletion semantic.
What dashboard provisioning is
Dashboard provisioning is the act of declaring Grafana’s dashboards through a YAML provider file and the JSON dashboard content it references. The provider file declares a source (a local directory, a GitHub repository, an S3 bucket) and tells the loader what to do with the dashboards it finds. The JSON files are the content. The two are separate files for a reason: the operator wants to version the content and the operator wants to change the source location without renaming every file.
The provider block is apiVersion: 1 with a providers: list. The
loader walks the list, fetches the JSON files from each source, and
reconciles declared-to-live by folderUid + uid (or title if
no UID is set on the JSON).
Why a sysadmin cares
The dashboard is what the operator opens at 03:00. The provisioning file is what makes the dashboard survive a Grafana restart. The reasons a team provisions dashboards:
- Survive a container restart. A UI-built dashboard lives in the database. The database is on a volume that is, in the worst case, ephemeral. The provisioning file is on a ConfigMap or a Git repo that is not ephemeral.
- Code review. A dashboard edit is a pull request. The diff is the change. The reviewer is the second pair of eyes.
- Reproducibility. A new region, a disaster-recovery host, a staging replica: the same dashboard from the same file.
- SchemaVersion control. A
schemaVersionmismatch is the dominant upgrade-incompatibility class. Pinning the schema version in the file is the way to detect the mismatch before the dashboard goes blank.
How it works
The dashboard loader is two distinct reconcilers: one for the provider YAML and one for the dashboard JSON the provider points at. The provider YAML is the declaration; the dashboard JSON is the content.
tick (default 60s)
|
v
scan provisioning/dashboards/*.yaml
|
v
for each provider block:
|
+--> fetch the source (file, github, s3, ...)
|
+--> for each dashboard JSON in the source:
| |
| +--> parse JSON
| | |
| | +--> schemaVersion > Grafana accepts?
| | | log warning, skip
| |
| +--> lookup by uid (or folderUid + title)
| |
| +--> match found:
| | diff against declared
| | update database if different
| | log ProvisioningDashboards updated
| |
| +--> no match:
| | insert row
| | log ProvisioningDashboards inserted
| |
| +--> extra row in database (and disableDeletion: false):
| delete row
| log ProvisioningDashboards deleted
|
+--> emit per-loader ProvisioningDashboards summary
The natural key is folderUid + uid. A missing UID is
auto-generated from the title in the same way the data source
loader does. The auto-generated UID is stable across
reconciliations, but the operator should always set the UID
explicitly — a generated UID changes when the title changes,
which is the most common break-pattern.
The disableDeletion key is the operationally critical one. With
disableDeletion: false (the default), removing a dashboard JSON
from the source deletes the row from the database on the next
poll. With disableDeletion: true, the removal is logged and the
dashboard remains. The choice is permanent per provider; the
“prod” provider is false (the file is the source of truth), the
“experimental” provider is true (the file is a draft).
The folder is the second key in the natural key. folderUid
points at a folder created either by the dashboard loader (when
the first dashboard is inserted) or by an explicit
access-control/permissions.yaml entry. The folder UID is
immutable; renaming the folder in the UI renames the display
but not the UID. A misconfigured folderUid is the dominant
cause of dashboards that load into a “wrong” folder.
How to configure it
A production-grade directory declares one provider per team and keeps the file structure shallow.
# /etc/grafana/provisioning/dashboards/prod.yaml
apiVersion: 1
providers:
- name: prod-sre
orgId: 1
folderUid: sre
folder: SRE
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards/prod-sre
foldersFromFilesStructure: true
The same block, pointed at a GitHub repository, with the extension fields the loader expects:
# /etc/grafana/provisioning/dashboards/prod.yaml
apiVersion: 1
providers:
- name: prod-sre
orgId: 1
folderUid: sre
folder: SRE
type: github
disableDeletion: false
updateIntervalSeconds: 60
allowUiUpdates: false
options:
org: runbook-academy
repo: grafana-dashboards
branch: main
path: dashboards/prod
use_github_auth: true
github_auth_token: ${GITHUB_TOKEN}
The extension fields for the s3 provider:
# /etc/grafana/provisioning/dashboards/prod.yaml
apiVersion: 1
providers:
- name: prod-sre
orgId: 1
folderUid: sre
folder: SRE
type: s3
disableDeletion: false
options:
bucket: runbook-grafana-prod
region: eu-west-1
path: dashboards/prod/
The minimum dashboard JSON the loader accepts:
{
"uid": "checkout-error-rate",
"title": "Checkout error rate",
"schemaVersion": 39,
"version": 1,
"editable": false,
"timezone": "browser",
"time": { "from": "now-6h", "to": "now" },
"refresh": "30s",
"tags": ["prod", "checkout"],
"panels": [],
"templating": { "list": [] },
"annotations": { "list": [] }
}
The editable: false at the JSON level is the dashboard-local
equivalent of allowUiUpdates: false at the provider level. The
JSON-level key is enforced by the loader; the UI cannot save a
panel edit. The provider-level key is enforced by the loader’s
reconcile loop; a UI edit is reverted at the next poll.
For data-source references inside a panel, the JSON expects:
{
"panels": [
{
"type": "timeseries",
"title": "Checkout error rate",
"datasource": {
"type": "prometheus",
"uid": "prom-prod"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(checkout_errors_total[5m]))"
}
]
}
]
}
The datasource.uid is the cross-resource handle. The runtime
resolves the UID at query time; the loader does not validate the
UID at provisioning time. The consequence is that a dashboard
with a missing UID is provisioned successfully and displays
“datasource not found” on every panel.
How to validate it
Five checks confirm a dashboard is provisioned and reachable.
# 1. The dashboards in the folder are listed.
curl -sf -u "grafana-admin:$GF_ADMIN_PASSWORD" \
"http://grafana:3000/api/search?folderUIDs=sre" \
| jq '.[] | {uid, title, type}'
{ "uid": "checkout-error-rate", "title": "Checkout error rate", "type": "db" }
{ "uid": "cache-hit-rate", "title": "Cache hit rate", "type": "db" }
# 2. The dashboard metadata includes the provisioned flag.
# A 'provisioned: true' value means the dashboard was loaded
# from a file, not from the UI.
curl -sf -u "grafana-admin:$GF_ADMIN_PASSWORD" \
"http://grafana:3000/api/dashboards/uid/checkout-error-rate" \
| jq '.meta | {provisioned, slug, folder}'
{
"provisioned": true,
"slug": "checkout-error-rate",
"folder": "SRE"
}
# 3. The reload endpoint is forced without a restart.
curl -sf -X POST -u "grafana-admin:$GF_ADMIN_PASSWORD" \
http://grafana:3000/api/admin/provisioning/dashboards/reload
# {"message":"Dashboards provisioning reloaded"}
# 4. The loader logs the per-resource outcome.
docker logs grafana --since 2m 2>&1 \
| grep -i 'ProvisioningDashboards'
# ProvisioningDashboards inserted (uid=checkout-error-rate)
# ProvisioningDashboards unchanged (uid=cache-hit-rate)
# 5. The schemaVersion is the one the loader accepts.
# Grafana 11.x accepts 39 and 40. A higher value is dropped.
jq '.schemaVersion' \
/etc/grafana/provisioning/dashboards/prod-sre/checkout-error-rate.json
# 39
A missing inserted line for a new file means the loader did not
pick up the file. The cause is one of three: the file is outside
the watched directory, the file extension is not .json, or the
file is a directory the loader does not recurse into by default.
How it can fail
Six high-frequency failure shapes:
- SchemaVersion mismatch. The dashboard JSON has a
schemaVersionhigher than the running Grafana accepts. Symptom: the loader logsDashboard schemaVersion 41 is newer than the supported 40; the dashboard is dropped. - Folder UID auto-created. The provider declares
folderUid: srebut no permission entry exists for that UID. Symptom: the folder is auto-created on the first dashboard insert, but the team has no permissions for it; the dashboard is invisible to non-admin users. - Deletion of an intentionally-removed dashboard. A developer deletes a dashboard JSON from the source. The loader deletes the row at the next poll. A “we thought we just removed one file” becomes a “we just removed the dashboard’s entire history” event.
- UI edit reverted at the next poll. The operator edits a panel in the UI. The next poll restores the file-based value. Symptom: the operator’s “fix” vanishes within a minute.
- Cross-resource UID typo. The dashboard JSON declares a
datasource.uidthat does not exist. Symptom: the dashboard is provisioned successfully; every panel displays “datasource not found”. __inputsleft in the committed JSON. A exported JSON contains a__inputsfield with a token. Symptom: the token is visible in the Git repo. The fix is to strip__inputsfrom the export.
How to troubleshoot it
The diagnostic order, designed to isolate one layer at a time.
- Is the file in the watched directory?
ls -laon the container’s provisioning directory. The most common cause is a volume mount that is missing the file. - Does the file parse?
python3 -c "import json; json .load(open('checkout-error-rate.json'))"is a quick safety net. The loader’s parser is stricter thanjson; the right final check is a reload and a log read. - Did the schemaVersion match?
jq '.schemaVersion' <file>. A value higher than the running Grafana accepts is the most common cause of a silently dropped dashboard. - Did the reload hit?
POST /api/admin/provisioning/dashboards /reloadand watch the next 5 s of the log. AProvisioningDashboards ...line per file is the success signal. - Does the dashboard appear in the API?
GET /api/search ?folderUIDs=\{x\}. A missing entry is a loader missed it; an extra entry is a UI edit replaced a file entry. - Is the UID consistent?
jq '.uid' \{file\}and the/api/dashboards/uid/\{uid\}endpoint. A mismatch is a “we renamed the file but the loader is still inserting the old one” pattern.
Security implications
- The provisioning directory is read by the Grafana process.
The grafana user must have read access. The directory should
not be world-writable. The container image’s
grafanauser (UID 472) needs read access; the operator’s write access goes through a separate workflow. - Provider credentials live in the YAML. A GitHub token, an S3 access key, or a Vault token is in the file at provisioning time. Treat the directory as a secret.
__inputsin the exported JSON. A legacy bug-pattern where an internal token was hidden in a__inputsfield still surfaces occasionally. Strip__inputsfrom the exported JSON before committing.- The
editable: falsekey at the JSON level closes the divergence path. Without it, a UI edit is allowed and is reverted at the next poll. With it, the UI edit is rejected at save time and the operator receives a clear “this dashboard is read-only” signal. - The reload endpoint requires admin auth. A read-only service account cannot trigger a reload. The reload is a privileged operation because it can change the live state.
Performance implications
- Each loader polls at the configured interval. A 60 s poll on a directory of 200 dashboards is invisible work for a modern Grafana; a 5 s poll on a network-mounted Git repository is noticeable.
- Network-mounted provisioning directories (NFS, S3 mount via
s3fs, or a sidecar container) introduce latency and stale-file risks. Use a local copy or a CI deploy that mirrors the directory on container start. - The provider’s
updateIntervalSecondsoverrides the global default. A short interval on a slow provider causes the provider to be slow on every poll, not just the first. - The
provisionedflag is checked on every dashboard list call. A large fleet of provisioned dashboards is still a single database query; the flag is a column on the row.
Production guidance
- One provider per team, one folder per provider. Commit the directory to version control. CI validates the JSON.
- Choose the provider by thinking about the write direction.
If the dashboards are written by humans through a code review,
githuborgitlabis the natural fit. If they are written by automation (a recording rules pipeline, a service-mesh exporter),s3orhttpis the natural fit. - Pin the
schemaVersionin the directory’s README. AschemaVersionhigher than the running Grafana accepts is the dominant incompatibility class. - Treat the provisioning directory as a deployable artefact.
Its diff is the change log. Its rollback is
git revert. Its audit isgit log. - Use
editable: falseon the JSON for read-only dashboards andallowUiUpdates: falseon the provider for the rest.
Verification
You should now be able to answer:
- What is the natural key for matching a declared dashboard to a database row, and how does it relate to the folder UID?
- What does
disableDeletion: falsedo on a provider block, and when isdisableDeletion: truethe right choice? - Why does a
schemaVersionmismatch silently drop a dashboard? - Which endpoint returns the
provisionedflag for a single dashboard, and what is the difference betweenprovisioned: trueand a UI-edited dashboard? - How does the loader handle a
datasource.uidthat does not exist, and at what point does the failure surface?
Quiz
Knowledge check · 8 questions
Q1. Which two fields form the natural key for matching a dashboard in the loader?
Q2. What happens when a schemaVersion in the dashboard JSON is higher than the running Grafana accepts?
Q3. A provider with disableDeletion: false deletes a dashboard from the database when the JSON file is removed from the source.
Q4. Which key on the provider block reverts a UI edit at the next provisioning poll?
Q5. Which of the following are common failure shapes for dashboard provisioning?
Q6. Name the JSON field that signals the dashboard was loaded from a file rather than created in the UI.
Q7. What is the right folder UID discipline for a team that owns the SRE dashboard folder?
Q8. Why is foldersFromFilesStructure: true useful for a file-based provider?
Passing score: 75%. Answers are checked in this browser.