Skip to content

The Terraform + Helm baseline

The reusable skeleton every downstream Chiron recipe extends. This page walks the four files each provider ships in examples/foundations/<provider>/. E2/E3 recipes will source this module — you'll never repeat the plumbing.

The baseline provisions ONE resource-scoping container per provider (a resource group / project / … account context), the compute target parameterized as kubernetes OR serverless, and a container registry to receive image pushes. That's it — enough foundation to terraform validate today, and enough to apply against a real cloud tomorrow (Phase 3, deferred).

Full source: examples/foundations/.

What's in the baseline

Four files per provider, one shared Helm chart:

examples/foundations/
  azure/{versions,provider,main,variables,outputs}.tf
  gcp/  {versions,provider,main,variables,outputs}.tf
  aws/  {versions,provider,main,variables,outputs}.tf
  chart/                   (shared minimal Helm chart)
    Chart.yaml
    values.yaml
    templates/{deployment,service}.yaml

Provider version pins (verified 2026-08-08)

Provider Version Doc
hashicorp/azurerm ~> 5.0 https://registry.terraform.io/providers/hashicorp/azurerm/latest
hashicorp/google ~> 7.0 https://registry.terraform.io/providers/hashicorp/google/latest
hashicorp/aws ~> 6.0 https://registry.terraform.io/providers/hashicorp/aws/latest

The versions.tf per provider

Pinning Terraform + provider versions is non-optional — a terraform init against an unpinned config downloads the latest provider on every fresh checkout, which is where hallucinated resource shapes silently start rendering with real errors.

terraform {
  required_version = ">= 1.10.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}
terraform {
  required_version = ">= 1.10.0"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 7.0"
    }
  }
}
terraform {
  required_version = ">= 1.10.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

The provider.tf

Provider config is intentionally minimal — real auth flows from the credentials + auth page (CLI locally, workload identity in-cloud). Nothing hard-coded here.

provider "azurerm" {
  features {}
  # subscription_id/tenant_id come from `az login` context or from
  # the ARM_* env vars (see Azure docs). Do NOT hard-code.
}
provider "google" {
  project = var.project_id
  region  = var.region
  # Credentials come from Application Default Credentials — set up
  # via `gcloud auth application-default login`.
}
provider "aws" {
  region = var.region
  # Credentials come from the boto3-family default chain
  # (env vars, ~/.aws/credentials, IRSA, instance profile, …).
}

The main.tf — parameterized k8s | serverless

Each provider's main.tf defines two mutually-exclusive compute paths and picks between them with var.compute = kubernetes | serverless. E2/E3 recipes flip that variable — no other module change.

# Scoping container — every child resource sits inside this RG.
resource "azurerm_resource_group" "chiron" {
  name     = var.name
  location = var.region
}

# Container registry — where recipes push app images.
resource "azurerm_container_registry" "chiron" {
  name                = replace(var.name, "-", "")
  resource_group_name = azurerm_resource_group.chiron.name
  location            = azurerm_resource_group.chiron.location
  sku                 = "Basic"
}

# Kubernetes compute path — AKS.
resource "azurerm_kubernetes_cluster" "chiron" {
  count               = var.compute == "kubernetes" ? 1 : 0
  name                = "${var.name}-aks"
  location            = azurerm_resource_group.chiron.location
  resource_group_name = azurerm_resource_group.chiron.name
  dns_prefix          = var.name

  default_node_pool {
    name       = "default"
    node_count = 1
    vm_size    = "Standard_D2s_v5"
  }

  # azurerm 5.0 (released 2026-07-27) added
  # node_provisioning_profile as a required block on
  # azurerm_kubernetes_cluster. "Manual" preserves the pre-5.0
  # behavior of an operator-managed default node pool.
  node_provisioning_profile {
    mode = "Manual"
  }

  identity {
    type = "SystemAssigned"
  }
}

# Serverless compute path — Container Apps environment.
resource "azurerm_container_app_environment" "chiron" {
  count               = var.compute == "serverless" ? 1 : 0
  name                = "${var.name}-cae"
  location            = azurerm_resource_group.chiron.location
  resource_group_name = azurerm_resource_group.chiron.name
}
# Container registry — Artifact Registry, receives image pushes.
resource "google_artifact_registry_repository" "chiron" {
  location      = var.region
  repository_id = var.name
  format        = "DOCKER"
}

# Kubernetes compute path — GKE Autopilot.
resource "google_container_cluster" "chiron" {
  count            = var.compute == "kubernetes" ? 1 : 0
  name             = "${var.name}-gke"
  location         = var.region
  enable_autopilot = true

  deletion_protection = false
}

# Serverless compute path — Cloud Run service.
resource "google_cloud_run_v2_service" "chiron" {
  count    = var.compute == "serverless" ? 1 : 0
  name     = "${var.name}-cr"
  location = var.region

  template {
    containers {
      # Placeholder — E2/E3 will swap in a real image URL.
      image = "us-docker.pkg.dev/cloudrun/container/hello"
    }
  }
}
# Container registry — ECR receives image pushes.
resource "aws_ecr_repository" "chiron" {
  name                 = var.name
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

# Kubernetes compute path — EKS cluster (minimal skeleton).
# A real recipe wires this into a VPC + subnet + node group; E2
# will layer that on top.
resource "aws_iam_role" "eks_cluster" {
  count = var.compute == "kubernetes" ? 1 : 0
  name  = "${var.name}-eks-cluster"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRole"
      Principal = {
        Service = "eks.amazonaws.com"
      }
    }]
  })
}

