Common Terraform patterns, expressed in Salt

This page walks through the most common Kubernetes orchestration patterns that teams set up with HashiCorp’s hashicorp/kubernetes Terraform provider, and shows the equivalent Salt SLS state for each. Patterns are drawn from real production examples on the Terraform Registry, the OneUptime tutorial series, the Container Solutions examples repo, and the multi-tenancy guides from Gruntwork and HashiCorp’s own learn-tutorials.

The Salt versions use the typed state functions shipped in saltext-kubernetes >= 2.1.0. Where a kind has no typed wrapper, the generic kubernetes.manifest_present / manifest_absent states accept any Kubernetes manifest — those work for everything, including CRDs and arbitrary custom resources.

1. App deploy: Namespace + ConfigMap + Secret + Deployment + Service

The “hello world” of every Terraform Kubernetes tutorial. Five resources, declared in one config, applied as a unit.

resource "kubernetes_namespace" "app" {
  metadata { name = "myapp" }
}

resource "kubernetes_config_map" "app" {
  metadata {
    name      = "app-config"
    namespace = kubernetes_namespace.app.metadata[0].name
  }
  data = {
    LOG_LEVEL = "INFO"
    REGION    = "us-east-1"
  }
}

resource "kubernetes_secret" "app" {
  metadata {
    name      = "app-secret"
    namespace = kubernetes_namespace.app.metadata[0].name
  }
  data = { API_TOKEN = "deadbeef" }
}

resource "kubernetes_deployment" "app" {
  metadata {
    name      = "app"
    namespace = kubernetes_namespace.app.metadata[0].name
  }
  spec {
    replicas = 3
    selector { match_labels = { app = "app" } }
    template {
      metadata { labels = { app = "app" } }
      spec {
        container {
          name  = "app"
          image = "myorg/app:1.2.3"
          env_from {
            config_map_ref { name = kubernetes_config_map.app.metadata[0].name }
          }
          env_from {
            secret_ref { name = kubernetes_secret.app.metadata[0].name }
          }
          port { container_port = 8080 }
        }
      }
    }
  }
}

resource "kubernetes_service" "app" {
  metadata {
    name      = "app"
    namespace = kubernetes_namespace.app.metadata[0].name
  }
  spec {
    selector = { app = "app" }
    port {
      port        = 80
      target_port = 8080
    }
  }
}
myapp-namespace:
  kubernetes.namespace_present:
    - name: myapp

app-config:
  kubernetes.configmap_present:
    - namespace: myapp
    - data:
        LOG_LEVEL: INFO
        REGION: us-east-1
    - require:
      - kubernetes: myapp-namespace

app-secret:
  kubernetes.secret_present:
    - namespace: myapp
    - data:
        API_TOKEN: deadbeef
    - require:
      - kubernetes: myapp-namespace

app-deployment:
  kubernetes.deployment_present:
    - name: app
    - namespace: myapp
    - spec:
        replicas: 3
        selector:
          matchLabels: {app: app}
        template:
          metadata:
            labels: {app: app}
          spec:
            containers:
              - name: app
                image: myorg/app:1.2.3
                envFrom:
                  - configMapRef: {name: app-config}
                  - secretRef: {name: app-secret}
                ports:
                  - containerPort: 8080
    - require:
      - kubernetes: app-config
      - kubernetes: app-secret

app-service:
  kubernetes.service_present:
    - name: app
    - namespace: myapp
    - spec:
        selector: {app: app}
        ports:
          - port: 80
            targetPort: 8080
    - require:
      - kubernetes: app-deployment

Notable differences:

  • Salt’s require: clause replaces Terraform’s implicit attribute-reference dependency graph. The semantics are equivalent; Salt is explicit.

  • Salt accepts the kubectl-native camelCase spelling (matchLabels, containerPort, targetPort) directly. Terraform requires snake_case (match_labels, container_port, target_port).

  • For larger manifests, use kubernetes.manifest_present with a source: salt://...yaml pointing at a vanilla Kubernetes YAML file — the same YAML you’d kubectl apply -f.

2. RBAC: ServiceAccount + Role + RoleBinding

The “give a workload least-privilege” pattern. Common Terraform modules: gruntwork-io/terraform-kubernetes-namespace, aidanmelen/rbac/kubernetes.

resource "kubernetes_service_account" "reader" {
  metadata {
    name      = "pod-reader"
    namespace = "production"
  }
}

