ferkakta.dev

One module block per service per tenant

Every tenant on my platform gets three services: an API server, an auth service, and a frontend. Each one is a single module block in Terraform that creates a Kubernetes deployment, a ClusterIP service, an ALB ingress, IRSA for AWS access, ESO-synced secrets from SSM, and a feature flag discovery mechanism. The module is the same for all three services. The variables are different.

I extracted it into an open source module because I kept explaining the design decisions to people who asked “how do you deploy services to EKS?” and the answer was always “let me show you the module.” The module is the answer.

Refuse mutable tags

The first opinion the module enforces is that latest is not a valid image tag.

variable "image_tag" {
  validation {
    condition     = var.image_tag != "latest" && length(var.image_tag) > 0
    error_message = "Image tag must be specified and cannot be 'latest'."
  }
}

This isn’t pedantry. I’ve been burned by latest resolving to different images on different nodes, by latest meaning “whatever was pushed last from any branch,” and by latest making rollbacks meaningless because you can’t roll back to a tag that keeps moving. The module also accepts an optional image_digest for full immutability — tag plus digest means the exact bytes are pinned.

Security contexts are defaults, not options

Every container gets the same CMMC Level 2 hardened security context:

security_context {
  allow_privilege_escalation = false
  read_only_root_filesystem  = true
  run_as_non_root            = true
  run_as_user                = 1000
  capabilities {
    drop = ["ALL"]
  }
}

This isn’t a variable you enable. It’s the default on every container the module creates, including sidecars. If your app needs a writable filesystem, you mount an emptyDir at /tmp — the module has tmp_volume_enabled = true by default for exactly this reason. The root filesystem stays read-only.

I learned this the hard way when gunicorn 25.x added a control server that creates a Unix socket in the working directory. Read-only root filesystem, silent failure, error buried in crash logs from a different problem. The security context caught a bad assumption in a dependency upgrade before it became a runtime vulnerability.

Secrets belong in SSM, not in Terraform

The module creates ExternalSecret custom resources that sync AWS SSM Parameter Store to Kubernetes Secrets. Terraform never sees the secret values — it just tells ESO where to look.

ssm_secrets = {
  DATABASE_URL = "/ramparts/dev/tenants/momcorp/apiserver/DATABASE_URL"
  REDIS_PASSWORD = "/ramparts/dev/tenants/momcorp/apiserver/REDIS_PASSWORD"
}

A checksum annotation on the deployment triggers a rolling update when the synced secret changes. The pod restarts, picks up the new values, and Terraform’s state file never contained a password.

Feature flags are a separate secret

The module has a second ExternalSecret specifically for feature flags. It uses ESO’s dataFrom.find to walk an SSM path prefix and discover any parameter matching FEATURE_FLAG_*:

ssm_feature_flag_prefix = "/ramparts/dev/tenants/momcorp/apiserver"

New flags are an SSM put-parameter. No Terraform change, no PR, no plan/apply cycle. The deployment loads the feature flag secret via envFrom, so every key in the secret becomes an environment variable. I wrote about this pattern in detail in from feature_flags import *.

The flags live in a separate Kubernetes Secret from the main secrets because envFrom loads every key. If the flags were in the same secret as DATABASE_URL and REDIS_PASSWORD, those would also be loaded via envFrom — duplicating the explicit env entries and creating a confusing precedence situation. Separate secret, clean boundary.

Sidecars get the same treatment

The module accepts a sidecars list — additional containers in the same pod, each with their own image, port, env vars, and resource limits. Every sidecar inherits the same CMMC security context and gets its own /tmp mount.

sidecars = [
  {
    name         = "copilotkit-runtime"
    image        = "012345678901.dkr.ecr.us-east-1.amazonaws.com/copilotkit-sidecar:v1"
    port         = 3000
    ingress_path = "/api/copilotkit"
    env = {
      AGNO_HOST = "http://apiserver:8000"
    }
  }
]

If a sidecar has a port and an ingress path, the module exposes that port on the Kubernetes Service and adds an ALB ingress rule routed directly to it. The sidecar is reachable from the internet through the same ALB as the primary service, on its own path. No separate service, no separate ingress resource — one module block handles it.

The probe problem

The default probe timings in most Helm charts and Terraform modules assume your app boots in 10 seconds. Mine doesn’t. The API server loads weasyprint, initializes agno agents, runs alembic migrations, and boots gunicorn workers — 90 seconds on a good day. The module’s startup probe defaults give it 5 minutes of runway:

startup_probe = {
  period_seconds    = 5
  timeout_seconds   = 2
  failure_threshold = 60
}

The startup probe runs first. Liveness and readiness don’t start until the startup probe passes. This means a slow-booting app gets 5 minutes to start without the liveness probe killing it mid-migration. The values are overridable per service — the API server gets a longer leash than the frontend.

One block per service per tenant

The result is that adding a service to a tenant looks like this:

module "apiserver" {
  source       = "github.com/fizz/terraform-k8s-service-deployment"
  service_name = "apiserver"
  tenant_name  = "tenant-momcorp-abc123"
  namespace    = kubernetes_namespace.tenant.metadata[0].name
  # ... image, env, secrets, probes, ingress
}

Every opinion — immutable tags, read-only filesystems, ESO-synced secrets, feature flag discovery, CMMC security contexts — is a default that applies to every service on every tenant. The module is the guardrail. The variables are the flexibility.

github.com/fizz/terraform-k8s-service-deployment

#terraform #kubernetes #platformengineering #aws