# Serverless compute path — App Runner service (skeleton).
resource "aws_iam_role" "apprunner_access" {
  count = var.compute == "serverless" ? 1 : 0
  name  = "${var.name}-apprunner-access"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRole"
      Principal = {
        Service = "build.apprunner.amazonaws.com"
      }
    }]
  })
}

Variables + outputs

variables.tf on each provider surfaces the same 3 knobs — name, region, compute — so an E2 recipe can wire a Terraform module call with an identical variable set no matter which cloud is active.

variable "name" {
  type        = string
  default     = "chiron-demo"
  description = "Base name — used for the resource group, registry, and compute resource."
}

variable "region" {
  type        = string
  default     = "eastus"
  description = "Azure region."
}

variable "compute" {
  type        = string
  default     = "kubernetes"
  description = "'kubernetes' (AKS) or 'serverless' (Container Apps)."
  validation {
    condition     = contains(["kubernetes", "serverless"], var.compute)
    error_message = "compute must be either 'kubernetes' or 'serverless'."
  }
}
variable "project_id" {
  type        = string
  description = "GCP project id (no default — every gcloud tenant has its own)."
}

variable "name" {
  type        = string
  default     = "chiron-demo"
  description = "Base name — used for the artifact repo + compute resource."
}

variable "region" {
  type        = string
  default     = "us-central1"
  description = "GCP region."
}

variable "compute" {
  type        = string
  default     = "kubernetes"
  description = "'kubernetes' (GKE) or 'serverless' (Cloud Run)."
  validation {
    condition     = contains(["kubernetes", "serverless"], var.compute)
    error_message = "compute must be either 'kubernetes' or 'serverless'."
  }
}
variable "name" {
  type        = string
  default     = "chiron-demo"
  description = "Base name — used for the ECR repo + compute resource."
}

variable "region" {
  type        = string
  default     = "us-east-1"
  description = "AWS region."
}

variable "compute" {
  type        = string
  default     = "kubernetes"
  description = "'kubernetes' (EKS) or 'serverless' (App Runner)."
  validation {
    condition     = contains(["kubernetes", "serverless"], var.compute)
    error_message = "compute must be either 'kubernetes' or 'serverless'."
  }
}

The shared Helm chart

For the Kubernetes compute path, one minimal chart lives in examples/foundations/chart/. It renders a Deployment + Service and is intentionally provider-agnostic — the image registry URL and image tag are chart values, so the same chart installs the same app on AKS, GKE, or EKS.

# examples/foundations/chart/values.yaml
image:
  repository: "nginx"      # E2/E3 recipes will point this at the provider registry
  tag: "1.27-alpine"
service:
  type: ClusterIP
  port: 80

Install (once your cluster kubeconfig is set):

helm install chiron ./examples/foundations/chart

Validate — no apply

Every baseline runs terraform validate clean without hitting a real cloud (no credentials are needed for validation). The CI gate + the local check are the same:

cd examples/foundations/azure    # or gcp, or aws
terraform init -backend=false
terraform validate

init -backend=false skips remote state config for validation runs; validation covers HCL syntax + provider schema shape, which is exactly what we want at Phase 1 (docs correctness) without needing an account.

Teardown notes (Phase 3)

When Phase 3 lands live apply, the teardown is the same command inverted:

terraform destroy
# Or, nuclear option — delete the whole RG:
az group delete --name chiron-demo --yes --no-wait
terraform destroy
# Autopilot GKE clusters take 5-10 min to fully tear down; give
# them room before you consider the account clean.
terraform destroy
# If ECR still holds images, deletion needs `force_delete = true`
# on the aws_ecr_repository (already set in the baseline).