ObservabilityXXIX · Grafana ProvisioningGrafanaProvisioning
Testing Provisioning Locally
What you'll learn
- Spin up a disposable Grafana with the same provisioning directory as production and verify the loaders apply every file
- Configure a synthetic TestData data source for local testing without exposing the production backends
- Validate a provisioning change with curl /api/admin/provisioning/.../reload and the per-resource /api endpoints
- Use the sandbox pattern to exercise a provisioning change end-to-end before any production rollout
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 developer writes a new dashboard on the staging Grafana. The
dashboard works. The pull request is merged. The production
Grafana is rebuilt and the loader complains: data source with uid prom-prod exists. The reason is that the developer
referenced the staging data source UID, not the production one.
The dashboard loads in staging because the UID matches; the
dashboard fails in production because the UID does not match.
This lesson is about the test setup that catches this kind of mistake before the production roll-out. The setup is a disposable Grafana with the same provisioning directory, a synthetic data source, and a sandbox that exercises the loaders end-to-end.
What testing provisioning locally is
Testing provisioning locally is the practice of running a disposable Grafana with the same provisioning directory as production, validating that the loaders apply every file, and confirming the API endpoints report the expected state. The test is performed in a sandbox — a Docker container, a separate namespace, or a CI runner — that is destroyed after the test. The cost is a few minutes of setup; the benefit is the absence of “what changed in production” surprises.
The test setup is:
- A Grafana container with the same
apiVersion: 1YAML files. - A synthetic data source (the
testdataplugin) that produces predictable values without a real backend. - A
curl-based script that calls the admin API and validates the response. - A
docker compose down -vat the end to return the host to working state.
The discipline is to never commit a provisioning change without running the test. The test is the contract between the developer’s laptop and the production Grafana.
Why a sysadmin cares
Two reasons, each one a class of production failure:
- Loader failures are silent. A malformed YAML, a UID typo, a missing folder UID, a plugin version mismatch — all of these are logged at WARN level and do not crash the loader. The dashboard is dropped, the data source is rejected, the alert rule is skipped. The production error is the absence of a dashboard, not a stack trace.
- Cross-resource dependencies are invisible. A dashboard that references a missing UID is provisioned successfully; the panel fails at query time. The dashboard is in the database; the panel is broken. The test must check the cross-reference, not just the file presence.
The sandbox is the only place the operator can run the test with confidence. A staging Grafana is the wrong place: the staging data sources and dashboards are different from production. The test must be a self-contained environment.
How it works
The test setup is a Docker Compose stack with one Grafana container, one synthetic data source, and a validation script.
+--------------------+
| Test runner |
| (shell + curl) |
| runs: |
| docker compose up|
| curl /api/... |
| validate |
| docker compose |
| down -v |
+---------+----------+
|
v
+--------------------+
| Grafana sandbox |
| image: grafana/ |
| grafana:11.2.0 |
| volumes: |
| - provisioning/ |
| - testdata.yaml |
| env: |
| GF_AUTH_ANON: |
| true |
+---------+----------+
|
v
+--------------------+
| TestData plugin |
| (built-in) |
| yields: |
| CSV / random |
| series for |
| dashboards |
+--------------------+
The synthetic data source is the testdata plugin that ships
with the Grafana image. The plugin has no external dependencies
and produces deterministic values through CSV content and
Random walk data shapes. The dashboard JSON in the test
references the test data source; the validation script confirms
the panels render.
For the validation, the script calls the admin API:
/api/datasources— the data sources are loaded./api/search?folderUIDs={x}— the dashboards are loaded./api/dashboards/uid/{uid}— the provisioned flag is true./api/v1/provisioning/alert-rules— the alert rules are loaded./api/admin/provisioning/dashboards/reload— the reload endpoint is reachable.
The test is a green bar when every endpoint returns the expected value. The test is a red bar when any endpoint returns an unexpected value or a non-200 status.
How to configure it
A production-grade test setup is a Docker Compose file with two services and a validation script.
# docker-compose.test.yaml
services:
grafana-test:
image: grafana/grafana:11.2.0
ports:
- "3001:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_USERS_DEFAULT_THEME=light
- GF_LOG_LEVEL=warn
volumes:
- ./provisioning:/etc/grafana/provisioning:ro
- ./testdata.yaml:/etc/grafana/provisioning/datasources/testdata.yaml:ro
The sandbox provisioning directory mirrors the production layout. The testdata.yaml is the synthetic data source:
# testdata.yaml
apiVersion: 1
datasources:
- name: TestData
uid: testdata
type: testdata
access: proxy
orgId: 1
editable: false
jsonData:
timeInterval: 30s
The dashboard JSON in the test references the test data source by the canonical UID:
{
"uid": "checkout-error-rate-test",
"title": "Checkout error rate (test)",
"schemaVersion": 39,
"editable": false,
"tags": ["test"],
"panels": [
{
"type": "timeseries",
"title": "Random walk",
"datasource": {
"type": "testdata",
"uid": "testdata"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "testdata",
"uid": "testdata"
},
"scenarioId": "random_walk"
}
]
}
]
}
The validation script:
#!/bin/bash
# bin/test-provisioning.sh
set -euo pipefail
echo "Starting Grafana sandbox..."
docker compose -f docker-compose.test.yaml up -d
echo "Waiting for Grafana to be ready..."
for i in {1..30}; do
if curl -sf http://localhost:3001/api/health > /dev/null; then
break
fi
sleep 1
done
echo "Validating data sources..."
curl -sf http://localhost:3001/api/datasources \
| jq -e '.[] | select(.uid == "testdata")' > /dev/null
echo "Validating dashboards..."
curl -sf "http://localhost:3001/api/search?query=checkout-error-rate-test" \
| jq -e '.[0].uid == "checkout-error-rate-test"' > /dev/null
echo "Validating the provisioned flag..."
curl -sf "http://localhost:3001/api/dashboards/uid/checkout-error-rate-test" \
| jq -e '.meta.provisioned == true' > /dev/null
echo "Validating the reload endpoint..."
curl -sf -X POST http://localhost:3001/api/admin/provisioning/dashboards/reload \
| jq -e '.message | test("reloaded")' > /dev/null
echo "Tearing down the sandbox..."
docker compose -f docker-compose.test.yaml down -v
echo "All checks passed."
The script is the contract. The script returns a non-zero exit code on the first failure; the CI run fails; the pull request is blocked.
How to validate it
Five checks confirm the test setup is correct.
# 1. The Grafana sandbox is up.
docker compose -f docker-compose.test.yaml ps
# NAME SERVICE STATUS
# xxx-grafana-test-1 grafana-test Up 5 seconds (healthy)
# 2. The Grafana health endpoint is reachable.
curl -sf http://localhost:3001/api/health | jq
# {"database":"ok","version":"11.2.0"}
# 3. The data sources are loaded.
curl -sf http://localhost:3001/api/datasources | jq '.[] | {uid, type}'
# {"uid":"testdata","type":"testdata"}
# 4. The dashboards are loaded.
curl -sf "http://localhost:3001/api/search?query=test" \
| jq '.[] | {uid, title}'
# {"uid":"checkout-error-rate-test","title":"Checkout error rate (test)"}
# 5. The provisioned flag is true.
curl -sf "http://localhost:3001/api/dashboards/uid/checkout-error-rate-test" \
| jq '.meta.provisioned'
# true
A false value at the last check means the dashboard is in
the database but is UI-edited, not file-based. The cause is
usually a UI edit that raced the loader; the fix is to reset
the dashboard to the file-based version by deleting the row
and reloading.
How it can fail
Six high-frequency failure shapes:
- Wrong image tag. The test uses an older Grafana image
that does not accept the dashboard schemaVersion. Symptom:
the loader logs
Dashboard schemaVersion 41 is newer than the supported 40; the dashboard is dropped. - Wrong port. The test uses a port that is already bound
on the host. Symptom: the container exits with
bind: address already in use; the validation script fails on thecurl http://localhost:3001/api/healthcall. - Volume mount read-only. The test mounts the
provisioning directory read-only. The reload endpoint
cannot write to the database; the test fails. Symptom:
the loader logs
permission denied. - TestData plugin disabled. The Grafana image is built
without the testdata plugin. Symptom: the data source is
rejected with
plugin not found: testdata; the provisioning fails. - UID collision with the production UID. The test
uses the same UID as the production data source. Symptom:
the loader logs
data source with uid {x} exists; the data source is dropped. - Sandbox not torn down. The
docker compose down -vis skipped. The next test runs against a stale database. The fix is to always rundown -vin the script’s teardown.
How to troubleshoot it
The diagnostic order, designed to isolate which side of the test setup is broken.
- Is the container running?
docker compose -f docker-compose.test.yaml ps. A missing or crashed container is the dominant cause. - Is the health endpoint reachable?
curl -sf http://localhost:3001/api/health. A connection refused means the container is not listening on the port. - Is the data source loaded?
curl -sf http://localhost:3001/api/datasources. A missing source is a YAML error or a UID collision. - Is the dashboard loaded?
curl -sf "http://localhost:3001/api/search?query=test". A missing dashboard is a loader error or a schemaVersion mismatch. - Is the provisioned flag true?
curl -sf http://localhost:3001/api/dashboards/uid/\{uid\} | jq .meta.provisioned. Afalsevalue is a UI edit raced the loader, or the dashboard is not in the file. - Is the reload endpoint reachable?
curl -sf -X POST http://localhost:3001/api/admin/provisioning/dashboards /reload. A 401 or 403 means the anonymous role is notAdmin.
Security implications
- The sandbox uses anonymous admin. The test setup
configures
GF_AUTH_ANONYMOUS_ENABLED=trueandGF_AUTH_ANONYMOUS_ORG_ROLE=Admin. The container is exposed on localhost only. The discipline is to never expose the sandbox on a public interface. - The sandbox has the testdata plugin. The plugin is development-only. The discipline is to never deploy the testdata plugin in a production YAML.
- The provisioning directory is mounted read-only. The test container cannot write to the directory. The discipline is to keep the directory read-only on the container, even though the source is writable on the host.
- The validation script runs in CI. The CI runner has access to the source code. The discipline is to keep the validation script in version control and to never include production credentials in the script.
Performance implications
- The sandbox is a single container. The test runs in approximately 30 s of CI time. The cost is small.
- The
docker compose down -vreturns the host to working state. The cost is a few seconds of disk I/O. - The validation script is a series of
curlcalls. The cost is a few hundred milliseconds of network I/O. - The total cost of a test run is approximately 35 s. The benefit is the absence of “what changed in production” surprises.
Production guidance
- Run the test on every pull request that touches the provisioning directory. The test is the contract.
- Use the TestData plugin for synthetic data. The discipline is to never expose the production backends to the test.
- Mount the provisioning directory read-only on the container. The discipline is to keep the source on the host and the destination on the container separate.
- Tear down the sandbox at the end of the test. The discipline is to never leave a sandbox running.
- Pin the Grafana version in the test setup. The discipline is to test against the same version as production.
Verification
You should now be able to answer:
- What is the role of the TestData plugin in the test setup?
- Why is the test setup a disposable Grafana with the same provisioning directory as production?
- Which endpoint confirms the provisioned flag is true on a
dashboard, and what does a
falsevalue mean? - Why is the
disableDeletionsemantic relevant when the sandbox is torn down? - What is the right discipline for the anonymous role in the sandbox configuration?
Quiz
Knowledge check · 8 questions
Q1. What is the right data source for a Grafana provisioning test that must not depend on the production backends?
Q2. Which endpoint confirms the provisioned flag is true on a single dashboard?
Q3. A test that only validates the YAML syntax is sufficient for a provisioning change.
Q4. What is the right anonymous role for the sandbox Grafana in the test setup?
Q5. Which of the following are common failure modes in the provisioning test setup?
Q6. Name the docker compose command that is the right tear-down for a provisioning test.
Q7. Why is the test setup a disposable Grafana with the same provisioning directory as production?
Q8. What is the right response when the validation script returns a non-zero exit code on a pull request?
Passing score: 75%. Answers are checked in this browser.