Free lesson · GenAI Platform Engineering

Deploy multi-tenant infrastructure with Helm overrides

Configure Helm value overrides for per-tenant resource limits, network policies, and database schemas. Deploy and validate the multi-tenant stack end-to-end.

Course: AI Developer Platform Engineering · Chapter 6 · Multi-Tenant Architecture

Free to read — no subscription required.

Introduction

Engineers often manage dozens of tenants across a shared Kubernetes cluster, where a single misconfigured namespace can starve neighboring tenants of CPU or open unintended network paths. Manually crafting per-tenant Helm values files is error-prone and doesn't scale past a handful of tenants. By the end of this lesson, you'll be able to build a Python-driven pipeline that generates Helm value overrides from a tenant registry, applies namespace isolation and resource quotas atomically, and rolls out tenant infrastructure in safe, health-checked batches.

Key Terminology

  • TenantSpec — A Python dataclass that encodes every per-tenant configuration parameter — tenant_id, namespace, CPU/memory/storage limits, allowed_ingress_cidrs, and db_schema — serving as the single input contract for both the renderer and the deployer.
  • TenantHelmRenderer — The class responsible for merging a TenantSpec with PLATFORM_DEFAULTS and serializing the result to a per-tenant YAML values file; its render_values method performs copy.deepcopy(PLATFORM_DEFAULTS) before applying overrides so that batch renders stay isolated from one another.
  • PLATFORM_DEFAULTS — The module-level Python dict that defines cluster-wide baseline configuration (replicaCount, image coordinates, quota sizes, and network policy) from which every tenant's values file is derived; it is never mutated directly — only deep-copied per render call.
  • Atomic Helm release — A release deployed with the --atomic flag, which causes Helm to roll back automatically to the last successful revision if any resource fails to reach a healthy state, ensuring the cluster is never left in a partially-upgraded condition.
  • Deterministic release name — The convention of naming every Helm release tenant-{tenant_id}, which allows helm upgrade --install to idempotently target the correct existing release on subsequent runs without embedding timestamps or environment labels that would create duplicate releases.
  • Per-tenant values file — The YAML artifact written to {tenant_id}-values.yaml by write_values_file before the helm subprocess call; because it is named after the tenant and persists on disk, it serves as a diffable audit artifact operators can inspect during incident review without needing live cluster access.

Concepts

From Registry to Values File: Separating Rendering from Deployment

The pipeline enforces a deliberate split between two concerns that are easy to conflate: deciding what configuration a tenant needs and applying that configuration to the cluster. TenantHelmRenderer owns the first step — it merges a TenantSpec with PLATFORM_DEFAULTS and writes the result to a named YAML file on disk. deploy_tenant owns the second step — it reads that file and hands it to helm. This separation matters because the values file becomes a first-class artifact. Operators can diff tenant-a-values.yaml against a previous backup during a post-mortem without kubectl access, and a CI pipeline can validate the rendered YAML against a schema before any cluster state is touched. Without the file, values exist only inside the running Python process and vanish at process exit; with it, every deployment decision is recorded in a form that survives the process (see Code Walkthrough).

Loading diagram...

Preventing Cross-Tenant Value Leakage with Deep Copy

PLATFORM_DEFAULTS is a module-level dict shared by every TenantHelmRenderer instance in the same Python process. If render_values mutated that dict in place, the first tenant render would overwrite the platform defaults for every subsequent tenant in the batch — silently propagating one tenant's CPU limit or ingress CIDRs to all others.

copy.deepcopy(PLATFORM_DEFAULTS) creates a fully independent copy of the nested structure, so each call to render_values operates on its own tree. Mutations like merged["resourceQuota"]["cpu"] = self.spec.cpu_limit are scoped to that single render call and never reach the shared baseline. This property is what makes batch deployments safe: no matter how many TenantSpec objects are processed in sequence, PLATFORM_DEFAULTS reads identically for each one.

Atomic Upgrades and Idempotent Release Naming

Two arguments in the helm upgrade --install call collaborate to make repeated deployments safe. --install makes the command idempotent: if no release named tenant-{tenant_id} exists in the target namespace, Helm creates it; if it already exists, Helm upgrades it. The same deploy_tenant function therefore handles both first-time provisioning and subsequent configuration changes without branching logic in the caller.

--atomic closes the gap between "Helm accepted the manifest" and "the cluster is actually healthy." Without it, a failed pod rollout leaves the release in a failed state that blocks future upgrades until a human manually rolls back. With --atomic, Helm waits for all resources to pass their readiness checks; if any fail within the timeout, it automatically reverts the release to its previous revision. From the pipeline's perspective, deploy_tenant returns False, and the batch runner can re-queue or alert without touching cluster state directly.

The deterministic release name tenant-{tenant_id} ties both behaviors together. Because the name is stable across runs, --install always locates the correct existing release, and --atomic always knows which release to revert. A name containing a timestamp or deploy-run ID would create a fresh release on every invocation, making rollback semantics undefined and helm list output increasingly cluttered with orphaned releases.

Code Walkthrough

Now that you understand idempotent releases, value-file versioning, and deterministic release naming, the next step is writing the Python module that drives every deployment in the pipeline.

The TenantHelmRenderer class is the engine. It accepts a TenantSpec dataclass — which carries the tenant's identifier, namespace, resource limits, allowed ingress CIDRs, and database schema — merges those values with platform-wide defaults, and serializes the result to a per-tenant YAML file. A separate deploy_tenant function reads that values file and calls helm upgrade --install with --atomic, so any failed release rolls back automatically to its previous revision. Using the deterministic release name tenant-{tenant_id} means subsequent deployments always target the correct Helm release.

