How to Manage Grafana Dashboards as Code: Terraform, Git Sync (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

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.

Dashboards as code: one pipeline, three ways to deliver 1. Generate Foundation SDK or Grafonnet 2. Commit JSON to Git one file per dashboard, unique uid 3. CI checks linter, uid check, tf validate File provisioning YAML provider, read-only in UI Terraform provider 4.54 folders, permissions, plan/apply Git Sync (GA in Grafana 13) UI edits become pull requests Grafana 13.3 Pick one owner per folder. Two writers (UI, Terraform, Git Sync, provisioning) on the same uid will fight.
The workflow this guide builds. Versions are the releases current on 10 November 2026; Git Sync's GA status is from Grafana's April 2026 announcement.

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