resource "kubernetes_role" "reader" {
  metadata {
    name      = "pod-reader"
    namespace = "production"
  }
  rule {
    api_groups = [""]
    resources  = ["pods"]
    verbs      = ["get", "list", "watch"]
  }
}

resource "kubernetes_role_binding" "reader" {
  metadata {
    name      = "pod-reader"
    namespace = "production"
  }
  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "Role"
    name      = kubernetes_role.reader.metadata[0].name
  }
  subject {
    kind      = "ServiceAccount"
    name      = kubernetes_service_account.reader.metadata[0].name
    namespace = "production"
  }
}
pod-reader-sa:
  kubernetes.service_account_present:
    - name: pod-reader
    - namespace: production

pod-reader-role:
  kubernetes.role_present:
    - name: pod-reader
    - namespace: production
    - spec:
        rules:
          - apiGroups: [""]
            resources: [pods]
            verbs: [get, list, watch]

pod-reader-binding:
  kubernetes.role_binding_present:
    - name: pod-reader
    - namespace: production
    - spec:
        subjects:
          - kind: ServiceAccount
            name: pod-reader
            namespace: production
        roleRef:
          apiGroup: rbac.authorization.k8s.io
          kind: Role
          name: pod-reader
    - require:
      - kubernetes: pod-reader-sa
      - kubernetes: pod-reader-role

ClusterRole / ClusterRoleBinding work the same way — replace role_present with cluster_role_present and drop the namespace: field (they are cluster-scoped).

3. Ingress with TLS via cert-manager

The standard production HTTPS pattern: cert-manager auto-issues a Let’s Encrypt cert, the Ingress references it via an annotation. Real Terraform modules: terraform-iaac/cert-manager/kubernetes, sculley/terraform-kubernetes-cert-manager.

resource "kubernetes_ingress_v1" "site" {
  metadata {
    name      = "site"
    namespace = "production"
    annotations = {
      "cert-manager.io/cluster-issuer" = "letsencrypt-prod"
    }
  }
  spec {
    ingress_class_name = "nginx"
    tls {
      hosts       = ["www.example.com"]
      secret_name = "site-tls"
    }
    rule {
      host = "www.example.com"
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = "site"
              port { number = 80 }
            }
          }
        }
      }
    }
  }
}
site-ingress:
  kubernetes.ingress_present:
    - name: site
    - namespace: production
    - metadata:
        annotations:
          cert-manager.io/cluster-issuer: letsencrypt-prod
    - spec:
        ingressClassName: nginx
        tls:
          - hosts: [www.example.com]
            secretName: site-tls
        rules:
          - host: www.example.com
            http:
              paths:
                - path: /
                  pathType: Prefix
                  backend:
                    service:
                      name: site
                      port: {number: 80}

cert-manager itself is typically installed once per cluster via its upstream Helm chart. With Salt, install the CRDs declaratively and target the controller deployment with manifest_present:

cert-manager-crds:
  kubernetes.manifest_present:
    - source: salt://cert-manager/crds.yaml

letsencrypt-prod-issuer:
  kubernetes.manifest_present:
    - manifest:
        apiVersion: cert-manager.io/v1
        kind: ClusterIssuer
        metadata: {name: letsencrypt-prod}
        spec:
          acme:
            server: https://acme-v02.api.letsencrypt.org/directory
            email: ops@example.com
            privateKeySecretRef: {name: letsencrypt-prod-key}
            solvers:
              - http01:
                  ingress: {class: nginx}
    - require:
      - kubernetes: cert-manager-crds

4. Stateful workload with PVC + StorageClass

Postgres-style: a StatefulSet with a volumeClaimTemplates block that provisions a fresh PVC per replica.

resource "kubernetes_storage_class" "fast" {
  metadata { name = "fast-ssd" }
  storage_provisioner    = "ebs.csi.aws.com"
  reclaim_policy         = "Retain"
  volume_binding_mode    = "WaitForFirstConsumer"
  allow_volume_expansion = true
  parameters             = { type = "gp3", encrypted = "true" }
}

