Manages a Grafana Cloud stack together with the service accounts — and their static tokens — that operate against it, as one composite unit. Targets
grafana/grafana(~> 4.0).
- ☁️ Creates and manages a
grafana_cloud_stack— name, requiredslug, optional region, description, custom URL, labels, delete protection, and readiness-wait behavior. - 🔐 Renders zero or more
grafana_cloud_stack_service_accountresources,for_each-keyed by a caller-chosen stable service account name. - 🔑 Renders exactly one
grafana_cloud_stack_service_account_tokenper service account entry — a stack with service accounts but no way to authenticate against them is an incomplete bootstrap, so the token is a required nested field on eachservice_accountsentry, not a separate, independently-optional collection. - 🆔 Emits
slugas the primary output — the required, caller-facing identity real sibling resources key on (grafana_cloud_stack_service_account.stack_slug), not the computed numericid. - 🌐 Surfaces the genuinely useful computed per-product endpoint URLs (Prometheus, Logs, Alertmanager, Graphite, OTLP, Traces, Profiles, Fleet Management, OnCall, Synthetic Monitoring, Connections, Cloud Provider Observability) as outputs — never as inputs, since all of them are read-only on the live schema.
💡 Why it matters: a newly created Grafana Cloud stack has no useful machine-identity access until at least one service account and its token exist. Managing the stack and its initial service accounts as one composite unit means a caller provisioning a new stack also owns the first-class access needed to configure it, rather than leaving a bare stack pending a separate manual bootstrap step that is easy to forget in a regulated environment.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart TD
accesspolicy["terraform-grafana-cloud-access-policy\n(sibling, supplies auth token)"]:::sibling
keystone["grafana_cloud_stack.this"]:::this
svcacct["grafana_cloud_stack_service_account\n(for_each)"]:::this
token["grafana_cloud_stack_service_account_token\n(for_each)"]:::this
frontendobs["terraform-grafana-frontend-observability-app\n(stack_id type pending confirmation)"]:::sibling
content["Stack-scoped content modules\n(terraform-grafana-folder, -dashboard, -team,...)"]:::sibling
accesspolicy -- "cloud_access_policy_token (root provider alias auth)" --> keystone
keystone -- "slug (stack_slug)" --> svcacct
svcacct -- "id (service_account_id)" --> token
keystone -- "slug (stack-scoped grafana provider alias url)" --> content
keystone -- "slug (candidate stack_id reference)" --> frontendobs
classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
classDef sibling fill:#E8E8E8,color:#111217,stroke:#8A8A8A;
Validated via the Mermaid Chart MCP (valid: true) before embedding. terraform-grafana-cloud-access-policy
owns and rotates the cloud_access_policy_token this module's own provider alias authenticates with — it
is a wholly separate auth mechanism from the auth/Service Account token used once the stack exists. This
module's slug output is the identity a caller wires into a second, stack-scoped grafana provider alias
for downstream content modules (terraform-grafana-folder, terraform-grafana-dashboard, terraform-grafana-team,
etc.), and is the candidate reference for terraform-grafana-frontend-observability-app's stack_id-shaped
input, pending that module's own confirmation of its expected identity type.
flowchart LR
subgraph inputs["Inputs"]
varstack["var.stack\n(object)"]
varsvcaccts["var.service_accounts\n(map(object), default {})"]
end
keystone["grafana_cloud_stack.this\n(keystone)"]:::this
svcacct["grafana_cloud_stack_service_account.this\n(for_each, keyed map)"]:::this
token["grafana_cloud_stack_service_account_token.this\n(for_each, keyed map)"]:::this
varstack -- "name, slug, region_slug, description, url, labels, delete_protection, wait_for_readiness*" --> keystone
varsvcaccts -- "role, is_disabled (per key)" --> svcacct
varsvcaccts -- "token.name, token.seconds_to_live (per key)" --> token
keystone -- "slug (stack_slug)" --> svcacct
svcacct -- "id (service_account_id)" --> token
keystone --> outslug["slug\n(primary_output)"]
keystone --> outid["id"]
keystone --> outendpoints["per-product endpoint outputs\n(prometheus_url, logs_url, alertmanager_url,...)"]
svcacct --> outsvcids["service_account_ids\n(map)"]
svcacct --> outsvcmeta["service_accounts\n(map, metadata only)"]
token --> outtokmeta["service_account_tokens\n(map, metadata only)"]
token --> outtokkeys["service_account_token_keys\n(map, sensitive)"]
classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
Validated via the Mermaid Chart MCP (valid: true) before embedding.
Resource inventory:
| Resource | Cardinality | Role |
|---|---|---|
grafana_cloud_stack.this |
1 (keystone) | The stack itself: name, required slug, optional region, description, custom URL, labels, delete protection, readiness-wait behavior |
grafana_cloud_stack_service_account.this |
0..N (for_each, keyed by var.service_accounts map key) |
One resource per service account name — role, disabled flag |
grafana_cloud_stack_service_account_token.this |
0..N (for_each, same key set as above) |
One static token per service account — name, optional lifetime |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
grafana/grafana provider |
~> 4.0 (confirmed latest at authoring time: 4.40.1, installed and locked during this session's terraform init) |
| Provider block | None — the caller configures provider "grafana" {... } (with the cloud_access_policy_token mechanism this module requires) at the root module and passes it in via providers = { grafana =... } |
Schema notes that bite:
slugis the practical cross-reference identity, notid. Confirmed against the live provider schema:slugis a required string field on the live schema ("Subdomain that the Grafana instance will be available at"), and sibling resources (grafana_cloud_stack_service_account.stack_slug,grafana_cloud_stack_service_account_token.stack_slug) key on it directly.idis a computed numeric string — diagnostic/legacy reference only. This module'sprimary_outputisslug.- Changing
region_slugdestroys and recreates the stack. The live schema's own field description states this in prose ("Changing region will destroy the existing stack and create a new one in the desired region") without a separate "Forces new resource" annotation in the rendered docs — treat it as force-new in practice regardless of how the docs page tags it. sluguniqueness is enforced platform-side, not by Terraform state. A naming collision surfaces as an apply-time Cloud API error, not aplan-time Terraform error.labelshas a hard cap and a regex constraint enforced by the live schema: at most 10 entries, and every key and value must match^[a-zA-Z0-9/\-._]+$. This module enforces both constraints invariables.tfviavalidation {}blocks so a malformed label set fails atplan, not at the Cloud API.delete_protectiondefaults totrueon the live schema — this module'svar.stack.delete_protectionmirrors that default exactly. Per this module suite's Human judgment guidance, destroying agrafana_cloud_stackis a genuinely destructive, billing-relevant, org-level action; leave this enabled unless a human has explicitly decided the stack is disposable.grafana_cloud_stackexposes dozens of computed-only per-product attributes (alertmanager, graphite, logs, otlp, pdc, profiles, prometheus, traces, fleet_management — URLs, statuses, and AWS-PrivateLink-only connectivity info) — confirmed against the live provider schema. These are read-only; never model any of them as a module input. This module surfaces the operationally useful subset as outputs (see §14) and deliberately omits the AWS-PrivateLink-only connectivity attributes and per-product status/name fields to avoid dozens of rarely-used passthroughs.grafana_cloud_stack_service_account_rotating_tokenis a real, live-schema alternative to the staticgrafana_cloud_stack_service_account_tokenresource this module renders — confirmed against the live provider schema: it addsname_prefix,early_rotation_window_seconds,delete_on_destroy, and a computedready_for_rotationattribute for provider-managed rotation. Not adopted in v1 — this module keeps a static, caller-rotated token to keep the secret-lifecycle story simple and auditable (explicit re-apply to rotate, versus a provider-managed rotation schedule this module does not yet model). A caller needing provider-managed rotation must fork or extend this module deliberately.grafana_cloud_stack_service_account_token.seconds_to_livehas no module-level default. Per the live schema's own documented behavior, a null, zero, or omitted value produces a token that never expires. This module does not inject its own default — omission is an explicit risk acceptance the caller must choose knowingly (see §16 Design Principles).
Grafana core auth (per this module suite's Authentication model convention → Grafana core → Cloud access policy token). Requires
a cloud_access_policy_token scoped to Cloud org/stack-management capabilities — stack create/read/
update/delete plus the service-account-scoped capabilities needed to create and manage stack service
accounts and their tokens (the live schema's own docs list stacks:read, stacks:write, stacks:delete,
and stack-service-accounts:write as the required access policy scopes across the three resources this
module renders). This is a wholly separate auth mechanism from the base Grafana core auth argument used
once the stack exists — a provider instance configured only with auth cannot create, modify, or destroy
the stack resource itself. Configured once by the caller on whichever aliased grafana provider instance is
passed into this module via providers = { grafana =... }; this module declares no alias or credential of
its own.
- The target Grafana Cloud organization must already exist and be reachable via the caller's
cloud_access_policy_token. region_slugmust be one of the org's enabled Grafana Cloud regions — confirm the current enum via the Grafana Cloud "list regions" API or the live provider schema at call time; this module does not hardcode a region enum intovariables.tfsince the valid set is org-specific and can change.- No feature-flag gating is currently known for
grafana_cloud_stack,grafana_cloud_stack_service_account, orgrafana_cloud_stack_service_account_tokenthemselves — re-verify per this module suite's Provider-migration convention at the start of each authoring session.
terraform-grafana-cloud-stack/
├── providers.tf # required_providers: grafana/grafana ~> 4.0, required_version >= 1.12.0. No provider {} block.
├── variables.tf # var.stack (keystone object) + var.service_accounts (for_each-keyed map, nested token)
├── main.tf # grafana_cloud_stack.this (keystone) + grafana_cloud_stack_service_account.this (for_each) + grafana_cloud_stack_service_account_token.this (for_each)
├── outputs.tf # slug (primary), id, computed metadata, per-product endpoints, service_account_ids/service_accounts/service_account_tokens/service_account_token_keys
├── README.md # this file
├── SCOPE.md # cross-module contract, auth/scope, prerequisites, provider gotchas
└── examples/ # runnable example(s)
module "primary_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-primary"
slug = "caseyprimary"
}
}The caller configures the grafana provider (aliased for cloud_access_policy_token auth) once at the
root module — this module never accepts its own url, cloud_access_policy_token, or org_id default.
Consumes
| Input | Type | Source module |
|---|---|---|
none — stack definition fields (name, slug, region_slug,...) and the service_accounts collection are first-class, caller-supplied inputs to this module; nothing is consumed by reference from a sibling module |
— | — |
Emits
| Output | Description | Consumed by |
|---|---|---|
slug (primary) |
The stack's required, caller-facing slug — the stable cross-reference identity real sibling resources key on | Any module/root config targeting this stack via a stack-scoped grafana provider alias; terraform-grafana-frontend-observability-app pending confirmation of its stack_id field's expected identity type |
id |
Computed numeric stack ID — diagnostic/legacy reference only | — |
service_account_ids |
map(string), service account name → id |
Any module granting additional stack-scoped permissions to these service accounts |
service_accounts |
map(object), metadata only (id, role, is_disabled) |
Inventory/drift-detection |
service_account_tokens |
map(object), metadata only (id, name, expiration, has_expired) |
Automation/inventory tracking token lifecycle without seeing the secret |
service_account_token_keys |
map(string), sensitive = true |
Caller's secrets manager — must be moved out of Terraform output consumption immediately per this module suite's Secure-by-default convention |
prometheus_url, prometheus_remote_write_endpoint, prometheus_remote_endpoint, prometheus_user_id |
Computed Prometheus endpoints/identity | Root config wiring a Prometheus data source or remote_write target |
logs_url, logs_user_id |
Computed Loki endpoint/identity | Root config wiring a Loki data source or push target |
alertmanager_url, graphite_url, otlp_url, traces_url, profiles_url, fleet_management_url, oncall_api_url, sm_url, connections_api_url, cloud_provider_url |
Computed per-product base URLs | Root config / Alloy or agent configs wiring the corresponding product |
org_id, org_slug, org_name, status, cluster_slug, cluster_name |
Computed stack/org metadata | Informational |
1 · Minimal stack, no service accounts
module "sandbox_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-sandbox"
slug = "caseysandbox"
}
}💡 The empty-call default:
delete_protection = true,wait_for_readiness = true(wait_for_readiness_timeout = "10m0s"), no labels, no custom URL, no service accounts — a bare stack with the platform's own safe defaults intact.
2 · Stack with an explicit region
module "eu_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-eu"
slug = "caseyeu"
region_slug = "eu"
}
}
⚠️ Changingregion_slugon a later apply destroys and recreates the stack — confirm the target region before the first apply, and confirm the current valid region enum via the Grafana Cloud "list regions" API (see §8 Grafana Prerequisites) rather than assuming"eu"is universally valid for every org.
3 · Stack with labels
module "labeled_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-reporting"
slug = "caseyreporting"
labels = {
"cost-center" = "reporting"
"environment" = "prod"
}
}
}ℹ️ At most 10 labels, and every key/value must match
^[a-zA-Z0-9/\-._]+$— enforced by this module'svalidation {}blocks atplantime, not left to fail at the Cloud API.
4 · ⚠️ Stack with delete protection explicitly disabled
module "disposable_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-ephemeral-test"
slug = "caseyephemeraltest"
delete_protection = false
}
}
⚠️ Per this module suite's Human judgment guidance, destroying agrafana_cloud_stackis a genuinely destructive, billing-relevant, org-level action. Only disabledelete_protectionfor a stack a human has explicitly designated as disposable (e.g. a short-lived test stack) — never as a default posture.
5 · Stack with a custom URL (CNAME)
module "custom_url_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-branded"
slug = "caseybranded"
url = "https://grafana.financialpartners.com"
}
}🔒 Requires a CNAME already pointed at
<slug>.grafana.netbefore the stack is created — the live schema does not create the CNAME on the caller's behalf.
6 · Single least-privilege service account + token
module "monitored_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-monitored"
slug = "caseymonitored"
}
service_accounts = {
"ci-readonly" = {
role = "Viewer"
token = {
name = "ci-readonly-token"
seconds_to_live = 2592000 # 30 days
}
}
}
}💡 Per this module suite's secure-by-default convention,
"Viewer"is the narrowest role this module can render — prefer it whenever the account's automation can operate read-only.seconds_to_liveis set explicitly here to bound the token's lifetime rather than leaving it unset.
7 · Multiple service accounts at scale via caller-side locals
locals {
automation_accounts = {
"ci-deploy" = "Editor"
"ci-readonly" = "Viewer"
"oncall-admin" = "Admin"
}
}
module "fleet_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-fleet"
slug = "caseyfleet"
}
service_accounts = {
for name, role in local.automation_accounts :
name => {
role = role
token = {
name = "${name}-token"
}
}
}
}💡 Because service accounts and their tokens are
for_each-keyed by the same map key, adding or removing one named entry never re-indexes or disturbs any other entry's state.
8 · Service account with a bounded token lifetime
module "short_lived_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-shortlived"
slug = "caseyshortlived"
}
service_accounts = {
"pipeline-runner" = {
role = "Editor"
token = {
name = "pipeline-runner-token"
seconds_to_live = 604800 # 7 days
}
}
}
}🔒 Set
seconds_to_liveexplicitly whenever the caller's threat model allows it — omitting it produces a token that never expires (see §6 Schema notes that bite).
9 · Disabled service account (retained, access suspended)
module "suspended_account_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-suspended"
slug = "caseysuspended"
}
service_accounts = {
"legacy-integration" = {
role = "Editor"
is_disabled = true
token = {
name = "legacy-integration-token"
}
}
}
}ℹ️
is_disabled = trueretains the account and its token record in state while suspending its ability to authenticate — useful for a deprecated integration a caller isn't ready to fully remove yet.
10 · ⚠️ Admin-scoped service account (deliberate escalation)
module "admin_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-admin-ops"
slug = "caseyadminops"
}
service_accounts = {
"bootstrap-admin" = {
role = "Admin"
token = {
name = "bootstrap-admin-token"
seconds_to_live = 86400 # 24 hours
}
}
}
}
⚠️ "Admin"is never this module's default — it must be typed explicitly by the caller. Reserve it for genuine bootstrap/administrative automation, and prefer a shortseconds_to_livefor any Admin-scoped token.
11 · ⚠️ What NOT to do — an invalid role value
module "bad_role_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-bad-role"
slug = "caseybadrole"
}
service_accounts = {
"broken" = {
role = "SuperAdmin" # fails validation -- not one of Viewer/Editor/Admin
token = {
name = "broken-token"
}
}
}
}
⚠️ This module'svariable "service_accounts"carries avalidation {}block that failsterraform planwith an actionable error whenroleis not exactly"Viewer","Editor", or"Admin", rather than letting the live Cloud API reject it at apply time.
12 · Consuming computed endpoints downstream
module "metrics_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-metrics"
slug = "caseymetrics"
}
}
output "metrics_stack_remote_write_endpoint" {
value = module.metrics_stack.prometheus_remote_write_endpoint
}
output "metrics_stack_prometheus_user_id" {
value = module.metrics_stack.prometheus_user_id
}💡
prometheus_remote_write_endpointandprometheus_user_idare the two computed values most commonly wired into an external agent's (Alloy, Grafana Agent, Prometheus)remote_writebasic-auth block.
13 · Consuming `slug` vs. `id` downstream
module "network_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-network"
slug = "caseynetwork"
}
}
output "network_stack_slug" {
value = module.network_stack.slug
}
output "network_stack_id" {
value = module.network_stack.id
}
⚠️ Always wireslug, notid, into any sibling resource'sstack_slug-shaped input or a stack-scopedgrafanaprovider alias'surl—idis documented here only as an informational, diagnostic/legacy pass-through.
14 · Conceptual integration with terraform-grafana-cloud-access-policy
# terraform-grafana-cloud-access-policy is not yet authored in this session -- see that module's own
# README once available for its real variable/output names. Conceptually, its output token feeds the
# cloud_access_policy_token argument on the provider alias this module requires:
provider "grafana" {
alias = "cloud_mgmt"
cloud_access_policy_token = module.stacks_access_policy.access_policy_token # illustrative name only
}
module "primary_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-primary"
slug = "caseyprimary"
}
}ℹ️ Illustrative only —
terraform-grafana-cloud-access-policyhad not been authored as of this session. This module never accepts acloud_access_policy_tokenvariable of its own; the caller always configures it once on the provider alias, per this module suite's Authentication model convention.
15 · 🏗️ End-to-end composition — access policy → stack → service accounts → stack-scoped content
provider "grafana" {
alias = "cloud_mgmt"
cloud_access_policy_token = var.grafana_cloud_access_policy_token
}
# Illustrative -- see terraform-grafana-cloud-access-policy's own README for its real variable/output names
# once authored.
# module "stacks_access_policy" {
# source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-access-policy.git?ref=v1.0.0"
# providers = { grafana = grafana.cloud_mgmt }
# }
module "primary_stack" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
providers = { grafana = grafana.cloud_mgmt }
stack = {
name = "casey-primary"
slug = "caseyprimary"
}
service_accounts = {
"terraform-content-mgmt" = {
role = "Editor"
token = {
name = "terraform-content-mgmt-token"
seconds_to_live = 7776000 # 90 days
}
}
}
}
# The stack's own service account token bootstraps a SECOND, stack-scoped provider alias -- this module
# never configures that alias itself; the caller does, in the root module, using the sensitive
# service_account_token_keys output below.
provider "grafana" {
alias = "stack_primary"
url = "https://${module.primary_stack.slug}.grafana.net"
auth = module.primary_stack.service_account_token_keys["terraform-content-mgmt"]
}
module "primary_folder" {
source = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
providers = { grafana = grafana.stack_primary }
folder = {
title = "Platform Ops"
}
}🔒
service_account_token_keysissensitive = true— passing it directly into a secondproviderblock'sauthargument keeps the raw key out of any output the caller doesn't explicitly mark sensitive themselves, but it must still be moved to a secrets manager for any real bootstrap, per this module suite's Secure-by-default convention. This composition shows the full intended chain: an access-policy-scoped provider alias creates the stack and its content-management service account/token; that token then bootstraps a second, stack-scoped provider alias; and a downstream content-layer module (terraform-grafana-folder) places a folder inside the new stack using that second alias.
| Variable | Type (summary) | Default | Required |
|---|---|---|---|
stack |
object — keystone fields (see below) | n/a | Yes (name, slug required inside) |
service_accounts |
map(object(...)) — service accounts + their one static token each |
{} |
No |
Full object schemas
variable "stack" {
type = object({
name = string
slug = string
region_slug = optional(string)
description = optional(string, "")
url = optional(string)
labels = optional(map(string), {}) # max 10 entries; keys/values must match ^[a-zA-Z0-9/\-._]+$
delete_protection = optional(bool, true)
wait_for_readiness = optional(bool, true)
wait_for_readiness_timeout = optional(string, "10m0s")
})
}
variable "service_accounts" {
type = map(object({
role = string # "Viewer" | "Editor" | "Admin"
is_disabled = optional(bool, false)
token = object({
name = string
seconds_to_live = optional(number) # omitted/null/0 -- never expires
})
}))
default = {}
}| Output | Description | Sensitive / Conditional |
|---|---|---|
slug |
Primary output. Stack's required, stable slug | No |
id |
Computed numeric stack ID (diagnostic/legacy only) | No |
name |
Stack display name | No |
status |
Computed stack status | No |
org_id / org_slug / org_name |
Computed org assignment | No |
cluster_slug / cluster_name |
Computed cluster assignment | No |
prometheus_url / prometheus_remote_write_endpoint / prometheus_remote_endpoint / prometheus_user_id |
Computed Prometheus endpoints/identity | No |
logs_url / logs_user_id |
Computed Loki endpoint/identity | No |
alertmanager_url |
Computed Alertmanager endpoint | No |
graphite_url |
Computed Graphite endpoint | No |
otlp_url |
Computed OTLP ingest endpoint | No |
traces_url |
Computed Traces (Tempo) endpoint | No |
profiles_url |
Computed Profiles (Pyroscope) endpoint | No |
fleet_management_url |
Computed Fleet Management endpoint | No |
oncall_api_url |
Computed OnCall API endpoint | No |
sm_url |
Computed Synthetic Monitoring API endpoint (requires separate activation) | No |
connections_api_url |
Computed Connections API endpoint | No |
cloud_provider_url |
Computed Cloud Provider Observability API endpoint | No |
service_account_ids |
Map, keyed like var.service_accounts, of each account's id |
No — empty map {} when var.service_accounts is empty |
service_accounts |
Map, keyed like var.service_accounts, metadata only (id, role, is_disabled) |
No |
service_account_tokens |
Map, keyed like var.service_accounts, metadata only (id, name, expiration, has_expired) |
No |
service_account_token_keys |
Map, keyed like var.service_accounts, raw secret token values |
Yes — sensitive = true |
slugis the stable cross-reference identity;idis not. Every downstream reference in this module's own examples and in this library's convention wiresslug, neverid— this mirrors the live schema's own design, wherestack_slug(not a numeric stack ID) is the field every stack-scoped resource and provider argument expects.- The service account token is a required nested field, not an independent collection.
variables.tfmodelsvar.service_accounts[*].tokenas a requiredobject, not a separately-keyedoptional(map(object(...)), {})— every service account this module renders gets exactly one static token in the same apply, matching this module's SCOPE.md Design intent (a stack with service accounts but no tokens is still an incomplete bootstrap).main.tfcross-references the token to its parent viagrafana_cloud_stack_service_account.this[each.key].id— both resources share the samefor_eachkey set (var.service_accounts), so adding or removing one named service account never disturbs any other entry's account or token state. region_slugis treated as force-new in this module's documentation even though the rendered provider docs page does not tag it with a separate "Forces new resource" annotation — its own field description states the destroy-and-recreate behavior in prose. Treat any change to an existing stack'sregion_slugas equivalent to replacing the stack.delete_protectionis a platform-side safety net, not a Terraformlifecycleblock. It defaults totrueon the live schema and is threaded straight through by this module; disabling it (§12 example 4) is a deliberate, human-confirmed opt-out per this module suite's Human judgment guidance, not a routine toggle.- Computed per-product endpoints are a curated subset, not exhaustive. The live schema exposes dozens of
AWS-PrivateLink-only connectivity attributes and per-product status/name fields this module does not
surface as outputs (see §6 Schema notes that bite) — a caller needing one of those must reference
grafana_cloud_stack.thisdirectly in a fork of this module. - Secret handling mirrors
terraform-grafana-service-account's pattern:service_account_tokenscarries metadata only;service_account_token_keyscarries the raw secret and is the only sensitive output this module emits. - Cloud-only applicability. Unlike
grafana_folder/grafana_team/grafana_data_source, all three resources this module renders apply only to Grafana Cloud organizations — there is no OSS/Enterprise equivalent of a "stack" as a manageable Terraform resource.
| Concern | Safe default (this module) | Opt-out |
|---|---|---|
| Stack deletion | delete_protection defaults to true, matching the live schema's own default — the platform itself refuses to delete a protected stack |
Caller sets var.stack.delete_protection = false (human-confirmed, per §12 example 4) |
| Service account tokens | No default expiration override — seconds_to_live is left to the live schema's own "omitted = never expires" behavior; this module never injects a hidden default |
Caller sets an explicit, bounded token.seconds_to_live per entry |
| Team/role permissions (service account roles) | No module-level default role — every service_accounts entry must state its role explicitly; this module's own examples default to "Viewer" wherever automation can be read-only |
Caller sets role = "Editor" or role = "Admin" explicitly, per entry |
| Legacy API keys | Not modeled at all — this module only renders grafana_cloud_stack_service_account + grafana_cloud_stack_service_account_token, never the legacy grafana_api_key |
N/A |
| Org/stack scope | slug is always an explicit, required, visible field — this module never relies on "whatever stack the provider defaults to" |
N/A — this is not user-overridable; it is a hard module design rule |
| Data source access / TLS | Not applicable — this module renders no data source resources | N/A |
| Credentials | No credential-bearing input variables; the only credential-shaped output (service_account_token_keys) is sensitive = true |
N/A |
cd C:\GitHubCode\newgrafanamodules\terraform-grafana-cloud-stack
terraform init -backend=false
terraform validate
terraform fmt -checkPin consumers at ?ref=v1.0.0, never a branch. This module is plan-only in this authoring pipeline —
no apply is ever run here; a human applies from CI against a real Grafana Cloud organization.
terraform validate + terraform fmt -check together confirm: every field in var.stack and
var.service_accounts type-checks against the schemas declared in variables.tf; the three
validation {} blocks (labels count, labels regex, service account role enum) are themselves syntactically
valid; and the whole file set is canonically formatted. These checks run entirely offline — no
cloud_access_policy_token or live Grafana Cloud organization is required.
What this harness cannot catch: whether a slug collides with an existing stack org-wide, whether a
region_slug is actually a valid, enabled region for the caller's organization, whether the caller's
cloud_access_policy_token actually carries the stacks:write / stack-service-accounts:write scopes this
module's resources require, or the real destroy/recreate blast radius of changing region_slug on a stack
already holding content — those surface only at plan/apply against a live Grafana Cloud organization.
$ terraform output
id = "1234567"
slug = "caseyprimary"
name = "casey-primary"
status = "active"
org_id = 654321
org_slug = "financialpartners"
prometheus_remote_write_endpoint = "https://prometheus-prod-13-prod-us-east-0.grafana.net/api/prom/push"
prometheus_user_id = 987654
service_account_ids = {
"terraform-content-mgmt" = "42"
}
service_accounts = {
"terraform-content-mgmt" = {
"id" = "42"
"is_disabled" = false
"role" = "Editor"
}
}
service_account_tokens = {
"terraform-content-mgmt" = {
"expiration" = "2026-10-08T00:00:00Z"
"has_expired" = false
"id" = "7"
"name" = "terraform-content-mgmt-token"
}
}
service_account_token_keys is omitted from plain terraform output — it prints only via
terraform output -json or terraform output service_account_token_keys explicitly, since it is marked
sensitive = true.
| Symptom | Cause | Fix |
|---|---|---|
plan fails: "var.stack.labels may contain at most 10 entries" |
More than 10 labels supplied | Reduce var.stack.labels to 10 or fewer entries |
plan fails: "Every var.stack.labels key and value must match ^[a-zA-Z0-9/-._]+$" |
A label key or value contains disallowed characters | Use only alphanumerics, /, -, ., _ in every label key and value |
plan fails: "Each var.service_accounts entry's role must be one of: Viewer, Editor, Admin" |
An invalid/mistyped role value |
Use exactly "Viewer", "Editor", or "Admin" |
apply fails with a Cloud API error on slug |
The requested slug is already taken org-wide (uniqueness is enforced platform-side, not by Terraform state) |
Choose a different slug |
apply unexpectedly destroys and recreates the stack |
region_slug was changed on an existing stack — the live schema documents this as destroy-and-recreate behavior |
Treat region_slug as write-once; provision a new stack instead of changing an existing one's region |
apply fails attempting to delete the stack |
delete_protection is true (the default) and the platform is refusing the deletion |
Get explicit human confirmation, then set var.stack.delete_protection = false before the destroy plan |
| A service account token never expires even though the caller expected it to | token.seconds_to_live was omitted, null, or 0 — the live schema's own documented behavior for that case is "never expires" |
Set an explicit, positive seconds_to_live on the token |
terraform init can't find provider version ~> 4.0 |
Local provider cache is stale or offline | Re-run terraform init -backend=false -upgrade, or confirm network access to the Terraform Registry |
- Provider resource docs:
grafana_cloud_stack,grafana_cloud_stack_service_account,grafana_cloud_stack_service_account_token,grafana_cloud_stack_service_account_rotating_token(grafana/grafanaprovider, Terraform Registry) - This module's
SCOPE.md— cross-module contract, required auth/scope, Grafana prerequisites, provider gotchas, design decisions - Sibling modules referenced above:
terraform-grafana-cloud-access-policy(not yet authored as of this session — treat its example call shapes above as illustrative),terraform-grafana-folder,terraform-grafana-frontend-observability-app,terraform-grafana-service-account