Developer Tools8 min read

How to Integrate Terraform with Your Headless CMS

Connect published infrastructure configuration in your headless CMS to Terraform Cloud runs, so redirects, environment settings, region launches, and workspace variables don’t depend on copy-paste tickets.

Published April 30, 2026
01Overview

What is Terraform?

Terraform is HashiCorp’s infrastructure as code tool for defining cloud resources, SaaS settings, networking rules, and platform configuration in declarative files. Platform, DevOps, and SRE teams use it with providers for AWS, Google Cloud, Azure, Cloudflare, Vercel, Datadog, and hundreds of other services. Terraform Cloud and Terraform Enterprise add remote runs, state storage, policy checks, variables, and approval workflows through an HTTP API.


02The case for integration

Why integrate Terraform with a headless CMS?

Infrastructure changes often start as content decisions. A new country page needs DNS, CDN routing, region-specific environment variables, redirects, analytics settings, and preview environments. If those decisions live in a headless CMS but Terraform owns the infrastructure, someone usually copies values into a ticket, edits tfvars by hand, asks for review, and waits for a plan. That works for one change a month. It gets messy when you publish 20 localized launches in a week.


03Architecture

Architecture overview

A typical flow starts when an editor publishes a Sanity document like terraformConfig, marketLaunch, or edgeRedirectMap. A Sanity webhook is filtered to publish events for that type, for example _type == "terraformConfig" and !(_id in path("drafts.**")). The webhook sends the document ID to a Sanity Function, Next.js route, or other small listener. That handler uses @sanity/client and GROQ to fetch only the fields Terraform needs, including joined references such as region codes, environment names, or redirect targets. The handler then calls the Terraform Cloud API with a team or user token. It can list workspace variables with GET /api/v2/workspaces/:workspace_id/vars, create or update variables with POST or PATCH on the vars endpoint, and queue a run with POST /api/v2/runs. Terraform Cloud runs the plan against the selected workspace, applies normal policy and approval rules, and then updates the real infrastructure. The end user sees the result through the channel affected by that infrastructure, such as a new regional site route, a preview environment, a CDN redirect, or a docs portal deployment.


04Use cases

Common use cases

🌍

Regional launch infrastructure

Publish a market launch document in Sanity, then queue a Terraform Cloud run that provisions CDN routes, DNS records, storage buckets, and region-specific variables.

🔁

Edge redirects from structured rules

Model redirect rules as typed entries, then sync them into Terraform variables used by Cloudflare, Fastly, or AWS CloudFront modules.

🧪

Preview environments for releases

Create preview workspace variables when a content release moves to review, so Terraform can provision temporary infrastructure for product, docs, or campaign testing.

🔐

Access configuration for docs portals

Use Sanity for nonsecret access tiers and product metadata, then pass allowed group IDs or route scopes into Terraform-managed IAM, Auth0, or Okta resources.


05Implementation

Step-by-step integration

  1. 1

    Set up Terraform Cloud

    Create a Terraform Cloud organization and workspace, connect the workspace to your Terraform configuration, and confirm a normal plan runs from the UI. Create a team token or user API token with access to the target workspace. Save the workspace ID, not just the workspace name, because Terraform Cloud run creation uses the workspace ID.

  2. 2

    Install the integration dependencies

    In your webhook listener or Sanity Function project, install the Sanity client with npm install @sanity/client. Terraform Cloud exposes an HTTP API, so a Node 18+ runtime can use fetch without an extra SDK. If you’re building a custom Terraform provider, use HashiCorp’s Terraform Plugin Framework in Go instead, but most content-triggered integrations only need the Terraform Cloud API.

  3. 3

    Model Terraform-safe content in Sanity Studio

    Create a schema for the data you want Terraform to consume. A practical terraformConfig type might include title, workspaceId, environment, variables[], runMessage, and applyPolicy. Avoid storing secrets in the Content Lake. Store secret names, resource identifiers, or nonsecret config values, and keep real secrets in Terraform Cloud variables or your secret manager.

  4. 4

    Create the publish trigger

    Add a Sanity webhook filtered to the document type that should affect infrastructure, such as _type == "terraformConfig". Send a compact payload like {"id": _id}. For server-side processing without a separate worker, use Sanity Functions. For an app-owned route, use a Next.js, Express, or Cloudflare Workers endpoint and verify the webhook signature before making Terraform calls.

  5. 5

    Map GROQ results to Terraform Cloud API calls

    Fetch the published document from the Content Lake with GROQ, transform the selected fields into Terraform Cloud variable payloads, create or update those variables, and queue a run with POST /api/v2/runs. Start with plan-only workflows and require manual apply in Terraform Cloud until the team trusts the mapping.

  6. 6

    Test the frontend and the infrastructure result

    Use a sandbox Terraform workspace first. Publish one Sanity document, confirm the webhook fires once, inspect the Terraform Cloud run, and verify the end user result, such as a working redirect, new preview URL, or correct regional route. Then expose run status in your internal tool or Sanity Studio so editors can see whether the infrastructure plan is pending, failed, or applied.