resource "kubernetes_stateful_set_v1" "pg" {
  metadata {
    name      = "postgres"
    namespace = "production"
  }
  spec {
    service_name = "postgres-headless"
    replicas     = 3
    selector { match_labels = { app = "postgres" } }
    template {
      metadata { labels = { app = "postgres" } }
      spec {
        container {
          name  = "postgres"
          image = "postgres:16-alpine"
          port { container_port = 5432 }
          volume_mount {
            name       = "data"
            mount_path = "/var/lib/postgresql/data"
          }
        }
      }
    }
    volume_claim_template {
      metadata { name = "data" }
      spec {
        access_modes       = ["ReadWriteOnce"]
        storage_class_name = "fast-ssd"
        resources { requests = { storage = "20Gi" } }
      }
    }
  }
}
fast-ssd-sc:
  kubernetes.storageclass_present:
    - name: fast-ssd
    - spec:
        provisioner: ebs.csi.aws.com
        reclaimPolicy: Retain
        volumeBindingMode: WaitForFirstConsumer
        allowVolumeExpansion: true
        parameters:
          type: gp3
          encrypted: "true"

postgres-statefulset:
  kubernetes.statefulset_present:
    - name: postgres
    - namespace: production
    - spec:
        serviceName: postgres-headless
        replicas: 3
        selector:
          matchLabels: {app: postgres}
        template:
          metadata:
            labels: {app: postgres}
          spec:
            containers:
              - name: postgres
                image: postgres:16-alpine
                ports:
                  - containerPort: 5432
                volumeMounts:
                  - name: data
                    mountPath: /var/lib/postgresql/data
        volumeClaimTemplates:
          - metadata: {name: data}
            spec:
              accessModes: [ReadWriteOnce]
              storageClassName: fast-ssd
              resources:
                requests: {storage: 20Gi}
    - require:
      - kubernetes: fast-ssd-sc

PVCs created outside of volumeClaimTemplates (e.g. for a standalone shared volume) use the kubernetes.persistent_volume_claim_present state — same arg shape.

5. Multi-tenant namespace: Quota + LimitRange + NetworkPolicy + RBAC

The pattern teams use when carving up a shared cluster for multiple teams or environments. Every namespace gets the same scaffolding: hard resource ceilings, default container limits, default-deny network isolation, and a baseline RBAC binding.

The Terraform community standardises this as a reusable module (see gruntwork-io/terraform-kubernetes-namespace). Salt’s equivalent is one SLS file you include: or extend: per namespace.

module "team_a" {
  source        = "gruntwork-io/namespace/kubernetes"
  namespace     = "team-a"
  admin_group   = "team-a-admins"
  reader_group  = "team-a-readers"

  cpu_limit     = "10"
  memory_limit  = "16Gi"
  pod_limit     = 100
}
{% set ns = pillar['team_namespace']['name'] %}
{% set admin_group = pillar['team_namespace']['admin_group'] %}

namespace-{{ ns }}:
  kubernetes.namespace_present:
    - name: {{ ns }}
    - metadata:
        labels:
          managed-by: salt
          tenant: {{ ns }}

quota-{{ ns }}:
  kubernetes.resource_quota_present:
    - name: tenant-quota
    - namespace: {{ ns }}
    - spec:
        hard:
          requests.cpu: "{{ pillar['team_namespace']['cpu_limit'] }}"
          requests.memory: "{{ pillar['team_namespace']['memory_limit'] }}"
          pods: "{{ pillar['team_namespace']['pod_limit'] }}"

limit-range-{{ ns }}:
  kubernetes.limit_range_present:
    - name: container-defaults
    - namespace: {{ ns }}
    - spec:
        limits:
          - type: Container
            default:
              cpu: 500m
              memory: 512Mi
            defaultRequest:
              cpu: 100m
              memory: 128Mi
            max:
              cpu: 2
              memory: 4Gi

netpol-default-deny-{{ ns }}:
  kubernetes.network_policy_present:
    - name: default-deny
    - namespace: {{ ns }}
    - spec:
        podSelector: {}
        policyTypes: [Ingress, Egress]

admin-binding-{{ ns }}:
  kubernetes.role_binding_present:
    - name: tenant-admin
    - namespace: {{ ns }}
    - spec:
        roleRef:
          apiGroup: rbac.authorization.k8s.io
          kind: ClusterRole
          name: admin
        subjects:
          - kind: Group
            name: {{ admin_group }}
            apiGroup: rbac.authorization.k8s.io

Drop that SLS file into your tree (say salt/k8s/tenant/init.sls), parameterise via pillar, and state.apply per team:

# pillar/teams/a.sls
team_namespace:
  name: team-a
  admin_group: team-a-admins
  cpu_limit: "10"
  memory_limit: "16Gi"
  pod_limit: 100
