Stop Hand-Copying Dashboards: Automating Kibana Deployments with the Saved Objects API
Manual dashboard copying between Kibana environments causes drift and toil. Here's how to automate deployments with the Saved Objects API, including the ID-reference gotcha and a worked export/import example.
25 Jan 2026, 19:10 UTC

If your team runs Kibana in dev, test, and prod, you've probably done the dashboard shuffle: build it in dev, screenshot it, then rebuild it by hand in prod — or export an NDJSON file and click through the import UI every release. It works, but it's slow, and the drift between environments creeps in silently. Someone tweaks a visualization in prod "just for now" and three sprints later nobody knows which version is canonical.
The fix is to treat dashboards like code: store them in Git, and push them with Kibana's Saved Objects API instead of the UI. This post walks through that workflow, including the one detail that bites everyone — saved object ID references.
What the Saved Objects API gives you
Every dashboard, visualization, lens panel, and data view (formerly index pattern) in Kibana is a "saved object" — a JSON document with a type, an ID, and attributes. Kibana exposes a REST API under /api/saved_objects/ that lets you create, read, update, export, and import these objects programmatically.
That means a deployment pipeline can do what your mouse used to do: pull a dashboard definition from one environment, transform it if needed, and upsert it into another. Because the payload is plain JSON, it diffs cleanly in pull requests and rolls back with git revert.
The ID problem you have to solve first
A dashboard doesn't embed its visualizations — it references them by saved object ID, and those visualizations in turn reference a data view by ID. If you let Kibana auto-generate IDs, each environment invents different ones, and a naive import produces a dashboard full of broken references.
The standard fix is to pin stable IDs. When you create or import objects, set the id field explicitly (for example, dv-logs-prod for the data view) and use the same IDs in every environment. The import API's overwrite=true parameter then updates in place rather than duplicating. Alternatively, the export endpoint can rewrite references for you, but explicit IDs are easier to reason about in version control.
A worked example: export, transform, import
Assume Kibana 8.x, an API key created under Stack Management → API Keys, and a dashboard with the pinned ID dash-service-overview. Run these from any machine with network access to both Kibana instances; the API key needs the kibana_admin-style privileges (or a custom role with saved objects read/write).
First, export the dashboard and its dependencies from dev:
curl -X POST "https://kibana-dev.example.com/api/saved_objects/_export" \
-H "Authorization: ApiKey $DEV_API_KEY" \
-H "kbn-xsrf: true" \
-H "Content-Type: application/json" \
-d '{
"objects": [{ "type": "dashboard", "id": "dash-service-overview" }],
"includeReferencesDeep": true
}' -o dashboard.ndjsonThe kbn-xsrf header is mandatory on any mutating Kibana request — without it you get a 400. includeReferencesDeep pulls in the visualizations and data views the dashboard depends on, so the file is self-contained.
Next, edit the NDJSON if the environments differ — for example, point the data view at a different index pattern (logs-prod-* instead of logs-dev-*). Then import to prod:
curl -X POST "https://kibana-prod.example.com/api/saved_objects/_import?overwrite=true" \
-H "Authorization: ApiKey $PROD_API_KEY" \
-H "kbn-xsrf: true" \
--form file=@dashboard.ndjsonThe response is a JSON summary with success, successCount, and an errors array. Check it programmatically — a 200 doesn't guarantee every object imported. A typical failure is a missing reference: the target space lacks a data view the dashboard expects.
Verifying the result
Don't stop at the import response. Fetch the dashboard back and confirm it references what you expect:
curl -X GET "https://kibana-prod.example.com/api/saved_objects/dashboard/dash-service-overview" \
-H "Authorization: ApiKey $PROD_API_KEY"Then open the dashboard in the prod UI with real data. API-level success plus a rendered dashboard is the actual definition of done — a dashboard can import cleanly and still show empty panels if the underlying indices or field names differ between environments.
Trade-offs worth knowing
This approach has real limits. The API automates objects, not data: if prod's index mappings differ from dev's, your visualizations may silently render empty. Spaces complicate things too — object IDs are scoped per space, so multi-space setups need the /s/{space_id}/api/... path prefix and per-space keys. Rate limiting and version drift matter as well: an NDJSON exported from 8.11 may not import cleanly into an older instance, so keep your environments on compatible versions or test imports in staging first. Finally, the API won't stop someone from editing prod dashboards by hand; pair automation with restricted write roles if you want Git to stay the source of truth.
Where to start
Pick one low-risk dashboard, pin its IDs, export it, and check the NDJSON into a repo. Wire the import call into whatever CI tool you already use, run it against a test space, and verify with the read-back GET above. Once that loop is boring, expand it to the rest of your dashboards — and enjoy never rebuilding a panel by hand again.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.