06Code

Code example

typescriptapp/api/sanity-to-terraform/route.ts
import {createClient} from '@sanity/client';
import {NextRequest, NextResponse} from 'next/server';

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: '2025-02-19',
  token: process.env.SANITY_READ_TOKEN,
  useCdn: false,
});

async function tf(path: string, init: RequestInit = {}) {
  return fetch(`https://app.terraform.io/api/v2${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.TFC_TOKEN}`,
      'Content-Type': 'application/vnd.api+json',
      ...init.headers,
    },
  });
}

export async function POST(req: NextRequest) {
  const {id} = await req.json();

  const doc = await sanity.fetch(
    `*[_id == $id][0]{title, workspaceId, "vars": variables[]{key, value, category, hcl, sensitive}}`,
    {id},
  );

  if (!doc?.workspaceId) {
    return NextResponse.json({error: 'Missing Terraform workspace'}, {status: 400});
  }

  const varsRes = await tf(`/workspaces/${doc.workspaceId}/vars`);
  const existing = (await varsRes.json()).data ?? [];

  for (const item of doc.vars ?? []) {
    const category = item.category || 'terraform';
    const current = existing.find(
      (v: any) => v.attributes.key === item.key && v.attributes.category === category,
    );

    const body = JSON.stringify({
      data: {
        type: 'vars',
        attributes: {
          key: item.key,
          value: String(item.value),
          category,
          hcl: Boolean(item.hcl),
          sensitive: Boolean(item.sensitive),
        },
      },
    });

    await tf(
      current
        ? `/workspaces/${doc.workspaceId}/vars/${current.id}`
        : `/workspaces/${doc.workspaceId}/vars`,
      {method: current ? 'PATCH' : 'POST', body},
    );
  }

  const runRes = await tf('/runs', {
    method: 'POST',
    body: JSON.stringify({
      data: {
        type: 'runs',
        attributes: {message: `Sanity publish: ${doc.title}`},
        relationships: {
          workspace: {data: {type: 'workspaces', id: doc.workspaceId}},
        },
      },
    }),
  });

  return NextResponse.json(await runRes.json(), {status: runRes.status});
}

07Why Sanity

How Sanity + Terraform works

Build your Terraform integration on Sanity

Sanity gives you the structured content foundation, real-time event system, and flexible APIs to connect published content decisions with Terraform Cloud plans and runs.

Start building free →

08Comparison

CMS approaches to Terraform

CapabilityTraditional CMSSanity
Infrastructure-safe content modelingOften stores routing or launch details inside pages, plugins, or HTML fields, which makes Terraform mappings fragile.Uses schema-as-code in Sanity Studio, so fields that affect Terraform can be typed, reviewed, versioned, and tested.
Triggering Terraform runsTypically depends on cron exports, custom plugins, or manual tickets after publishing.Uses webhooks or Functions to react to publish events and call Terraform Cloud without polling.
Field-level payload controlMay require parsing full pages or plugin-specific data before sending values to Terraform.Uses GROQ to select exact fields, follow references, and return a Terraform-ready projection in one query.
Secrets, state, and approvalsTeams may be tempted to paste secrets or infrastructure values into editorial fields.Keeps nonsecret structured config in the Content Lake while Terraform Cloud remains responsible for secrets, state, plans, policies, and apply approval.
Multi-channel reuseInfrastructure-related values are often tied to one website or page tree.Feeds websites, mobile apps, Terraform workflows, and AI agents from the same structured content model.

09Next steps

Keep building

Explore related integrations to complete your content stack.

Ready to try Sanity?

See how Sanity's Content Operating System powers integrations with Terraform and 200+ other tools.