Skip to main content
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 .tf files 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.
Duplicates only happen if the state is lost or bypassed — for example, applying against empty local state while the objects already exist in the UI, or deleting terraform.tfstate. This is exactly why CI refuses to run without a remote backend, and why existing resources are imported before the first apply.

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. The adopted/<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 the env:/ prefix of the backend.
Local state is gitignored and embeds the x-health-token value. Losing it causes duplicate creates on the next apply. Before any team or CI use, migrate to a shared S3-compatible remote backend.
The same block is stored in the 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

After apply, Terraform exposes:
  • status_page_resources_json — the component-scope → status-page-resource id map used to fill BETTERSTACK_STATUS_PAGE_RESOURCES.
  • uptime_heartbeat_ids — the three cron heartbeat ids.
  • IaC deep dive: docs/initiatives/betterstack/06-terraform-iac.md
  • Module README: infra/betterstack/README.md
  • Runner script: scripts/status-monitoring/terraform-apply.sh