Manages a shared Azure portal dashboard β a JSON tile layout whose display name is a tag and whose tiles hard-code absolute resource IDs (
azurerm_portal_dashboard). Targetshashicorp/azurerm ~> 4.0.
- π A shared portal dashboard β a JSON tile layout anyone with Reader on its resource group can open.
β οΈ The display name is a TAG. The portal readshidden-title; there is no first-class argument. This module accepts a normaldisplay_nameand sets the tag for you.- π΄ The JSON embeds absolute ARM resource IDs, subscription included β so a dashboard copied between subscriptions silently points at the original's resources.
- β
The JSON is validated for shape, not just for being JSON: a body with no
lensesis rejected, which catches exporting an ARM template instead of the dashboard. β οΈ Markdown tiles are constrained by the tenant configuration, a separate singleton resource.- β
dashboard_propertiesandtagsupdate in place; the three identity fields are force-new.
π‘ Why it matters: Two things make dashboards deceptively awkward: the title users read is metadata rather than an argument, and the JSON is a generated artifact full of absolute resource IDs. Both are invisible until a dashboard shows a GUID or somebody else's data.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub.
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
dash["terraform-azurerm-portal-dashboard"]
cfg["terraform-azurerm-portal-tenant-configuration"]
tenant["THE WHOLE ENTRA ID TENANT. The tenant configuration is a SINGLETON named default, its Resource ID contains no subscription, and changing it affects every dashboard in every subscription at once."]
users["portal users, who see shared dashboards and whose Markdown tiles the tenant configuration constrains"]
monitor["terraform-azurerm-log-analytics-workspace and application-insights, whose charts a dashboard tile usually points at"]
rg -->|"name, location"| dash
monitor -->|"resource ids embedded in the dashboard JSON"| dash
dash -->|"visible to"| users
cfg -->|"scoped to"| tenant
tenant -->|"constrains Markdown tiles in"| dash
cfg -->|"constrains what"| users
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class dash,cfg mine;
class tenant keystone;
class rg,users,monitor sib;
flowchart TB
ids["name, resource_group_name, location: all force-new"]
guid["and note what the portal does with name: a dashboard created in the portal gets a GUID for a name, which is why the provider's own import example shows one. Creating it here lets you use a readable name instead."]
title["THE DISPLAY NAME IS A TAG. A tag with the key hidden-title sets the user-friendly title, so the thing an analyst actually reads is metadata rather than a first-class argument - and forgetting it leaves them looking at a resource name."]
json["dashboard_properties: the dashboard body as a JSON string. Required, and THE ONLY UPDATABLE FIELD besides tags."]
jsonsrc["it is normally exported from the portal rather than hand-written, so keep it in a file and read it with file() - an inline heredoc of dashboard JSON is unreadable in a diff"]
embedded["AND IT EMBEDS RESOURCE IDS. The tiles point at workspaces, metrics and resources by full ARM id, so a dashboard copied between subscriptions silently references the ORIGINAL subscription's resources."]
markdown["Markdown tiles inside that JSON are also constrained by the TENANT CONFIGURATION, a separate singleton resource: with private markdown storage enforced, only external URIs are allowed and inline content is prohibited."]
this["azurerm_portal_dashboard.this"]
outputs["id, name, display_name, location, dashboard_properties, tile_count_hint, has_display_name"]
ids -->|"identity"| guid
guid -->|"documented"| this
title -->|"quirk"| this
json -->|"sourcing"| jsonsrc
jsonsrc -->|"practice"| this
json -->|"caution"| embedded
embedded -->|"documented"| this
markdown -->|"cross-resource"| this
this -->|"exports"| outputs
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this mine;
class title,embedded keystone;
class ids,guid,json,jsonsrc,markdown,outputs sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_portal_dashboard.this |
1 | The keystone. Only the JSON and tags update in place. |
locals.dashboard_tags |
β | Merges display_name into the hidden-title tag. |
timeouts block |
0..1 | All four operations exist. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Azure resource provider | Microsoft.Portal β a preview API version |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite β confirmed against the live provider schema and its documentation:
β οΈ The display name is thehidden-titletag. No first-class argument exists.- π΄
dashboard_propertiesembeds absolute ARM resource IDs, so the JSON is not portable between subscriptions. - βΉοΈ A portal-created dashboard has a GUID for a name, which is why the provider's import example shows one.
β οΈ Markdown tiles are constrained by the tenant-wide configuration, a separate singleton.name,resource_group_nameandlocationare force-new.- The API version is a preview one.
lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy.
| Operation | Role | Scope |
|---|---|---|
| Creating, updating or deleting the dashboard | Contributor | the resource group |
| Viewing the dashboard | Reader | the resource group |
| Rendering each tile's data | whatever role that tile's target requires | each referenced resource |
β οΈ Three scopes, and the third surprises people. Reader on this resource group lets a user open the dashboard and read the resource IDs in its tiles β but each tile renders only if that user can read its target. A dashboard is not a way to grant visibility into resources somebody cannot otherwise see; it shows them blank tiles (example 6).
- The resource group exists.
- A dashboard JSON body, normally exported from the portal rather than hand-written (example 2).
- The resources the tiles reference exist, in the subscription the JSON names β which is not necessarily the one you are deploying into (example 3).
terraform-azurerm-portal-dashboard/
βββ providers.tf # required_version + the pinned azurerm provider. No provider block.
βββ variables.tf # name, resource_group_name, location, dashboard_properties,
# # display_name, tags, timeouts
βββ main.tf # the keystone `this` + a `locals` block merging `display_name` into the `hidden-title` tag
βββ outputs.tf # id first, then the resource's fields, then the derived review flags
βββ README.md # this document
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "dash" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-portal-dashboard.git?ref=v1.0.0"
name = "platform-health"
resource_group_name = module.rg.name
location = module.rg.location
# β βΉ The title users see. Implemented as the `hidden-title` tag β see example 4.
display_name = "Platform health"
# Exported from the portal, not hand-written. See example 2.
dashboard_properties = file("${path.module}/dashboards/platform-health.json")
tags = { owner = "platform" }
}βΉοΈ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
β οΈ Read example 4 before wondering why your dashboard shows a resource name β the title is a tag, not an argument.
Consumes
| Input | Type | Source module |
|---|---|---|
name |
string |
caller β force-new |
resource_group_name / location |
string |
terraform-azurerm-resource-group |
dashboard_properties |
string |
a portal export, via file() or templatefile() |
display_name |
string |
caller β becomes the hidden-title tag |
tags |
map(string) |
caller β must not also contain hidden-title |
timeouts |
object(...) |
caller β all four operations exist |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The Resource ID. | review, imports |
name |
The ARM name. Force-new. | review |
display_name / has_display_name |
The title users see. | usability review |
resource_group_name / location |
Identity. Force-new. | review |
dashboard_properties |
The JSON body. Not sensitive, deliberately. | review |
tile_count_hint |
Tiles in the first lens β a hint. | review |
embeds_absolute_resource_ids |
Always true. |
portability review |
No secret is accepted and none is emitted β and nothing sensitive belongs in the JSON (example 7).
1 Β· What a shared dashboard is
A shared dashboard is an ARM RESOURCE holding a JSON tile layout.
- it lives in a resource group, so Azure RBAC decides who can open it
- its tiles reference other resources by absolute ID
- it renders per-viewer: a tile only shows data the VIEWER can read
βΉοΈ "Shared" distinguishes it from a private dashboard, which lives in a user's own profile and is not an ARM resource at all. Only shared dashboards are manageable here.
π‘ Which makes Terraform genuinely useful for them: a dashboard defined in code is reviewable, versioned, and reproducible across environments β unlike one somebody built by dragging tiles and never documented.
β οΈ But the JSON is a generated artifact, not something to author by hand (example 2), and it is full of absolute resource IDs (example 3). Those two facts shape everything else in this document.
2 Β· Export the JSON; do not write it
dashboard_properties = file("${path.module}/dashboards/platform-health.json")π‘ Build the dashboard interactively in the portal, use Export to file, and commit the result. The format is generated: nested
lenses, numberedparts, position objects and per-tile input arrays. It is readable enough to review and thoroughly unpleasant to write.
β Keep it in its own file. An inline heredoc of dashboard JSON makes every diff unreadable and hides the one thing worth reviewing β which resources the tiles point at.
β The module validates more than "is it JSON". A body with no top-level
lensesobject is rejected:
Error: Invalid value for variable
dashboard_properties must carry a `lenses` object AT THE TOP LEVEL. The provider
unmarshals this string directly into its DashboardProperties type and requires lenses
to be present there, so a body shaped {"properties": {"lenses": ...}} is refused -
that is the ARM-template wrapper around a dashboard rather than the dashboard body
itself. Export the dashboard from the portal and pass the value of its `properties`
field, not the whole template.
π‘ That second check exists because "valid JSON" is a weak test here. The common mistake is exporting an ARM template β perfectly valid JSON, wrapping the dashboard body one or two levels down β and that mistake is detectable.
β οΈ The check requireslensesat the TOP LEVEL, and that is the provider's rule rather than a preference.validate.DashboardPropertiesunmarshals this string straight into the SDK'sDashboardPropertiestype and errors with\`lenses\` is required in JSON payloadwhen the field is absent β verified by driving a{"properties": {"lenses": {}}}literal throughterraform validate. An earlier revision of this module accepted that wrapped shape and said so in its own error message, which let the exact mistake this check exists to catch through to be refused one layer later, by a message that does not mention wrapping.
βΉοΈ
tile_count_hintis emitted so an obviously empty dashboard shows up in a plan. It reads only the first lens, so treat it as a hint rather than a total.
3 Β· π΄ The JSON hard-codes absolute resource IDs
"inputs": [{
"name": "ComponentId",
"value": { "SubscriptionId": "00000000-...", "ResourceGroup": "rg-prod", "Name": "law-prod" }
}]π΄ Tiles reference their targets by absolute ARM ID, subscription included. So a dashboard JSON copied from another subscription keeps pointing at the ORIGINAL subscription's resources β it applies without error, renders without error, and shows the wrong data or none at all.
β οΈ Nothing catches this. The JSON is valid, the resource is created, the plan is clean. The only symptom is a dashboard that looks broken to whoever opens it.
β So template the JSON rather than copying it:
dashboard_properties = templatefile("${path.module}/dashboards/health.json.tftpl", {
workspace_id = module.law.id
app_insights_id = module.appi.id
})π‘ Export once, replace the IDs with placeholders, and let Terraform substitute them. That turns a per-environment copy-paste problem into one file that works everywhere β and it makes the dependency real, so Terraform orders the workspace before the dashboard.
βΉοΈ The module emits
embeds_absolute_resource_idsas a constanttrue, because this is the fact that makes dashboard JSON non-portable and it is invisible in state.
4 Β· β οΈ The display name is a tag
display_name = "Platform health" # this module sets the `hidden-title` tag for you
β οΈ The portal reads the dashboard's title from a tag with the keyhidden-title. There is no first-class argument for it β one of the odder corners of this provider. The thing an analyst reads is metadata.
π΄ Omit it and the portal shows the ARM
nameβ which, for a dashboard originally created in the portal, is typically a GUID. A dashboard nobody can identify is a dashboard nobody uses.
β So this module exposes a normal
display_nameand merges it into the tag. That is a deliberate small divergence from the provider: mirroring it exactly would mean every caller rediscovering the quirk.
β And setting both is rejected, so the title has exactly one source:
display_name = "Platform health"
tags = { "hidden-title" = "Something else" } # βError: Invalid value for variable
Set either display_name or a `hidden-title` tag, not both β they are the same field
and two sources would make the dashboard's title ambiguous. Prefer display_name.
βΉοΈ The tag route is still available if you prefer it β leave
display_nameunset and puthidden-titleintags.has_display_nameis emitted either way, so a review can spot a dashboard that will show a machine name.
5 Β· β οΈ Markdown tiles answer to a tenant-wide setting
A Markdown tile in this dashboard's JSON can hold:
inline content -> prohibited if the TENANT CONFIGURATION enforces private storage
an external URI -> always allowed
β οΈ That setting is a different resource, and it is tenant-wide β the siblingterraform-azurerm-portal-tenant-configurationmodule, a singleton whose Resource ID contains no subscription. Withprivate_markdown_storage_enforced = true, inline Markdown tiles stop rendering their content across every dashboard in the tenant.
π΄ So a dashboard's JSON can be correct and its tiles still not behave as written. This module cannot see that setting, and nothing in a plan reconciles the two.
π‘ Which is a reason to prefer external Markdown anyway. Content in a storage account is auditable and access-controlled; inline content is whatever the last editor typed, displayed inside the portal with the portal's implicit credibility.
βΉοΈ If your tenant enforces private Markdown storage, treat inline Markdown in dashboard JSON as a defect to be fixed at export time rather than something to work around here.
6 Β· Who can see it, and what they actually see
Reader on the RESOURCE GROUP -> can open the dashboard, and read the resource IDs in it
Reader on each TILE'S TARGET -> is what makes that tile render data
β οΈ A dashboard does not grant visibility. Someone with Reader here but no access to the referenced workspace sees the dashboard, sees the tile, and sees no data in it. Tiles render per-viewer.
π‘ Which is usually the behaviour you want β it means sharing a dashboard cannot leak data β but it does mean a dashboard can look broken to one person and fine to another, and that is a support call worth anticipating.
π΄ What Reader on the resource group DOES expose is the JSON itself, including every resource ID, workspace name and query in it. That is a modest information disclosure: the structure of your estate, if not its data.
β So put dashboards in a resource group whose Reader list you are comfortable with, and keep anything sensitive out of the JSON β including in Markdown tile text (example 7).
7 Β· π Why the JSON is not marked sensitive
output "body" { value = module.dash.dashboard_properties } # not sensitive, deliberatelyπ This module does not mark
dashboard_propertiessensitive, and the reasoning is worth stating. It is a layout definition readable by anyone with Reader on the resource group (example 6) β so redacting it in plan output would hide it from review while protecting nothing.
β οΈ And it would break the review that matters most: checking which resource IDs the tiles point at (example 3) requires reading the JSON in a diff.
π΄ The corollary is the actual rule: do not put anything sensitive in dashboard JSON. Not a token in a query string, not a connection detail in a Markdown tile, not a comment naming an unannounced system. It is readable by a wide audience and it is stored in Terraform state in plaintext.
π‘ KQL queries embedded in tiles deserve a second look on this point. A query is code, and code sometimes carries context β an internal hostname, a customer identifier, an account number β that you would not choose to publish to everyone with Reader.
8 Β· Several dashboards, one per audience
locals {
dashboards = {
platform = { title = "Platform health", file = "platform-health.json" }
security = { title = "Security overview", file = "security.json" }
}
}
module "dash" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-portal-dashboard.git?ref=v1.0.0"
for_each = local.dashboards
name = each.key
resource_group_name = module.rg.name
location = module.rg.location
display_name = each.value.title
dashboard_properties = templatefile("${path.module}/dashboards/${each.value.file}.tftpl", {
workspace_id = module.law.id
})
}π‘ One dashboard per audience beats one dashboard with everything. Tiles render per-viewer (example 6), so a combined dashboard shows different people different amounts of blank space β and a focused dashboard is the one people actually keep open.
β οΈ Consider the resource group per audience too. Reader there exposes the JSON, so a security dashboard in a resource group the whole organisation can read is a small information-disclosure decision (example 7).
β
templatefileper dashboard keeps the resource IDs correct across environments (example 3).
βΉοΈ
for_eachlives in the caller; the module manages one dashboard.
9 Β· What a review should assert
output "dash_review" {
value = {
id = module.dash.id
title = module.dash.display_name # β οΈ null means users see the ARM name
titled = module.dash.has_display_name # assert true
tiles = module.dash.tile_count_hint # 0 is worth a look
portable = module.dash.embeds_absolute_resource_ids # always true
}
}
β οΈ titledis the assertion worth encoding.falsemeans the portal shows the ARM resource name, and for an imported dashboard that is a GUID (example 4).
β οΈ tilesat0usually means the wrong JSON was supplied β an ARM template wrapper, or an empty export. It is a hint and not a count (example 2).
βΉοΈ
portableis a constanttrue. Read it as "and this JSON names a specific subscription's resources" (example 3).
π‘ What no output can tell you is whether the tiles point at resources that exist, or whether any of them render. That needs the portal.
10 Β· Importing an existing dashboard
terraform import 'module.dash.azurerm_portal_dashboard.this' \
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-observability/providers/Microsoft.Portal/dashboards/00000000-0000-0000-0000-000000000000"βΉοΈ Note the GUID as the dashboard name. A dashboard created in the portal gets one, which is why the provider's own import example shows it β and why the imported dashboard will keep it, since
nameis force-new.
π‘ So importing a portal-built dashboard is usually the wrong move. Export its JSON, create a new dashboard through this module with a readable name, and delete the original. You end up with the same tiles and a name a human can use.
β οΈ If you do import, restatedashboard_propertiesfrom the export, or the first apply overwrites the dashboard with whatever your configuration says β an in-place change, so easy to miss in a plan.
β οΈ And checkhidden-title. An imported dashboard usually has one already; supplyingdisplay_nameas well is rejected (example 4), so pick one source and restate it.
11 Β· What a destroy removes, and the offline gate
terraform destroy on this module:
removes the dashboard -> the tiles' TARGET RESOURCES are untouched
-> no data, workspace or metric is affected
-> anyone with it open sees it disappear
β This is a safe destroy. A dashboard is a view; deleting it deletes no data and breaks no system. The worst outcome is somebody's bookmark stops working.
π‘ Which makes dashboards a good place to iterate. Unlike most resources in this library, getting one wrong and rebuilding it costs nothing.
The offline gate
terraform init -backend=false
terraform validate
terraform fmt -checkβ What it proves: the JSON parses, it has a
lensesobject, the title has exactly one source, and the identity fields are non-empty.
π‘ Proved by evaluating the conditions in
terraform consoleinside the module, which does fire root-module variable validations β unliketerraform validateon a calling configuration. A body of{"properties":{"template":{}}}fires thelensescheck, and supplyingdisplay_namealongside ahidden-titletag fires the both-sources check.
β οΈ What it cannot prove: that the referenced resources exist, that they are in this subscription, or that any tile renders. No cloud apply happens in this flow.
12 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-observability"
location = "eastus"
}
module "law" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
name = "law-platform"
resource_group_name = module.rg.name
location = module.rg.location
sku = "PerGB2018"
retention_in_days = 90
}
module "appi" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-application-insights.git?ref=v1.0.0"
name = "appi-platform"
resource_group_name = module.rg.name
location = module.rg.location
workspace_id = module.law.id
}
# ββ The dashboard: TEMPLATED, so the resource IDs are right (example 3) ββββ
module "dash" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-portal-dashboard.git?ref=v1.0.0"
name = "platform-health"
resource_group_name = module.rg.name
location = module.rg.location
# The title users read. A tag under the hood (example 4).
display_name = "Platform health"
# Placeholders substituted from the real modules, so this file works in any
# subscription AND Terraform orders the workspace first (example 3).
dashboard_properties = templatefile("${path.module}/dashboards/platform-health.json.tftpl", {
workspace_id = module.law.id
app_insights_id = module.appi.id
})
tags = { owner = "platform" }
}
# ββ The tenant-wide setting that constrains Markdown tiles (example 5) βββββ
# β οΈ A SINGLETON, and tenant-scoped β only ONE configuration in the tenant should
# own it, and it needs tenant-level permissions. Usually a separate root module.
# module "portal_config" {
# source = "git::https://github.com/microsoftexpert/terraform-azurerm-portal-tenant-configuration.git?ref=v1.0.0"
# private_markdown_storage_enforced = true
# }
output "dashboard_posture" {
value = {
title = module.dash.display_name
titled = module.dash.has_display_name # assert true
tiles = module.dash.tile_count_hint
}
}π What the composition gets right:
templatefilesubstituting real resource IDs so the JSON is portable and the dependency is real, adisplay_nameso users see a title rather than a resource name, the dashboard in an observability resource group whose Reader list is a deliberate choice, and the tenant configuration shown commented out β because it is a tenant-scoped singleton that does not belong in a subscription-level root module.
β οΈ What no plan will tell you: whether the tiles render, whether the referenced resources are in this subscription, or whether the tenant enforces private Markdown storage.
π‘
titledis the assertion to encode. Everything else here is a portal check.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
β | Required. Force-new. |
resource_group_name / location |
string |
β | Required. Force-new. RBAC here governs who can see it. |
dashboard_properties |
string |
β | Required. JSON and lenses-shape validated. Updates in place. |
display_name |
string |
null |
Becomes the hidden-title tag. β
Setting both is rejected. |
tags |
map(string) |
{} |
Real Azure tags. hidden-title is special. |
timeouts |
object(...) |
null |
All four operations exist. |
Full schemas
variable "dashboard_properties" {
type = string
# "Valid JSON" is a weak check for this field, because the common mistake β exporting an ARM TEMPLATE that
# wraps the dashboard β is also valid JSON. The second check looks for the dashboard's own shape.
# See example 2.
validation {
condition = can(jsondecode(var.dashboard_properties))
error_message = "dashboard_properties must be valid JSON. Export the dashboard from the portal ..."
}
validation {
condition = can(jsondecode(var.dashboard_properties).lenses)
error_message = "dashboard_properties must carry a `lenses` object AT THE TOP LEVEL ..."
}
}
variable "tags" {
type = map(string)
default = {}
# The title must have exactly ONE source. The check lives here, referencing display_name
# one-directionally β two variables validating each other is rejected as a cycle. See example 4.
validation {
condition = var.display_name == null || !contains(keys(var.tags), "hidden-title")
error_message = "Set either display_name or a `hidden-title` tag, not both β they are the same field ..."
}
}| Output | Description | Sensitive |
|---|---|---|
id |
The Resource ID. | no |
name |
The ARM name. Force-new. | no |
display_name / has_display_name |
The title users see, from the tag. | no |
resource_group_name / location |
Identity. Force-new. | no |
dashboard_properties |
The JSON body. Deliberately not sensitive β example 7. | no |
tile_count_hint |
Tiles in the first lens β a hint, not a count. | no |
embeds_absolute_resource_ids |
Always true. |
no |
-
display_nameis exposed as a normal variable and merged into thehidden-titletag by the module. Mirroring the provider exactly would mean every caller rediscovering that the title is metadata β this is the rare case where a small divergence serves the caller better. The tag route stays available, and setting both is rejected so the title has exactly one source. -
dashboard_propertiesgets a second validation looking forlenses. "Valid JSON" is a weak test here: the common mistake is exporting an ARM template that wraps the dashboard, which is also valid JSON. That mistake is detectable, so it is caught. -
tile_count_hintis named as a hint and its limitation stated in its own description. It reads only the first lens, so it is useful for spotting an empty dashboard and useless as a total β which is better said than implied by a number that looks authoritative. -
embeds_absolute_resource_idsis a constanttrue. It is the fact that makes copied dashboard JSON fail silently β valid, applied, and pointing at another subscription β and it is invisible in state. -
dashboard_propertiesis deliberately not marked sensitive, and the reasoning is given. It is readable by anyone with Reader on the resource group, so redaction would hide it from review while protecting nothing; the honest control is not putting anything sensitive in it, including in KQL queries and Markdown text. -
The per-viewer rendering behaviour is documented as a support consideration, not just a security one. A dashboard that looks broken to one person and fine to another is a predictable support call, and knowing why is cheaper than diagnosing it.
-
The tenant configuration's influence on Markdown tiles is documented from this side too, because a dashboard's JSON can be correct while its tiles do not behave as written β and nothing in a plan reconciles the two resources.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Unidentifiable dashboards | display_name wrapped over the tag quirk; has_display_name emitted |
omit a title, knowingly |
| Ambiguous titles | setting both display_name and hidden-title rejected |
β |
| Wrong-subscription JSON | embeds_absolute_resource_ids emitted; templatefile recommended |
copy JSON verbatim, knowingly |
| Wrong-artifact exports | a lenses-shape check beyond "is it JSON" |
β |
| Overstated outputs | tile_count_hint named a hint, with its limit stated |
β |
| Information disclosure | the JSON documented as Reader-visible; nothing sensitive belongs in it | β |
| False secrecy | the JSON deliberately not marked sensitive, with the reason | β |
- Before the first apply: set
display_name, or users see a resource name. - Before reusing JSON: it names a specific subscription's resources. Template it.
- Before committing JSON: nothing sensitive belongs in it, including inside KQL queries.
- Before choosing a resource group: Reader there can read the whole dashboard body.
- Before using inline Markdown: the tenant configuration may prohibit it.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag β
?ref=v1.0.0β never a branch. - Plan-only from here. A human applies from CI.
- β The JSON and tags update in place, so iterating on a dashboard is cheap.
- β A destroy affects no data β dashboards are views. This is a safe resource to rebuild.
β οΈ name,resource_group_nameandlocationare force-new.β οΈ Restatedashboard_propertiesafter an import, or the first apply overwrites the dashboard.- π‘ Prefer recreating a portal-built dashboard over importing its GUID name.
terraform validate and terraform fmt -check are the offline gate. Neither needs credentials.
The provider's own ValidateFuncs fire at terraform validate where values are literals:
nameis at most 160 characters and matches^[-\w]+$β letters, digits, hyphens and underscores, the last of which the provider's own error message wrongly denies;dashboard_propertiesis valid JSON and carries alensesobject at the top level.
This module's own validation {} blocks fire at plan when the module is called from a
configuration β not at validate:
namemirrors the provider's regex and cap, so the failure names the variable;resource_group_nameis not a Resource ID, andlocationis unpadded;dashboard_propertiesis not a whole ARM template β detected by a leading$schemakey;display_name, when set, is not blank;display_nameand ahidden-titletag are not both supplied;- every
timeoutsvalue, when set, is a Go duration; - the tag map is within Azure's 50-tag, 512-character-key, 256-character-value limits.
And: no output is sensitive β including the JSON body, deliberately β and the module declares no
provider block.
π‘ These were proved by evaluating the conditions in
terraform consoleinside the module β which does fire root-module variable validations, unliketerraform validateon a calling configuration. A body of{"properties":{"template":{}}}fires thelensescheck; a body of"not json at all"fires the JSON check; anddisplay_namealongside ahidden-titletag fires the both-sources check. A valid body withdisplay_nameset produced{"hidden-title" = "Platform health"}, confirming the merge.
What only plan and apply exercise:
- whether the resource group exists;
- whether the caller may create a dashboard in it.
What no Terraform command checks at any stage:
- π΄ whether the resources the tiles reference exist, or are in this subscription;
- π΄ whether any tile renders β that depends on the viewer's access to each target;
- whether the tenant enforces private Markdown storage, which changes how Markdown tiles behave;
- whether the JSON contains anything you would not want a Reader to see.
Outputs:
dashboard_properties = jsonencode({ ... })
display_name = "Platform health"
embeds_absolute_resource_ids = true
has_display_name = true
id = "/subscriptions/00000000-.../providers/Microsoft.Portal/dashboards/platform-health"
location = "eastus"
name = "platform-health"
resource_group_name = "rg-observability"
tile_count_hint = 6
β
has_display_name = truewith a readablenameβ the recommended shape. Compare an imported portal dashboard, whosenamewould be a GUID (examples 4 and 10).
β
tile_count_hint = 6suggests a real dashboard rather than an ARM template wrapper (example 2).
π΄
embeds_absolute_resource_idsis a constant. Read it as "and those IDs name one specific subscription" (example 3).
βΉοΈ The JSON appears in full and is not redacted, deliberately β which is also why nothing sensitive belongs in it (example 7).
| Symptom | Cause | Fix |
|---|---|---|
Plan rejects dashboard_properties as not JSON |
It is not valid JSON. | Export from the portal (example 2). |
must carry a `lenses` object AT THE TOP LEVEL |
An ARM template wrapper was exported, or the body was nested under properties. |
Pass the value of the dashboard resource's own properties field, not the whole template (example 2). |
`lenses` is required in JSON payload |
The same mistake, raised by the PROVIDER at validate rather than by this module - note it does not mention wrapping. |
As above. |
| Plan rejects the title | Both display_name and a hidden-title tag were set. |
Pick one (example 4). |
| The portal shows a GUID or a resource name | No hidden-title tag. |
Set display_name (example 4). |
| Tiles show no data for some users | Tiles render per-viewer. | They need access to each target (example 6). |
| Tiles show the wrong subscription's data | The JSON hard-codes absolute IDs. | Use templatefile (example 3). |
| Markdown tiles show nothing | The tenant enforces private Markdown storage. | Use external URIs (example 5). |
tile_count_hint is 0 |
Wrong JSON, or tiles are in a later lens. | It reads the first lens only (example 2). |
| An import overwrote the dashboard | dashboard_properties was not restated. |
Restate it first (example 10). |
Wanted prevent_destroy |
lifecycle is not valid inside a module block. |
Not available. |
azurerm_portal_dashboardβ provider documentation.- Share Azure dashboards β the RBAC model of example 6.
- Programmatically create dashboards β the JSON structure and the
hidden-titletag of examples 2 and 4. - Sibling modules:
terraform-azurerm-resource-group,terraform-azurerm-portal-tenant-configuration,terraform-azurerm-log-analytics-workspace,terraform-azurerm-application-insights. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."