salt-call state.apply k8s.tenant pillar='{"team_namespace": {"name": "team-a", ...}}'

6. HorizontalPodAutoscaler + PodDisruptionBudget

The “available under load” pair: HPA scales replicas up under CPU pressure, PDB guarantees a minimum during voluntary disruption (rolling updates, node drains).

resource "kubernetes_horizontal_pod_autoscaler_v2" "web" {
  metadata {
    name      = "web"
    namespace = "production"
  }
  spec {
    scale_target_ref {
      api_version = "apps/v1"
      kind        = "Deployment"
      name        = "web"
    }
    min_replicas = 3
    max_replicas = 20
    metric {
      type = "Resource"
      resource {
        name = "cpu"
        target {
          type                = "Utilization"
          average_utilization = 70
        }
      }
    }
  }
}

resource "kubernetes_pod_disruption_budget_v1" "web" {
  metadata {
    name      = "web"
    namespace = "production"
  }
  spec {
    min_available = "50%"
    selector {
      match_labels = { app = "web" }
    }
  }
}
web-hpa:
  kubernetes.horizontal_pod_autoscaler_present:
    - name: web
    - namespace: production
    - spec:
        scaleTargetRef:
          apiVersion: apps/v1
          kind: Deployment
          name: web
        minReplicas: 3
        maxReplicas: 20
        metrics:
          - type: Resource
            resource:
              name: cpu
              target:
                type: Utilization
                averageUtilization: 70

web-pdb:
  kubernetes.pod_disruption_budget_present:
    - name: web
    - namespace: production
    - spec:
        minAvailable: "50%"
        selector:
          matchLabels: {app: web}

7. Custom Resource Definition + Custom Resource

For operator-style workflows: install a CRD, then declare an instance of the custom type.

Terraform’s kubernetes_manifest resource is the usual choice for CRDs because the typed provider doesn’t ship custom types:

resource "kubernetes_manifest" "widget_crd" {
  manifest = yamldecode(file("${path.module}/crds/widgets.example.io.yaml"))
}

resource "kubernetes_manifest" "widget_demo" {
  manifest = {
    apiVersion = "example.io/v1"
    kind       = "Widget"
    metadata   = { name = "demo", namespace = "production" }
    spec       = { size = "large", color = "blue" }
  }
  depends_on = [kubernetes_manifest.widget_crd]
}
widget-crd:
  kubernetes.custom_resource_definition_present:
    - name: widgets.example.io
    - spec:
        group: example.io
        scope: Namespaced
        names:
          plural: widgets
          singular: widget
          kind: Widget
          shortNames: [wg]
        versions:
          - name: v1
            served: true
            storage: true
            schema:
              openAPIV3Schema:
                type: object
                properties:
                  spec:
                    type: object
                    properties:
                      size: {type: string}
                      color: {type: string}

widget-demo:
  kubernetes.manifest_present:
    - manifest:
        apiVersion: example.io/v1
        kind: Widget
        metadata: {name: demo, namespace: production}
        spec: {size: large, color: blue}
    - require:
      - kubernetes: widget-crd

manifest_present works for any kind, registered or not — it goes through the dynamic-client server-side-apply path that mirrors kubectl apply. For typed CRDs the dedicated custom_resource_definition_present state gives clearer SLS-level identity and idempotency tracking.

8. Conditional apply with test=True (Terraform’s plan)

Terraform’s terraform plan shows what would change without applying. Salt has the same affordance via test=True on any state run:

# Show changes the SLS would make, without applying them
salt-call state.apply k8s.tenant test=True

# Apply for real
salt-call state.apply k8s.tenant

Every typed *_present / *_absent state and the generic manifest_present / manifest_absent honour test=True. Under the hood they call the API with dry_run=All so server-side admission webhooks and validation rules fire — the same way Terraform’s plan catches what apply would.

What doesn’t translate yet

  • Helm releases. Terraform’s helm_release resource is the canonical way to install third-party charts. Salt’s saltext-helm (separate extension) is the equivalent and is recommended for chart-driven deployments.

  • Cloud-managed cluster provisioning (EKS, GKE, AKS). Use Salt’s cloud-provider modules (saltext-vmware, boto3-backed states, …) alongside this extension. Cluster provisioning is out of scope for the K8s API surface.

  • Provider-side authentication via exec plugins (aws-iam-authenticator, gke-gcloud-auth-plugin, …) is supported via the kubernetes.exec: pillar key — see Authentication.