ObservabilityXXIX · Grafana ProvisioningGrafanaProvisioning
Provisioning Anatomy
What you'll learn
- Identify the five sub-directories of /etc/grafana/provisioning/ and the loader that owns each one
- Choose between the file, github, bitbucket, gitlab, s3, http, and gcs providers based on the source of truth
- Trigger a controlled reload via /api/admin/provisioning/dashboards/reload and verify the change without restarting Grafana
- Explain how the dashboard loader reconciles with the data source loader through the UID field
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
A Grafana container starts. The image is grafana/grafana:11.2.0.
Three seconds later, the operator opens the UI and sees the
production folder, the staging folder, the seven data sources, the
forty-three dashboards, and the alert rules that were never typed
into the UI. That is the provisioning folder at work: a directory
of YAML files the server polls on a fixed interval and reconciles
against the database.
Every Grafana in production either has that directory or does not have it. The ones that do not have it are snowflakes. The ones that do are deployable artefacts.
What provisioning anatomy is
The provisioning folder is the on-disk contract between an
operator’s intent and Grafana’s live state. The default root is
/etc/grafana/provisioning/ and is settable through the
GF_PATHS_PROVISIONING environment variable or the paths .provisioning key in grafana.ini. The folder contains five
sub-directories, each one owned by a single loader:
/etc/grafana/
|-- grafana.ini
|-- provisioning/
| |-- datasources/ # data source loader
| | |-- metrics.yaml
| | |-- logs.yaml
| | |-- traces.yaml
| |-- dashboards/ # dashboard provider loader
| | |-- prod.yaml
| | |-- sre.yaml
| | |-- json/
| | |-- checkout-error-rate.json
| | |-- cache-hit-rate.json
| |-- alerting/ # rule / route / contact-point loader
| | |-- rules.yaml
| | |-- contact-points.yaml
| | |-- policies.yaml
| |-- plugins/ # plugin loader
| | |-- plugins.yaml
| |-- access-control/ # RBAC loader (Grafana 11+, opt-in)
| |-- permissions.yaml
Each sub-directory is scanned by a goroutine that runs on a
configurable interval ([provisioning] provider_configuration_sync_interval, default 60 seconds). The
loader reads every *.yaml / *.yml file, parses it, and walks
the declared items. The walk is a reconcile, not a push: the
loader compares the declared state to the rows in the database and
inserts, updates, or leaves alone accordingly. There is no event
queue. A malformed file is logged and skipped; the rest of the
directory is applied.
Why a sysadmin cares
Three reasons, and each one maps to a different incident class:
- Reproducibility. A second Grafana (a staging replica, a
regional instance, a disaster-recovery host) is the same files.
rsync -a provisioning/ host2:/etc/grafana/provisioning/is the entire operational contract for cloning. UI-edited dashboards do not have this property. - Audit. Every change is a commit. The change log is
git log -- provisioning/. The default of a UI edit is no log at all. - Rollback.
git revert \{sha\}undoes a dashboard change in the same workflow as a code change. The revert is also a commit, so the rollback itself is auditable.
The cost of provisioning is a small amount of upfront ceremony (the YAML, the loader, the reload). The cost of not having it is paid in incident time, the first time a dashboard that “works on the laptop” does not work on production.
How it works
The provisioning system is a set of seven small reconcilers, one per resource kind, plus a watchdog that triggers a re-scan on a timer.
+--------------------------+
| Grafana process starts |
+-----------+--------------+
|
v
+--------------------------+
| bootstrap: read |
| grafana.ini, locate |
| provisioning directory |
+-----------+--------------+
|
v
+--------------------------+
| start 7 reconcilers |
| (one per resource kind) |
+-----------+--------------+
|
v
+--------------------------+
| every N seconds |
| (default 60): |
| scan provisioning/ |
| parse YAML/JSON |
| reconcile vs database |
| emit per-item log |
+-----------+--------------+
|
v
+--------------------------+
| on demand, an operator |
| POSTs /api/admin/ |
| provisioning/<kind>/ |
| reload to skip the wait |
+--------------------------+
The five resource kinds each have their own loader:
- Data sources (
datasources/*.yaml) — declares types, UIDs, URLs, and credentials. - Dashboards (
dashboards/*.yaml+ dashboard JSON files) — declares providers and the JSON dashboards they own. - Alerting (
alerting/*.yaml) — declares rule groups, notification policies, and contact points. - Plugins (
plugins/*.yaml) — installs and enables third-party plugins. - Access control (
access-control/*.yaml, opt-in via[experimental] access_control_provisioning = true) — declares folder and team permissions.
Two more reconcilers that are not file-based but follow the same
discipline: the preferences reconciler (default org theme and
home dashboard) and the encryption key reconciler, which lives
under a separate path.
The dashboard loader is the only one that itself points at a provider. A provider configuration block declares one of seven back-ends for the loader to fetch dashboard JSON from:
file— a local directory (the default).github— a GitHub repository branch.gitlab— a self-hosted or SaaS GitLab repository.bitbucket— a Bitbucket Cloud repository.s3— an AWS S3 bucket.gcs— a Google Cloud Storage bucket.http— a generic HTTP endpoint that returns dashboard JSON.
The choice of provider is the choice of where the dashboard content lives. The choice of directory is the choice of where the declaration lives. They are separate.
How to configure it
The provisioning directory is configured through grafana.ini or
the matching environment variable. The default is the path shown
above; the recommendation is to leave it alone.
# /etc/grafana/grafana.ini
[paths]
# The default. Set this only if the deployment uses a non-standard
# install layout (e.g. a Kubernetes sidecar mount or a custom
# Docker volume).
provisioning = /etc/grafana/provisioning
[provisioning]
# How often the loaders reconcile. 60 seconds is the default.
# Lower this (15s) in CI where faster feedback matters; raise it
# (300s) when the provisioning directory is on a slow filesystem.
# The per-resource override in the YAML file takes precedence.
interval = 60
[experimental]
# Required for /etc/grafana/provisioning/access-control/.
# Default in Grafana 11 is off; the directory is silently ignored
# without this key.
access_control_provisioning = true
The dashboard provider block is the one most operators touch. The production shape for a file-based source:
# /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, is the config-as-code source for a team that treats dashboards as deployable artefacts:
# /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 same shape, with type: gitlab and gitlab_project_id,
points at a GitLab repo. With type: bitbucket, the options
expects repo_slug and branch. With type: s3, the options
expects bucket, region, and path. With type: http, the
options expects url and a JSON manifest of paths.
# /etc/grafana/provisioning/dashboards/prod.yaml
apiVersion: 1
providers:
- name: prod-sre
orgId: 1
folderUid: sre
folder: SRE
type: s3
options:
bucket: runbook-grafana-prod
region: eu-west-1
path: dashboards/prod/
A subtle but important detail: disableDeletion: false means the
loader will delete a dashboard that no longer appears in the
provider. disableDeletion: true means the loader will leave a
removed dashboard in the database. The same key exists on the
data-source loader. The choice is permanent on a per-provider
basis: a true here is the safe default for any directory that
might be partially deleted by mistake.
How to validate it
Five checks confirm the loader is working. None of them touch production data; all are READ-ONLY.
# 1. The provisioning directory is the one Grafana is using.
# The grafana.log records the resolved path at startup.
docker logs grafana 2>&1 | grep -i 'provisioning dir'
# logger=provisioning t=... msg="Provisioning directory" path=/etc/grafana/provisioning
# 2. The admin provisioning endpoint reports the loaders are
# healthy. The response is the per-loader status, not the
# per-resource state.
curl -sf -u "grafana-admin:$GF_ADMIN_PASSWORD" \
http://grafana:3000/api/admin/provisioning/dashboards | jq
{
"id": "prod-sre",
"name": "prod-sre",
"type": "file",
"folder": "SRE",
"folderUid": "sre",
"enabled": true,
"updated": "2026-08-13T09:42:11Z",
"deleteRestricted": false
}
# 3. A provider reload is forced without restarting Grafana.
# Severity: CONFIGURATION. Idempotent.
curl -sf -X POST -u "grafana-admin:$GF_ADMIN_PASSWORD" \
http://grafana:3000/api/admin/provisioning/dashboards/reload
# {"message":" Dashboards provisioning reloaded"}
# 4. The dashboard list reflects the reload.
curl -sf -u "grafana-admin:$GF_ADMIN_PASSWORD" \
"http://grafana:3000/api/search?folderUIDs=sre" | jq '.[] | {uid, title}'
{ "uid": "checkout-error-rate", "title": "Checkout error rate" }
{ "uid": "cache-hit-rate", "title": "Cache hit rate" }
# 5. The grafana server logs show 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)
A missing inserted or updated line after a reload means the
loader did not pick up the change. The path is one of two things:
either the file is outside the watched directory, or the file
extension is one the loader does not parse (.yml.bak or .json
inside the provider YAML directory, where .json is the
dashboard convention, not the provider convention).
How it can fail
Six high-frequency failure shapes:
- Wrong provider type. The YAML declares
type: githubbut the network cannot reach GitHub. Symptom: the loader logsDashboard provider failed to fetch; the dashboards are inserted on the first successful poll and removed on the next failed poll ifdisableDeletion: false. - UID collision across providers. Two providers declare a
dashboard with the same UID (“checkout-error-rate”). Symptom:
the second provider’s reload is rejected with
Dashboard with uid \{x\} exists; the first provider’s dashboard is the one rendered. - Folder UID does not exist. The provider declares
folderUid: srebut no provider or admin call has created the folder. Symptom: Grafana auto-creates a folder with that UID at the first dashboard insert, but the admin team expects an existing folder; the auto-created one has no permissions. - Reload endpoint skipped. The operator edits a file and
waits; six minutes later the dashboard has not appeared. The
reason is that the reconcile interval is 60 s and the file
is in a directory whose
updateIntervalSecondsis 300. The forced reload endpoint skips the wait. - Provider’s
pathis wrong for the loader. Afileprovider pointed at/etc/grafana/dashboards/(the legacy path) silently no-ops in Grafana 11 because the loader watches only declared paths. The legacy path is a fall-through that catches teams mid-migration. - Token expiry on a git or S3 provider. The GitHub token
scope is reduced; the OIDC token on the S3 provider expires.
Symptom: the loader logs
401 Unauthorizedfrom the provider and the dashboards disappear on the next reconciliation.
How to troubleshoot it
The diagnostic order. Each step isolates one layer.
- Is the loader running? The
ProvisionedTouchedPrometheus metric and theprovisioninglog entries confirm a poll happened. If neither appears, the loader is dead — checkgrafana-serverstartup for the path. - Is the right file in the watched directory? Run an
ls -la provisioning/dashboards/on the container and confirm the file is present. The most common cause is a volume mount that is missing the file. - Does the YAML parse?
python3 -c "import yaml; yaml .safe_load(open('dashboards/prod.yaml'))"is a quick safety net. The loader’s parser is stricter thanyaml; the right final check is to reload and read the log. - Did the force-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 file appear in the API?
GET /api/search ?folderUIDs=\{x\}lists dashboards in that folder. A missing entry is a loader missed it; an extra entry is a UI edit replaced a file entry. - Is the provider reachable? For
github,git ls-remote https://x-access-token:$TOKEN@github.com/...on the Grafana host. Fors3,aws s3 ls s3://bucket/...with the same credentials the loader uses. Forhttp,curl -fsSL {url}.
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 (CI, git pull, S3 sync). - Provider credentials are stored in the YAML. A GitHub token, an S3 access key, or a Vault token lives in the file at provisioning time. Treat the directory as a secret. The Grafana encryption at rest is for the database, not the filesystem.
- 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.
Use a service account token with the
Adminbasic role for CI-driven reloads. - The
allowUiUpdates: falsekey closes the divergence path. Without it, a UI edit is reverted at the next poll; the operator’s “fix” is silently undone. With it, the UI edit is rejected at save time and the operator receives a clear “this dashboard is read-only” signal.
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.
Production guidance
- One Grafana, one provisioning directory. Commit the directory to version control. CI validates the YAML.
- 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
grafana_versionin the directory’s README and remember thatschemaVersioninside a dashboard JSON is the version written by the Grafana that exported the dashboard, not the version that runs it. 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.
Verification
You should now be able to answer:
- What are the five sub-directories of
/etc/grafana/provisioning/and which loader owns each? - How does the polling file loader differ from the action-based admin HTTP API?
- Which seven provider types does the dashboards loader support in Grafana 11, and which one is the simplest?
- Why is the
UIDfield the coupling point between data source provisioning and dashboard provisioning? - What does
/api/admin/provisioning/dashboards/reloadactually do, and when would you use it instead of waiting for the poll?
Quiz
Knowledge check · 8 questions
Q1. Which sub-directory of /etc/grafana/provisioning/ is owned by the dashboard provider loader?
Q2. Which provider type is the simplest, requires no external credentials, and lives entirely on the local filesystem?
Q3. A malformed YAML file in the provisioning directory halts the entire reconciliation pass for that loader.
Q4. Which endpoint forces a reload of the dashboard provisioning without restarting Grafana?
Q5. Which of the following are real dashboard providers in Grafana 11?
Q6. Name the field that connects a dashboard panel to a data source and survives a UI rename of the data source.
Q7. What does disableDeletion: false on a provider block do?
Q8. Why is the forced reload endpoint idempotent and safe to call from CI?
Passing score: 75%. Answers are checked in this browser.