Topic 31

Dashboards as Code

As Code

Every Grafana dashboard is one JSON document — panels, queries, variables, links, thresholds, all of it. Left in the UI, that document becomes the hand-tuned dashboard everyone fears touching: one author who left the company, no history, no review, and no way back after a bad edit on a Friday afternoon.

This topic moves the checkout dashboard into git and provisions it from files, turning dashboard changes into pull requests — the same discipline Harborline's Prometheus rules and Compose files already follow. Prometheus config earned code review in Chapter 3; the dashboards responders stare at during incidents deserve no less.

The Hand-Tuned Dashboard Problem

A UI-edited dashboard is production configuration that escaped version control. When a panel breaks, there is no diff to read, no author to ask, and no revert — just the current state and everyone's fading memory of what it looked like last month. The fear of touching it is rational, and the fix is structural, not behavioral: no policy about careful editing survives contact with a 2 a.m. "quick fix". Put the document in git and the fear evaporates, because any change can be read, reviewed, and undone.

The JSON Model

Export any dashboard — the toolbar's Export icon in the UI, or the HTTP API — and read the document. The checkout dashboard's own JSON is legible top to bottom: a uid identifying it, a title, a panels array where each entry holds its queries, unit, thresholds, and grid position, and a templating section holding the $service variable and its label_values query.

Once the JSON is legible, the UI's status changes: it is no longer where the dashboard lives, just one editor for a document that lives elsewhere. That inversion is the entire topic.

File-Based Provisioning

Dashboard provisioning mirrors the data source provisioning from the start of this chapter: a provider file tells Grafana to load every JSON dashboard from a directory and re-read it on an interval. Harborline mounts its git repo's dashboard directory into the container behind this provider.

# /etc/grafana/provisioning/dashboards/harborline.yaml
apiVersion: 1
providers:
  - name: harborline
    type: file
    updateIntervalSeconds: 30
    options:
      path: /var/lib/grafana/dashboards

The provider declares a name, the file type, a 30-second re-read interval, and the directory to watch. With this in place the git repo — not the Grafana database — is where dashboards live: a rebuilt obs-01 container comes up with every dashboard intact, and a merged pull request appears on screen within half a minute of deployment.

The Edit Loop and Review via PR

Provisioned dashboards refuse UI saves by default, which forces the honest loop: edit in the UI, export the JSON, commit, open a pull request. A threshold change from 500 ms to 300 ms arrives as a two-line diff a reviewer approves in 30 seconds, and a bad change is one git revert away. The loop feels slower than clicking Save exactly once — the first time a revert saves an incident, the accounting flips for good.

Dashboards as code — a reviewed diff becomes what Grafana shows
JSON in gitsource of truth
PR reviewtwo-line diff
Provisioningre-read every 30s
Grafanarebuild-safe

Parameterize vs Duplicate

The JSON makes a design decision explicit that the UI lets you dodge: one $service-templated dashboard covers all five services as long as their layout is genuinely identical, and duplication is justified only when pages truly diverge. Harborline's generic service view is one templated file; the checkout dashboard is its own file, because its RED-plus-USE composition with a business-symptom top row fits no generic template.

Generators, Briefly

When raw JSON diffs stop being readable — grid-position churn, 2,000-line files, ten teams editing — teams stop writing the JSON and start generating it: Grafonnet (Jsonnet), grafanalib (Python), the Grafana CLI for syncing, or the Grafana Terraform provider alongside the rest of the infrastructure. The output is the same JSON model, produced by code instead of by hand. For Harborline's six dashboards this is machinery without a problem to solve; know the names, reach for them when the diffs demand it.

Parameterize vs Duplicate

Template variable — one dashboard serves many values of one dimension ($service across five services): one file to review, zero drift. The constraint is that every service gets the identical layout. Default here.

Separate dashboard — a distinct file with its own composition. Right when the page genuinely differs, like the checkout business view versus the generic service view; wasteful when the only difference is a label value. Duplicate only for a real layout divergence.

Common Mistakes
  • Editing a provisioned dashboard in the UI and assuming it saved — provisioned dashboards are read-only from the UI by default, and enabling allowUiUpdates is worse: the next provisioning reload silently overwrites the UI edit, and the change is simply gone.
  • Committing exported JSON with an environment-specific data source uid baked in — the dashboard renders in prod and errors on every panel in staging. Keep data source uids identical across environments via provisioning, or reference them through a variable.
  • Leaving the uid field null in committed JSON — Grafana assigns a random uid on import, and every alert, drill-down link, and bookmark pointing at the dashboard breaks on the next re-import.
  • Copy-pasting a dashboard file per service instead of templating — five near-identical 1,500-line files that drift independently, so the panel fixed in bookings.json stays broken in the other four.
  • Reviewing a PR where the UI reshuffled every panel's grid position — a one-threshold change buried in 400 lines of coordinate churn gets rubber-stamped. Keep edits minimal per export, or move to a generator once this becomes routine.
Best Practices
  • Provision every production dashboard from JSON files in git, and treat the Grafana database as a cache — a rebuilt container must come up with every dashboard already in place.
  • Pin an explicit, human-readable uid in every committed dashboard so links, alerts, and provisioning re-imports all stay stable.
  • Route every dashboard change through the edit-in-UI → export → commit → PR loop, so thresholds and queries get the same review as application code.
  • Reach for a generator (Grafonnet, grafanalib) only when raw JSON diffs stop being reviewable — for Harborline's six dashboards, plain JSON in git is the right weight.
Comparable toolsCNCF Perses — dashboards-as-code from first principles, no UI-first legacyGenerators Grafonnet, grafanalib, Grafana CLI — code that emits Grafana's JSON modelIaC Terraform provider — dashboards managed beside the infrastructureDatadog API + Terraform resources reach the same git-reviewed state

Knowledge Check

After the checkout dashboard is provisioned from files, where does the authoritative copy live?

  • In Grafana's SQLite database, as before
  • In the JSON file in the git repository
  • In Prometheus, alongside the metrics the panels query
  • On the laptop of whoever exported it last

A committed dashboard JSON has "uid": null. What happens on the next import, and what breaks?

  • Grafana refuses to import the file until a uid is set
  • The panels import but their queries are stripped out
  • A random uid is assigned, and every link and alert pointing at the old one breaks
  • The import silently overwrites whichever existing dashboard previously had a null uid

Harborline keeps one templated service dashboard but a separate file for checkout. What justifies the duplicate?

  • Checkout handles more traffic than the other services
  • Its layout genuinely differs from the generic service view
  • Templated dashboards cannot mix Prometheus and Loki panels
  • The $service variable stops working once a dashboard is provisioned

What does the export → commit → PR loop buy that careful UI editing cannot?

  • A reviewable diff and a guaranteed way back
  • Faster dashboard changes than clicking Save
  • Automatic validation that every PromQL query is correct
  • Dashboards that load faster because they come from files

You got correct