Tutorial 13 — Deploy via Terraform Module
> Status note.* The Helm chart at deploy/helm/buttrbase-backend-rust/ is real and committed — you can run it right now using tutorial 07. There is *no committed .tf in this repo. This tutorial authors a starter Terraform module that installs the chart; you adopt it, publish it under your own org, and maintain it going forward. It is a starting point, not a maintained ButtrBase artifact. The chart it wraps is stable; the Terraform surface around it is yours to evolve.
This tutorial is one layer thin: it explains what the Terraform module adds on top of the chart, shows you the three files you need (variables.tf, main.tf, outputs.tf), and walks the init/plan/apply cycle. For everything about topology modes, secret layout, and the /health probe, see 07-deploy-with-helm.md. That document is the substrate; this one references it and does not repeat it.
1. Why Terraform
IaC shops already have Terraform managing their cluster, VPC, databases, and DNS. A standalone helm upgrade --install command is fine on its own, but it lives outside the state graph: the cluster and the app on it are two separate operational contexts. Wrapping the chart in a helm_release resource plugs the deployment into the existing terraform apply cycle — secrets, namespace, and Helm release are created, updated, and destroyed as a single unit.
This is the natural fit for single-tenant-byoc and single-tenant-managed topologies: the customer (or your ops team) already owns the infrastructure in Terraform. The module lets them add ButtrBase as one more module block instead of reaching for a separate helm CLI step.
| What the Terraform module adds | What the chart still owns |
|-------------------------------|--------------------------|
| kubernetes_secret creating the three app secrets | All topology logic and env-var rendering |
| helm_release driving the install lifecycle | Pod spec, probes, ConfigMap, Service |
| variables.tf surface for IaC-idiomatic parameterization | The values-file topology presets |
| outputs.tf for downstream wiring (Ingress, DNS, etc.) | The image, port 4300, and /health |
2. Prerequisites
- Terraform 1.5 or later.
- A Kubernetes cluster (1.25+) and a
kubectlcontext pointing at it. The Terraformkubernetesandhelmproviders both read~/.kube/configby default. - Helm 3.12+ installed locally (the
hashicorp/helmprovider shells out to it). - A reachable Postgres database and the three secret values described in tutorial
07(database URL, secret key, 32-byte encryption key). - No existing
kubernetes_secretnamedbuttrbase-backend-rustin the target namespace — the module creates it. If you pre-created it following tutorial07, import it or let the module own it from the start.
3. The module files
Create a directory for the module — terraform-kubernetes-buttrbase/ is the canonical name for Terraform Registry publishing (see section 6). The three files below are the complete module.
variables.tf
variable "namespace" {
description = "Kubernetes namespace to deploy ButtrBase into."
type = string
default = "buttrbase"
}variable "release_name" {
description = "Helm release name. Also becomes the Kubernetes Secret name the chart reads."
type = string
default = "buttrbase"
}
variable "chart_path" {
description = "Path to the buttrbase-backend-rust Helm chart. Relative to the root of the ButtrBase repo checkout."
type = string
default = "../../deploy/helm/buttrbase-backend-rust"
}
variable "image_tag" {
description = "Container image tag to deploy. Pin a real tag — 'latest' is for development only."
type = string
}
variable "topology_mode" {
description = "Deployment topology. One of: saas, single-tenant-managed, single-tenant-byoc, on-prem."
type = string
default = "single-tenant-managed"
validation {
condition = contains(["saas", "single-tenant-managed", "single-tenant-byoc", "on-prem"], var.topology_mode)
error_message = "topology_mode must be one of: saas, single-tenant-managed, single-tenant-byoc, on-prem."
}
}
variable "tenant_slug" {
description = "Short identifier for this tenant (e.g. 'acme'). Used in bucket names and labels."
type = string
}
variable "tenant_region" {
description = "Cloud region this tenant's workload runs in (e.g. 'us-central1')."
type = string
}
variable "storage_backend" {
description = "Storage backend: 's3' for cloud object storage, 'local-fs' for air-gapped on-prem."
type = string
default = "s3"
}
variable "storage_bucket" {
description = "Name of the object-storage bucket the backend reads and writes."
type = string
}
variable "public_base_url" {
description = "Public HTTPS base URL for this tenant (e.g. 'https://acme.buttrbase.example')."
type = string
}
variable "network_mode" {
description = "Network exposure mode: 'public', 'private', or 'air-gapped'."
type = string
default = "private"
}
variable "providers_ownership" {
description = "Who operates the infrastructure: 'buttrbase-managed', 'customer-managed', or 'manual-invoice'."
type = string
default = "customer-managed"
}
variable "ip_allowlist_cidrs" {
description = "Optional CIDR list for IP allowlist policy. Empty list disables the allowlist."
type = list(string)
default = []
}
Secret values — marked sensitive so Terraform redacts them in plan output.
WARNING: these values ARE written to Terraform state. Use a remote backend
with encryption at rest (S3 + KMS, GCS + CMEK, Terraform Cloud, etc.).
See the state-sensitivity note in section 4.
variable "database_url" {
description = "Postgres connection URL for the ButtrBase backend."
type = string
sensitive = true
}
variable "secret_key" {
description = "Application secret key (32+ bytes of entropy). Generate with: openssl rand -hex 32"
type = string
sensitive = true
}
variable "encryption_key" {
description = "32-byte hex key used to encrypt tenant secrets at rest. Back this up before applying — losing it means losing access to every encrypted value."
type = string
sensitive = true
}
main.tf
terraform {
required_version = ">= 1.5" required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.30"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.13"
}
}
}
The kubernetes and helm providers inherit kubeconfig from the environment.
Override with explicit config_path / host / cluster_ca_certificate if needed.
provider "kubernetes" {}
provider "helm" {}resource "kubernetes_namespace" "buttrbase" {
metadata {
name = var.namespace
}
}
Create the Kubernetes Secret that the chart reads via env.*SecretRef.
The chart expects a single Secret named after the release, with three keys:
database-url, secret-key, encryption-key. These names are fixed in values.yaml
and must not be changed here unless you also override env.*SecretRef in the chart.
resource "kubernetes_secret" "buttrbase" {
metadata {
name = var.release_name
namespace = kubernetes_namespace.buttrbase.metadata[0].name
} data = {
"database-url" = var.database_url
"secret-key" = var.secret_key
"encryption-key" = var.encryption_key
}
type = "Opaque"
}
resource "helm_release" "buttrbase" {
name = var.release_name
namespace = kubernetes_namespace.buttrbase.metadata[0].name
chart = var.chart_path
version = "0.1.0"
# Pin the image away from 'latest'.
set {
name = "app.image.tag"
value = var.image_tag
}
# Topology values — mirrors what values-single-tenant-managed.yaml sets by hand.
set {
name = "topology.mode"
value = var.topology_mode
}
set {
name = "topology.tenantSlug"
value = var.tenant_slug
}
set {
name = "topology.tenantRegion"
value = var.tenant_region
}
set {
name = "topology.networkMode"
value = var.network_mode
}
set {
name = "topology.storageBackend"
value = var.storage_backend
}
set {
name = "topology.storageBucket"
value = var.storage_bucket
}
set {
name = "topology.publicBaseUrl"
value = var.public_base_url
}
set {
name = "topology.callbackBaseUrl"
value = var.public_base_url
}
# Providers and policy.
set {
name = "providers.ownership"
value = var.providers_ownership
}
set {
name = "policy.ipAllowlistCidrs"
value = "{${join(",", var.ip_allowlist_cidrs)}}"
}
# Point the chart's SecretRef values at the Secret this module created.
# The defaults in values.yaml already match release_name, so these are
# explicit for clarity — change them only if you rename the Secret.
set {
name = "env.databaseUrlSecretRef.name"
value = kubernetes_secret.buttrbase.metadata[0].name
}
set {
name = "env.secretKeySecretRef.name"
value = kubernetes_secret.buttrbase.metadata[0].name
}
set {
name = "env.encryptionKeySecretRef.name"
value = kubernetes_secret.buttrbase.metadata[0].name
}
depends_on = [kubernetes_secret.buttrbase]
}
outputs.tf
output "release_name" {
description = "Helm release name. Use to construct kubectl commands targeting this install."
value = helm_release.buttrbase.name
}output "namespace" {
description = "Kubernetes namespace the release was installed into."
value = helm_release.buttrbase.namespace
}
output "service_name" {
description = "Name of the ClusterIP Service fronting the backend. Use in Ingress or downstream modules."
value = helm_release.buttrbase.name
}
output "service_port" {
description = "Port the backend Service listens on."
value = 4300
}
output "health_endpoint" {
description = "In-cluster URL for the /health probe."
value = "http://${helm_release.buttrbase.name}.${helm_release.buttrbase.namespace}.svc.cluster.local:4300/health"
}
4. State-sensitivity of secrets
kubernetes_secret writes the secret values — database_url, secret_key, and encryption_key — into Terraform state. Terraform marks them sensitive in plan output (they are redacted), but they are not encrypted in state. A local terraform.tfstate file contains them in plaintext.
Before you run apply:
local backend does not.encryption-key is the most sensitive of the three. Losing it means losing access to every encrypted tenant value in the database. Back it up in your secrets manager before the first apply, not after.There is no option that is both maximally convenient and maximally safe. This tutorial is honest about that tradeoff: sensitive = true reduces accidental exposure in logs; it does not replace a proper secrets backend.
5. Install
From the directory that consumes the module (your root module, not the module directory itself):
root/main.tf — example consumer
module "buttrbase" {
source = "./terraform-kubernetes-buttrbase" image_tag = "0.1.0"
topology_mode = "single-tenant-managed"
tenant_slug = "acme"
tenant_region = "us-central1"
storage_bucket = "acme-buttrbase-runtime"
public_base_url = "https://acme.buttrbase.example"
database_url = var.database_url # sourced from tfvars or secrets manager
secret_key = var.secret_key
encryption_key = var.encryption_key
}
Then run the standard lifecycle:
terraform init
terraform plan -out=buttrbase.tfplan
terraform apply buttrbase.tfplan
plan shows the three resources: kubernetes_namespace, kubernetes_secret, and helm_release. Secret values appear as (sensitive value) — confirm the plan reads clean before applying.
6. Verify
After apply completes, the verification steps are identical to tutorial 07 — same chart, same probes:
kubectl -n buttrbase rollout status deploy/buttrbase --timeout=120s
kubectl -n buttrbase port-forward svc/buttrbase 4300:4300 &
curl -fsS http://localhost:4300/health && echo " ✓ healthy"
Confirm the topology env var round-tripped:
kubectl -n buttrbase exec deploy/buttrbase -- printenv BUTTRBASE_RUST_DEPLOYMENT_MODE
→ single-tenant-managed
If rollout status hangs, read the pod logs — the two dominant first-install failures are a database-url the pod's network cannot reach and an encryption-key that is not 32 bytes hex. Both surface in the first ten log lines.
To tear down: terraform destroy. This removes the helm_release, then the kubernetes_secret, then the namespace — in dependency order.
7. Publishing to the Terraform Registry
The Terraform Registry discovers modules from public GitHub repositories whose name follows the pattern terraform-. For this module:
terraform-kubernetes-buttrbasekubernetes (the primary provider the module wraps)buttrbaseOnce the repository is public and the module files are at the root:
1. Sign in to registry.terraform.io with your GitHub account.
2. Click Publish → Module and select the terraform-kubernetes-buttrbase repository.
3. The registry reads the README.md, variables.tf, and outputs.tf to generate the documentation page automatically.
4. Tag a release (git tag v0.1.0 && git push --tags) — the registry picks up the tag and lists it as a published version.
After publishing, consumers install from the registry instead of a local path:
module "buttrbase" {
source = "your-org/buttrbase/kubernetes"
version = "~> 0.1"
# ...
}
Versioning is your responsibility going forward. Pinning version = "~> 0.1" in consumer modules lets you ship patch fixes without breaking callers.
Done
You have a Terraform module that installs the ButtrBase backend — namespace, secrets, and Helm release — as a single terraform apply. From here:
07-deploy-with-helm.md is the ground truth for chart topology semantics, secret layout, and probe behavior. If anything in the chart behaves unexpectedly, start there.chart_path to a local chart copy, set topology_mode = "on-prem" and storage_backend = "local-fs", and follow 14-self-host-on-prem.md for the licensing path.15-set-up-federation.md connects them.