Code snippetpython
1from dataclasses import dataclass, field 2from pathlib import Path 3import subprocess 4import yaml 5import copy 6 7PLATFORM_DEFAULTS = { 8 "replicaCount": 2, 9 "image": {"repository": "platform/tenant-controller", "tag": "v2.8.1"}, 10 "resourceQuota": {"cpu": "4", "memory": "8Gi", "storage": "50Gi"}, 11 "networkPolicy": {"allowClusterInternal": True, "allowedIngressCIDRs": []}, 12} 13 14@dataclass 15class TenantSpec: 16 tenant_id: str 17 namespace: str 18 cpu_limit: str = "4" 19 memory_limit: str = "8Gi" 20 storage_limit: str = "50Gi" 21 allowed_ingress_cidrs: list = field(default_factory=list) 22 db_schema: str = "" 23 24class TenantHelmRenderer: 25 def __init__(self, spec: TenantSpec, values_dir: Path): 26 self.spec = spec 27 self.values_dir = values_dir 28 29 def render_values(self) -> dict: 30 merged = copy.deepcopy(PLATFORM_DEFAULTS) 31 merged["resourceQuota"]["cpu"] = self.spec.cpu_limit 32 merged["resourceQuota"]["memory"] = self.spec.memory_limit 33 merged["resourceQuota"]["storage"] = self.spec.storage_limit 34 merged["networkPolicy"]["allowedIngressCIDRs"] = self.spec.allowed_ingress_cidrs 35 merged["namespace"] = self.spec.namespace 36 merged["dbSchema"] = self.spec.db_schema 37 return merged 38 39 def write_values_file(self) -> Path: 40 values = self.render_values() 41 out_path = self.values_dir / f"{self.spec.tenant_id}-values.yaml" 42 out_path.write_text(yaml.dump(values, default_flow_style=False)) 43 return out_path 44 45def deploy_tenant(spec: TenantSpec, chart_path: str, values_dir: Path) -> bool: 46 renderer = TenantHelmRenderer(spec, values_dir) 47 values_file = renderer.write_values_file() 48 release_name = f"tenant-{spec.tenant_id}" 49 cmd = [ 50 "helm", "upgrade", "--install", release_name, chart_path, 51 "--namespace", spec.namespace, 52 "--create-namespace", 53 "--values", str(values_file), 54 "--atomic", 55 ] 56 result = subprocess.run(cmd, capture_output=True, text=True) 57 return result.returncode == 0

The render_values method performs a deep copy of PLATFORM_DEFAULTS before applying tenant-specific overrides, ensuring that no mutation leaks across tenant renders when deploying a batch. The write_values_file method writes the YAML to a path named after the tenant ID, giving operators a committed artifact to diff during incident review. The deploy_tenant function wraps the renderer and shells out to Helm — passing --atomic guarantees that the cluster is never left in a partially-upgraded state, and the tenant-{tenant_id} release name keeps releases discoverable without embedding timestamps or environment names that would confuse subsequent upgrades.

Confirm that after calling deploy_tenant for a tenant, running helm list -n <namespace> shows the release in deployed status and kubectl get resourcequota -n <namespace> reflects the CPU, memory, and storage limits you specified in the TenantSpec.

Do's and Don'ts

Having walked through the renderer, the values-file artifact, and the atomic Helm upgrade, the following guardrails distill the failure modes most likely to bite during batch tenant deployments.

Do's

  1. Do use copy.deepcopy(PLATFORM_DEFAULTS) before applying per-tenant overrides in render_values — sharing the same mutable dict across a batch deployment causes one tenant's CPU or ingress CIDR settings to silently contaminate every subsequent tenant rendered in the same process.
  2. Do use the deterministic release name tenant-{tenant_id} in every helm upgrade --install invocation — embedding timestamps or environment suffixes creates orphaned releases that helm list can't associate with the tenant registry, breaking idempotent re-runs and leaving stale quota objects in the namespace.
  3. Do pass --atomic to helm upgrade --install in deploy_tenant — without it, a failed mid-upgrade Helm release stays in a broken intermediate state, leaving a tenant's ResourceQuota and NetworkPolicy partially applied and potentially starving or exposing neighboring tenants until a manual rollback is performed.

Don'ts

  1. Don't mutate PLATFORM_DEFAULTS directly inside render_values instead of deep-copying it — any in-place write to the resourceQuota or networkPolicy sub-dicts permanently alters the shared defaults dict, so later calls to TenantHelmRenderer for other tenants will inherit the previous tenant's limits rather than the intended platform baseline.
  2. Don't omit --create-namespace from the helm upgrade --install command when the target namespace doesn't yet exist — Helm will error out before any ResourceQuota or NetworkPolicy objects are created, leaving the tenant with no namespace isolation rather than surfacing a clear provisioning failure.
  3. Don't write the values file to a shared or unnamed path instead of the per-tenant {tenant_id}-values.yaml path produced by write_values_file — overwriting a single shared file during a batch run means concurrent or sequential tenant deployments read each other's YAML, misapplying ingress CIDRs and resource quotas across tenants with no error at the Helm layer.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Platform Engineering subscription.

From · cancel anytime

More free lessons in AI Developer Platform Engineering

All free lessons in GenAI Platform Engineering