Dashboards as Code
The Problem With Clicking
A dashboard built in the UI lives in Grafana’s SQLite database. That means no review, no history beyond Grafana’s own version list, no way to apply the same change to staging and production, and a restore-from-backup as the only disaster recovery. The dashboard that matters most during an incident is the one nobody can reproduce.
Provisioning moves all of it into files: datasources, dashboards, alert rules, contact points, notification policies. Grafana reads them on startup and on a timer, and what is in the files wins.
The honest tradeoff is that provisioned objects are read-only in the UI. People who built dashboards by dragging panels now have to edit JSON, which is genuinely worse for exploration. The workable pattern is to build in the UI, export, and commit, keeping the UI as the editor and the files as the source of truth.
File Provisioning
Grafana reads /etc/grafana/provisioning/ with one subdirectory per object type.
provisioning/
datasources/datasources.yaml
dashboards/dashboards.yaml # the provider, pointing at a directory
alerting/rules.yaml
alerting/contactpoints.yaml
alerting/policies.yaml
dashboards/ # the dashboard JSON itself
api-overview.json
node-health.json
Datasources
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus # PIN THIS
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
timeInterval: 15s # must match the scrape interval
httpMethod: POST
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
jsonData:
maxLines: 1000
derivedFields:
- name: TraceID
matcherRegex: 'trace_id=(\w+)'
url: '${__value.raw}'
datasourceUid: tempo
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: -5m
spanEndTimeShift: 5m
filterByTraceID: true
tracesToProfiles:
datasourceUid: pyroscope
profileTypeId: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds'
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
- name: Pyroscope
uid: pyroscope
type: grafana-pyroscope-datasource
access: proxy
url: http://pyroscope:4040
deleteDatasources:
- name: OldInflux
orgId: 1Pinning uid is the whole game. Dashboard JSON references datasources by UID, so a pinned UID makes every dashboard portable between Grafana instances. Without it, Grafana assigns a random UID per install and an imported dashboard shows “datasource not found”. Use short stable strings: prometheus, loki, tempo.
timeInterval should match the Prometheus scrape interval. It is what $__rate_interval is computed from, so getting it wrong reintroduces the empty-rate() problem that $__rate_interval exists to prevent.
The cross-signal configuration from the Grafana page lives here, which is the strongest practical argument for provisioning: the correlations are fiddly to set up by hand and trivial to copy as YAML.
Dashboards
The provider tells Grafana where to find dashboard JSON.
apiVersion: 1
providers:
- name: 'files'
orgId: 1
folder: 'Provisioned'
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false # true lets the UI save over them until restart
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: truefoldersFromFilesStructure: true maps subdirectories to Grafana folders, which keeps a large set organised without listing every folder.
Dashboard JSON needs two edits after export. Strip the id field, which is instance-specific and causes collisions, and replace hard-coded datasource UIDs with a template variable if the dashboard should be portable across environments.
# Export every dashboard from a running Grafana, cleaned
for uid in $(curl -sH "Authorization: Bearer $GRAFANA_TOKEN" \
"$GRAFANA_URL/api/search?type=dash-db" | jq -r '.[].uid'); do
curl -sH "Authorization: Bearer $GRAFANA_TOKEN" \
"$GRAFANA_URL/api/dashboards/uid/$uid" \
| jq '.dashboard | .id = null' > "dashboards/$uid.json"
doneAlert Rules
Grafana’s unified alerting provisions the same way, which is what makes it comparable to file-based Prometheus rules.
apiVersion: 1
groups:
- orgId: 1
name: api
folder: Alerts
interval: 1m
rules:
- uid: api-error-ratio
title: API error ratio above 5 percent
condition: threshold
data:
- refId: query
datasourceUid: prometheus
model:
expr: |
sum(rate(http_requests_total{job="api",status=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="api"}[5m]))
instant: true
- refId: threshold
datasourceUid: __expr__
model:
type: threshold
expression: query
conditions:
- evaluator: {type: gt, params: [0.05]}
for: 10m
labels:
severity: page
annotations:
summary: 'API error ratio is {{ $values.query }}'
noDataState: NoData
execErrState: ErrornoDataState and execErrState are the fields people forget. noDataState: NoData fires a distinct no-data alert, which is usually right: a query returning nothing because the service vanished should not look like a healthy zero. Setting it to OK is how an alert silently stops protecting anything.
The Terraform Provider
Provisioning files are good for one Grafana. Terraform manages many, and manages the things files cannot: folders with permissions, service accounts, teams, organisations, and cloud stacks.
terraform {
required_providers {
grafana = {
source = "grafana/grafana"
version = "~> 3.0"
}
}
}
provider "grafana" {
url = "http://192.168.2.205:3000"
auth = var.grafana_service_account_token
}
resource "grafana_folder" "observability" {
title = "Observability"
}
resource "grafana_data_source" "prometheus" {
type = "prometheus"
name = "Prometheus"
uid = "prometheus"
url = "http://prometheus:9090"
is_default = true
json_data_encoded = jsonencode({
timeInterval = "15s"
httpMethod = "POST"
})
}
resource "grafana_dashboard" "api" {
folder = grafana_folder.observability.uid
config_json = file("${path.module}/dashboards/api-overview.json")
}
resource "grafana_rule_group" "api" {
name = "api"
folder_uid = grafana_folder.observability.uid
interval_seconds = 60
rule {
name = "API error ratio above 5 percent"
condition = "threshold"
for = "10m"
labels = { severity = "page" }
# data blocks as above
}
}
This is the same Terraform covered in the Terraform page, and the same workflow: init, plan, apply. The advantage over files is that plan shows exactly what will change before it changes, and state tracks what exists, so a deleted resource is actually removed rather than orphaned.
Do not mix the two mechanisms on one object. A dashboard provisioned from a file and also managed by Terraform will fight, with each apply and each Grafana restart overwriting the other. Pick one per object type. A common split is datasources and alerting by file, because they ship with the compose stack, and dashboards by Terraform, because they change more often and benefit from a plan.
The token is a credential. Use a service-account token with the minimum role, keep it in a variable, and keep it out of the repository. See the placeholder convention in Grafana.
Grizzly
grr is a purpose-built CLI for Grafana objects, and it sits between hand-managed files and full Terraform.
grr get Dashboard.api-overview > dashboards/api-overview.yaml # pull one down
grr diff dashboards/ # what would change
grr apply dashboards/ # push it up
grr watch dashboards/ api-overview.yaml # live reload while editing
grr serve dashboards/ # preview without touching a servergrr watch is the feature that makes editing dashboard source bearable: save the file, see it in the browser immediately. It largely removes the argument that code-managed dashboards kill the feedback loop.
Grizzly also understands Jsonnet and Grafonnet, which is how large organisations generate hundreds of near-identical dashboards from a template rather than copying JSON. That is real power and a real learning curve; for a handful of dashboards, plain JSON plus grr is enough.
Choosing An Approach
| Situation | Use |
|---|---|
| One Grafana, ships with the compose stack | File provisioning |
| Several environments, want a plan before applying | Terraform provider |
| Editing dashboards often, want a fast loop | Grizzly, with files or Terraform underneath |
| Hundreds of generated dashboards | Jsonnet or Grafonnet via Grizzly |
| A dashboard somebody is still designing | The UI. Export and commit when it settles |
For a home lab, file provisioning committed next to the compose file covers everything, and the whole Grafana becomes reproducible from the repository.
What To Version
Everything that would have to be rebuilt after losing grafana.db:
- Datasources, with pinned UIDs and the cross-signal links.
- Dashboard JSON.
- Alert rules, contact points, notification policies, mute timings.
- Folder structure and permissions.
- Prometheus and Loki recording and alerting rules, which are already files.
- The compose file or Ansible role that ties it together.
What stays in the database and is therefore worth a backup anyway: users, API tokens, annotations, alert state history, and anything created ad hoc in the UI.
The test is simple: delete the Grafana volume and bring it back up. If the dashboards return and the alerts resume, the setup is genuinely reproducible. If it takes an afternoon of clicking, it was not.
Where Next
- Grafana for what is being provisioned.
- Alerting for the rules themselves.
- Terraform for the provider’s workflow.