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.
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.
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):
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:
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: