All Better Stack configuration — status pages, monitors, heartbeats, log
labels, dashboards and alerts — is declared as code under
infra/betterstack/. The UI is never the source of truth; Terraform is.Why infrastructure as code
Configuring dozens of monitors, sections and alerts by hand does not scale and drifts silently between environments. Terraform gives us:How Terraform thinks
Terraform continuously reconciles three pictures of the world. Understanding this is the key to everything else on this page:- Declared config — what the
.tffiles and adoption manifests say should exist. - Tracked state (
terraform.tfstate) — Terraform’s inventory of what it already manages, including each resource’s Better Stack id. - Real world — what the Better Stack API returns right now.
plan vs apply — the core distinction
This is the concept every operator must internalize. The simplest analogy:
plan is the diff, apply is the commit.
terraform plan — dry run, read-only
Refreshes state from the Better Stack API, compares it against the declared
config, and prints the actions it would take:
+ create, ~ update,
- destroy. It changes nothing. A plan showing
0 to add, 0 to change, 0 to destroy proves there is no drift.terraform apply — executes the change
Takes that same diff and runs the real API calls (POST / PATCH / DELETE),
then writes the resulting ids back into the tfstate. This is the only
operation that mutates infrastructure.
You never
apply without first reading the plan. In CI the plan is printed
in the job log and apply additionally requires typing APPLY — see
CI/CD for Monitoring.A third verb: import (adoption)
When a resource already exists in Better Stack (created by hand before Terraform
managed it), import attaches it to the state without recreating it. This
is how the existing monitors were adopted: the first apply in development
imported 68 resources, and the follow-up plan reported 0 changes.
Idempotency — why re-running is safe
Terraform is declarative: applying the same config twice does not duplicate anything, as long as the state is preserved.Repository layout
Providers
Adoption manifests
Every environment converges to the same catalogue — 31 monitors (12 API, including the cross-folder Digital Contract journey, + 9 integrations + 10 platforms), 4 sections and 3 heartbeats — each pointing at its own hosts. Theadopted/<env>.json manifest is the desired
state; after adoption you edit it via PR. The generator synthesizes entries
that do not exist yet (with monitor_id = null) so Terraform creates them on
apply instead of importing.
Regenerate only to audit drift, and always review the diff before committing:
Per-environment state (workspaces)
State is isolated per environment using Terraform workspaces: the workspace name equals the environment name, so development, certification and production each keep an independent state under theenv:/ prefix of the backend.
TF_BACKEND_OVERRIDE GitHub secret so the
workflow writes it before terraform init.
Running it locally
terraform-apply.sh wraps init + workspace selection + plan/apply, and
loads tokens automatically from backend/horizon-api/.env.<env> (mapping
BETTERSTACK_UPTIME_API_TOKEN to the provider’s BETTERUPTIME_API_TOKEN).
destroy is deliberately guarded: it requires CONFIRM_DESTROY=<env> so it
can never run by accident.Outputs
Afterapply, Terraform exposes:
status_page_resources_json— the component-scope → status-page-resource id map used to fillBETTERSTACK_STATUS_PAGE_RESOURCES.uptime_heartbeat_ids— the three cron heartbeat ids.
Related source
- IaC deep dive:
docs/initiatives/betterstack/06-terraform-iac.md - Module README:
infra/betterstack/README.md - Runner script:
scripts/status-monitoring/terraform-apply.sh