How to Manage Grafana Dashboards as Code: Terraform, Git Sync (2026)
This guide is for anyone who has lost a Grafana dashboard to a well-meaning click, or who wants dashboard changes reviewed like any other change. By the end you will have dashboards generated from code, stored as JSON in Git, linted in CI, and delivered to Grafana by one of three mechanisms: file provisioning, the Terraform provider, or Git Sync. Versions used: Grafana 13.3.0, the Terraform provider v4.54.0 (validated locally with Terraform 1.12), the Grafana Foundation SDK for TypeScript v0.0.22 on Node 24, and dashboard-linter v0.5.2. You need a Grafana you can admin, a service account token, Git and Node or Terraform. The example dashboard is the RED row from my companion post, golden signals vs RED vs USE.
Step 1: Pick one delivery mechanism per folder
Every option below ends with the same JSON in Grafana. What differs is who owns it and whether people can still edit in the UI.
| Mechanism | Best for | UI edits | Permissions as code | Source |
|---|---|---|---|---|
| File provisioning | Self-hosted Grafana, Helm charts, immutable setups | Blocked unless allowUiUpdates: true |
No | Grafana provisioning docs |
| Terraform provider | Teams already running Terraform, Grafana Cloud, folders and teams | Blocked by default on new APIs | Yes, grafana_folder_permission |
terraform-provider-grafana |
| Git Sync | Teams who want to keep editing in the UI | Saved as commits or pull requests | Folder permissions set in the UI | Git Sync GA note |
| gcx CLI | Scripted push and pull, any resource type | Not restricted | No | grafana/gcx |
A note on CLIs, because the advice online is out of date. Grizzly is archived and marked for deprecation in favour of grafanactl, and grafanactl's own README now says it is deprecated in favour of gcx, currently v1.7.1. If a 2024 tutorial tells you grr apply, use gcx resources push instead.
The rule that saves the most pain: one owner per folder. If Terraform and Git Sync both write the same dashboard uid, the last writer wins and the other one shows drift forever.
Step 2: Lay out the repository
grafana-as-code/
├── package.json # Foundation SDK dependency
├── build.mts # generates dashboards/*.json
├── dashboards/ # generated JSON, committed
│ ├── checkout-red.json
│ └── .lint # dashboard-linter exclusions
├── provisioning/
│ └── dashboards/
│ └── checkout.yaml # Option A: file provisioning
├── terraform/
│ └── main.tf # Option B: Terraform
└── .github/workflows/
└── dashboards.yaml # CI checks
Commit the generated JSON as well as the generator. Reviewers can read a JSON diff, and every delivery mechanism wants JSON anyway.
Step 3: Generate dashboards with the Foundation SDK
The Foundation SDK provides typed builders for Go, TypeScript, Python, PHP and Java, and its README calls it "public preview", used in production by Grafana Labs but still moving. Pin the version.
npm init -y
npm install @grafana/grafana-foundation-sdk@0.0.22
// build.mts: run with Node 24 (`node build.mts`), which strips types natively
import { DashboardBuilder, RowBuilder, DatasourceVariableBuilder } from "@grafana/grafana-foundation-sdk/dashboard";
import { PanelBuilder as TimeseriesPanel } from "@grafana/grafana-foundation-sdk/timeseries";
import { DataqueryBuilder } from "@grafana/grafana-foundation-sdk/prometheus";
import { writeFileSync } from "node:fs";
// Panels read the data source from a dashboard variable, never a hard-coded uid
const ds = { type: "prometheus", uid: "${datasource}" };
const rate = new TimeseriesPanel()
.title("Requests per second")
.description("RED rate: requests per second by route")
.unit("reqps")
.datasource(ds)
.withTarget(new DataqueryBuilder()
.expr('sum by (http_route) (rate(http_server_request_duration_seconds_count{job="checkout"}[$__rate_interval]))')
.legendFormat("{{http_route}}"));
const p99 = new TimeseriesPanel()
.title("p99 latency")
.description("RED duration: 99th percentile request latency")
.unit("s")
.datasource(ds)
.withTarget(new DataqueryBuilder()
.expr('histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket{job="checkout"}[$__rate_interval])))')
.legendFormat("p99"));
const dash = new DashboardBuilder("Checkout service (RED)")
.uid("checkout-red") // stable uid: the key every tool matches on
.readonly() // sets editable: false
.tags(["generated", "red"])
.refresh("1m")
.time({ from: "now-6h", to: "now" })
.withVariable(new DatasourceVariableBuilder("datasource").label("Data source").type("prometheus"))
.withRow(new RowBuilder("Traffic and latency"))
.withPanel(rate)
.withPanel(p99);
writeFileSync("dashboards/checkout-red.json", JSON.stringify(dash.build(), null, 2));
console.log("wrote dashboards/checkout-red.json");
I ran this; the SDK lays out the grid for you:
$ mkdir -p dashboards && node build.mts
wrote dashboards/checkout-red.json
$ jq -c '{uid, schemaVersion, panels: [.panels[] | [.type, .gridPos.w, .gridPos.x]]}' dashboards/checkout-red.json
{"uid":"checkout-red","schemaVersion":42,"panels":[["row",24,0],["timeseries",12,0],["timeseries",12,12]]}
If your team already lives in Jsonnet, Grafonnet is generated from the same schemas. Its README labels it experimental and recommends go-jsonnet over the C implementation for speed. The old grafonnet-lib is deprecated.
// dashboard.jsonnet: jb install github.com/grafana/grafonnet/gen/grafonnet-latest@main
local g = import 'github.com/grafana/grafonnet/gen/grafonnet-latest/main.libsonnet';
local ts = g.panel.timeSeries;
g.dashboard.new('Checkout service (RED)')
+ g.dashboard.withUid('checkout-red')
+ g.dashboard.withPanels(
g.util.grid.makeGrid([
ts.new('Requests per second')
+ ts.standardOptions.withUnit('reqps')
+ ts.queryOptions.withTargets([
g.query.prometheus.new('$datasource', 'sum(rate(http_server_request_duration_seconds_count{job="checkout"}[$__rate_interval]))'),
]),
], panelWidth=12)
)
Render it with jsonnet -J vendor dashboard.jsonnet > dashboards/checkout-red.json.
Step 4 (Option A): Deliver with file provisioning
Provisioning reads YAML provider files from provisioning/dashboards under Grafana's provisioning path, which is /etc/grafana/provisioning in the official Docker image. Field names below are from the provisioning docs.
# provisioning/dashboards/checkout.yaml
apiVersion: 1
providers:
- name: checkout # unique per provider
orgId: 1
folder: Checkout # folder title in Grafana
folderUid: team-checkout # stable folder uid
type: file
disableDeletion: true # removing a file will not delete the dashboard
updateIntervalSeconds: 30 # above 10 means polling, safe on bind mounts
allowUiUpdates: false # UI saves are blocked; Git stays the source of truth
options:
path: /var/lib/grafana/dashboards/checkout
# compose.yaml: local test of the provisioning setup
services:
grafana:
image: grafana/grafana:13.3.0
ports: ["3000:3000"]
volumes:
- ./provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./dashboards:/var/lib/grafana/dashboards/checkout:ro
Three details from the docs that bite people. If updateIntervalSeconds is 10 or less, Grafana relies on filesystem watch events, which may not propagate through Docker bind mounts or network filesystems, so use a value above 10. To mirror your directory tree as folders, set options.foldersFromFilesStructure: true and leave folder and folderUid empty, or the provider refuses to load. And Dashboards v2 (dynamic dashboards) must be provisioned in the Kubernetes resource format, not the classic JSON shown here.
Check the result:
docker compose up -d
docker compose logs grafana | grep -i provisioning
curl -s -u admin:admin http://localhost:3000/api/dashboards/uid/checkout-red | jq '.meta.provisioned'
# Expected: true
The admin:admin login is only for this throwaway local container; use a service account token everywhere else.
Step 5 (Option B): Deliver with the Terraform provider
The provider configures with url and auth, where auth is a service account token. This configuration passes terraform init and terraform validate against provider v4.54.0:
# terraform/main.tf
terraform {
required_version = ">= 1.10"
required_providers {
grafana = {
source = "grafana/grafana"
version = "~> 4.54"
}
}
}
provider "grafana" {
url = var.grafana_url
auth = var.grafana_token # service account token, from CI secrets
}
variable "grafana_url" { type = string }
variable "grafana_token" {
type = string
sensitive = true
}
resource "grafana_folder" "checkout" {
uid = "team-checkout"
title = "Checkout"
}
resource "grafana_dashboard" "checkout_red" {
folder = grafana_folder.checkout.uid
config_json = file("${path.module}/../dashboards/checkout-red.json")
overwrite = true # take over a dashboard that already exists with this uid
message = "Managed by Terraform"
}
resource "grafana_team" "checkout" {
name = "checkout"
}
# Items omitted from this list are removed, so this is the full ACL for the folder
resource "grafana_folder_permission" "checkout" {
folder_uid = grafana_folder.checkout.uid
permissions {
role = "Viewer"
permission = "View"
}
permissions {
team_id = grafana_team.checkout.id
permission = "Edit"
}
}
cd terraform
terraform init
terraform validate
# Success! The configuration is valid.
terraform plan -var grafana_url=https://grafana.example.com -var grafana_token="$GRAFANA_TOKEN"
Two things changed in 2026. The grafana_dashboard docs now say that from Grafana v13 you should use the resource that matches your dashboard's API version for Kubernetes-style dashboards, such as grafana_apps_dashboard_dashboard_v1beta1 or grafana_apps_dashboard_dashboard_v2, which take metadata.uid, metadata.folder_uid and spec.json. Those resources block UI edits unless you set options.allow_ui_updates = true. The legacy grafana_dashboard still accepts classic JSON. Also, grafana_folder_permission is authoritative for the folder: anything you leave out gets removed. That is what you want, but it will surprise whoever granted access by hand last week. If you are importing existing dashboards rather than creating them, see importing resources into Terraform.
Step 6 (Option C): Connect Git Sync
Git Sync is the option for teams who will not give up the UI. It first shipped with Grafana 12 and became generally available for Grafana Cloud and self-managed users as of April 2026. People edit in Grafana, save, and Grafana commits or opens a pull request. Without webhooks, Grafana polls the repository, every 60 seconds by default.
On self-managed Grafana 13 the provisioning feature is on by default. The defaults file lists local, git and github repository types for OSS, with Bitbucket and GitLab in Enterprise. To restrict types or allow an internal Git host:
# custom.ini (Grafana 13.3), then restart Grafana
[provisioning]
repository_types = git|github|local
# repository URLs resolving to private addresses are rejected unless listed here
allowed_git_urls = ghe.example.com:8443
# default 10 repositories; 0 means unlimited
max_repositories = 10
For GitHub, the prerequisites page recommends a GitHub App installed only on the repositories you sync, with Contents, Pull requests and Webhooks set to read and write, and Administration and Metadata read-only. Then connect the repository from Grafana's provisioning settings and choose a folder as the sync target.
Know the limits before you commit to it. Git Sync handles dashboards and folders only; alerts, data sources and library panels are not supported yet. Grafana advises no more than 1,000 resources per repository connection. Grafana Cloud Free allows one repository with 20 resources. Git Sync cannot take ownership of a dashboard while an unmanaged one with the same uid exists, so delete or migrate the old copy first.
Step 7: Validate dashboards in CI
Nothing here needs a Grafana instance, so it runs on every pull request:
# .github/workflows/dashboards.yaml
name: dashboards
on:
pull_request:
paths: ["dashboards/**", "build.mts", "terraform/**"]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Regenerate and fail on drift
run: |
npm ci
node build.mts
git diff --exit-code dashboards/ # someone edited JSON by hand
- name: Unique uids and titles
run: |
dupes=$(jq -r '.uid' dashboards/*.json | sort | uniq -d)
test -z "$dupes" || { echo "duplicate uid: $dupes"; exit 1; }
jq -e 'select(.title == null or .title == "")' dashboards/*.json && exit 1 || true
- uses: grafana/dashboard-linter@v0.5.2
with:
files: dashboards
args: --strict
- uses: hashicorp/setup-terraform@v4
- name: Terraform checks
working-directory: terraform
run: |
terraform fmt -check
terraform init -backend=false
terraform validate
Pin the third-party actions to commit SHAs in a real repository; my GitHub Actions security post explains why. The linter is opinionated, and --strict turns its warnings into failures. When I first ran v0.5.2 against the generated dashboard it failed on four things: panels without descriptions, editable: true, no job and instance template variables, and queries without $job and instance matchers. The .description() and .readonly() calls in Step 3 fix the first two. The job and instance rules assume one shared dashboard filtered by variables, which is not how a generated per-service RED dashboard works, so I excluded them with reasons in a .lint file next to the JSON, following the linter docs:
# dashboards/.lint: read by dashboard-linter for every dashboard in this directory
exclusions:
template-job-rule:
reason: One generated dashboard per service; the job is pinned in build.mts.
target-job-rule:
reason: One generated dashboard per service; the job is pinned in build.mts.
template-instance-rule:
reason: RED panels aggregate across instances on purpose.
target-instance-rule:
reason: RED panels aggregate across instances on purpose.
$ dashboard-linter lint dashboards/checkout-red.json --strict; echo "exit=$?"
exit=0
To deploy, run terraform apply on merge, or gcx resources push -p ./resources --on-error abort with GRAFANA_SERVER and GRAFANA_TOKEN set, as the gcx README's CI example does.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "Cannot save provisioned dashboard" in the UI | Dashboard came from file provisioning with allowUiUpdates: false |
Edit the code and redeploy, or export JSON, change it in Git, and reprovision |
| File changed but Grafana shows the old version | updateIntervalSeconds 10 or less on a bind mount, so watch events never arrive |
Set it above 10 to force polling; check the mount path inside the container |
| "A dashboard with the same uid already exists" | Same uid in two files, or a hand-made copy already exists | Make uids unique (the CI check); for Terraform set overwrite = true once |
| "'folder' and 'folderUID' should be empty using 'foldersFromFilesStructure' option" | Both set on one provider | Remove folder and folderUid from that provider |
| "Dashboard title cannot be empty" | Generator emitted no title, or a v2 file in classic format | Set a title; provision v2 dashboards in the Kubernetes resource format |
| Terraform plan shows changes on every run | Someone edits in the UI, or two tools write the same uid | One owner per folder; block UI edits on Terraform-owned folders |
| Folder permissions vanished after apply | grafana_folder_permission is authoritative |
Declare every grant in Terraform, including ones added by hand |
| Git Sync will not take over an existing dashboard | Unmanaged dashboard with the same uid exists | Delete or migrate the old copy, then sync |
| Git Sync rejects an internal Git URL | Private addresses are blocked to prevent SSRF | Add the host to allowed_git_urls (in the source from Grafana 13.0.5 and 13.1.2 onwards) |
| "Dashboard refresh interval is too low" | Dashboard refresh below min_refresh_interval, 5s by default |
Use a longer refresh such as 1m in the generator |
The error strings above are taken from Grafana's dashboard error definitions and provisioning file reader at v13.3.0.
FAQ
Should I use Terraform or Git Sync for Grafana dashboards?
Use Terraform if dashboards are part of infrastructure you already manage with Terraform, especially with folders, teams and permissions in the same plan. Use Git Sync if people need to keep editing in the UI and you want those edits to arrive as pull requests. Do not point both at the same folder.
Is Grafonnet deprecated?
The old grafonnet-lib is deprecated. The newer Grafonnet, generated from the Foundation SDK schemas, is maintained but its README still calls it experimental. For new projects in a general-purpose language, the Foundation SDK is the safer choice.
What replaced Grizzly and grafanactl?
Grizzly is archived and pointed users to grafanactl, and grafanactl's README now says it is deprecated in favour of gcx. gcx fully supports Grafana 13 (Grafana 12 mostly works but only gets security patches) and does push, pull and validate for dashboards, folders and other resources.
Can provisioned dashboards be edited in the Grafana UI?
Only if the provider sets allowUiUpdates: true, and then the next file update overwrites the UI change regardless of the JSON version field. Treat UI edits on provisioned dashboards as drafts to export back into Git.
The first thing I check when a dashboard looks wrong is who owns it: provisioning, Terraform, Git Sync, or a human. Nine times out of ten the bug is two owners. In production I generate JSON from code, block UI saves on owned folders, keep a scratch folder where anyone can experiment, and promote good experiments by exporting them into the repository. For reference, see the Grafana provisioning docs, the Terraform provider docs, Git Sync and the Foundation SDK. If your Grafana runs on Kubernetes, start with kube-prometheus-stack.
Related: How to Set Up kube-prometheus-stack: Prometheus and Grafana (2026), How to Test Terraform: terraform test, TFLint and Checkov (2026) and Grafana vs Datadog vs New Relic Cost 2026: $1,945 to $5,600 a Month.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Write a Blameless Postmortem: Template and Examples (2026)
A step-by-step guide to writing a blameless postmortem: when one is required, building the timeline from evidence, contributing factors instead of one root cause, why the 5 Whys misleads, owned action items, the review meeting, and a copy-paste markdown template.
15 minOct 20, 2026How to Run Chaos Experiments on Kubernetes with Chaos Mesh (2026)
Step-by-step Chaos Mesh 2.8.5 guide: install with Helm on containerd, opt namespaces in, write PodChaos, NetworkChaos and StressChaos YAML, gate experiments on a Prometheus SLO, schedule them, and run the same test with a LitmusChaos promProbe, plus a troubleshooting table.
16 minOct 20, 2026How to Fix Kubernetes CrashLoopBackOff: Causes and Commands (2026)
Fix Kubernetes CrashLoopBackOff step by step: what the state means, the 10s to 5 minute backoff and the new per-node max in 1.35+, kubectl describe and logs --previous, exit codes 1, 127, 137 and 143, probe and OOM causes, and kubectl debug for crashing containers.
13 min