Dashboards live as JSON files in dashboards/ and stay in sync
with a running Grafana in both directions, using Grafana 12+'s new
provisioning system (the engine behind Git Sync, pointed at a mounted local
path instead of GitHub — no remote or webhook needed):
- File → Grafana: the repository connection pulls the directory on a
~60 second cycle, so edits to the JSON (e.g. after a
git pull) appear in Grafana. - Grafana → file: pressing Save in the UI writes the dashboard straight back to its JSON file — no sidecar, no export script. The save dialog even shows which file it's writing to.
docker compose up -dOpen http://localhost:3000 — no login needed (anonymous access with an Admin
role is enabled for local development; remove the three GF_AUTH_* lines in
docker-compose.yml to restore the login page, admin / admin). Edit the
Example Dashboard, press Save. The file updates immediately:
git diff dashboards/
git add dashboards/ && git commit -m "Update dashboard"The repository appears in Grafana as a Git dashboards folder. To create a new dashboard that's managed by the repository, save it into that folder: the save dialog switches to the provisioned flow (it asks for a filename) and writes the JSON file alongside the others. Dashboards saved anywhere else go to Grafana's database only — that's how you keep scratch dashboards out of git. To delete a managed dashboard, delete its JSON file; Grafana removes it on the next sync.
The stack includes a .NET 10 minimal Web API (app/) instrumented with
OpenTelemetry, displayed by the community dashboard
OpenTelemetry dotnet webapi (20568)
(dashboards/otel-dotnet-webapi.json):
- Metrics are exposed on
http://localhost:8080/metricsby the OTel Prometheus exporter and scraped by Prometheus (http://localhost:9090). ASP.NET Core, Kestrel, HTTP client, runtime, and process instrumentation cover every panel in the dashboard. - Logs are pushed over OTLP to Loki (http://localhost:3100), which feeds the dashboard's Logs row.
- A
trafficcontainer hits the API (including a 404 and a failing endpoint) every couple of seconds so the panels have data.
Three deliberate compatibility choices, worth knowing before upgrading anything:
OpenTelemetry.Instrumentation.Runtimeis pinned to 1.9.0: from 1.10 the package uses the runtime's built-inSystem.Runtimemeter on .NET 9+, which renames every metric to the newdotnet_*semconv names — but this dashboard (including theservice_namevariable that all panels depend on) queries the oldprocess_runtime_dotnet_*names, which 1.9.0 emits on any runtime version. If you upgrade the pin, switch to a dashboard built for the new names, e.g. the ASP.NET Core OTel dashboards for .NET 9+.- Prometheus runs with
metric_name_validation_scheme: legacy(prometheus/prometheus.yml): Prometheus 3 otherwise negotiates UTF-8 names with the exporter and stores dotted names (http.server.request.duration) the dashboard can't find. - The dashboard filters everything by
service_name/service_version/deployment_environment/host_namelabels. In OTLP-collector setups those come from resource attributes; with a direct scrape they're attached as static labels in the Prometheus scrape config — keep them in step with the resource attributes inapp/Program.cs.
docker-compose.yml— Grafana 13 with:GF_FEATURE_TOGGLES_ENABLE=provisioning,kubernetesDashboards— the new provisioning system (default-on in v13+, needed explicitly on v12.x);GF_PATHS_PERMITTED_PROVISIONING_PATHS=git-dashboards/— allowlists the directory (relative to Grafana's working dir/usr/share/grafana, where./dashboardsis mounted);- a one-shot
provisioning-setupcontainer that registers the directory as a repository connection via the provisioning API.
setup/create-repository.sh— the bootstrap call. It does exactly what Administration → Provisioning → Configure file provisioning does in the UI; use the UI instead if you prefer clicking through it once.dashboards/example.json— the dashboard, in Grafana's resource format (apiVersion: dashboard.grafana.app/v2, content underspec:). Classic dashboard JSON is accepted on first import and converted to this format on first save from the UI.
You can watch sync state under Administration → Provisioning, or force a pull instead of waiting for the interval:
curl -u admin:admin -H 'Content-Type: application/json' \
-X POST http://localhost:3000/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/git-dashboards/jobs \
-d '{"action": "pull", "pull": {}}'- The provisioning system is experimental (Grafana 12/13): the
provisioning.grafana.app/v0alpha1API used by the bootstrap script may change between releases. The UI setup path is the stable-ish alternative. - File → Grafana sync runs on a ~60s cycle for local paths (UI saves hit the file instantly). If both sides change in the same window, the repository (file) side wins on the next pull.
- The sync target is
folder: everything indashboards/lives under the "Git dashboards" folder, and new dashboards saved into that folder become repository-managed. The alternativefolderlesstarget imports files at the instance root instead, but new UI dashboards then default to plain database saves — there's no folder to opt them into the repository. - Not available on Grafana Cloud (it has Git Sync to GitHub instead).
- The example uses admin/admin basic auth for bootstrap — swap in a service-account token for anything beyond local use.