🎯 The Problem: Manifest Sprawl
Kubernetes manifests start simple — a Deployment YAML and a Service YAML. Then a ConfigMap, then a Secret reference, then you need dev and prod variants, then a second service, then onboarding a new team... Without intentional structure, you end up with a flat folder of 200 YAML files where nobody knows what owns what or how to promote a change safely.
This lesson establishes the mental models and concrete patterns for organizing manifests at every scale. The key insight: the right structure is not universal — it scales with your team size and deployment complexity.
The three-scale evolution
SMALL 1–5 devs
One repo, flat structure, environment differences as value overrides. Simplicity wins. Optimize later.
MEDIUM 5–20 devs
Separate infra repo, Helm charts per service, overlays per environment, GitOps pipeline with ArgoCD or Flux.
ENTERPRISE 20+ devs
Platform team owns base charts + golden paths. Product teams own value files only. Tenant model with guardrails.
SMALL Pattern 1 — App-in-Repo (Co-located Manifests)
For a small team with one or a few services, keep manifests in the same repository as the application code. No separate infra repo, no Helm overhead. Fast to iterate, easy to understand.
my-app/
├── src/ # application source code
├── Dockerfile
├── k8s/ # ALL manifests live here
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ ├── configmap.yaml
│ │ └── kustomization.yaml # ties base together
│ ├── overlays/
│ │ ├── dev/
│ │ │ ├── kustomization.yaml
│ │ │ └── patch-replicas.yaml # dev: 1 replica
│ │ └── prod/
│ │ ├── kustomization.yaml
│ │ └── patch-replicas.yaml # prod: 3 replicas, HPA
└── .github/workflows/
└── deploy.yaml # CI: build image → kustomize build → kubectl apply
Small-scale Kustomize base
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
images:
- name: my-app
newTag: latest # CI overrides: kustomize edit set image my-app=ghcr.io/org/app:v1.2.3
# k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-replicas.yaml
namePrefix: prod- # optional: prefix all resource names
namespace: production
ENTERPRISE Pattern 3 — Platform Team + Tenant Model
At 20+ developers across multiple teams, the medium pattern breaks down: teams fight over the charts/ directory, base charts accumulate too many feature flags, and onboarding a new team takes days. The enterprise pattern separates platform concerns from product concerns.
platform-infra/ # owned by Platform Team
├── base-charts/ # golden path charts (opinionated)
│ ├── web-service/ # any HTTP service
│ ├── worker-service/ # any background worker
│ ├── cronjob/ # any scheduled task
│ └── postgres-instance/ # managed DB provisioning
│
├── platform/ # cluster-level config
│ ├── clusters/
│ │ ├── prod-eu-west-1/
│ │ │ ├── platform-apps.yaml # ArgoCD AppOfApps
│ │ │ └── cluster-config/
│ │ └── prod-us-east-1/
│ ├── addons/ # cert-manager, external-dns, etc.
│ └── policies/ # Kyverno/OPA baseline policies
│
└── tenants/ # one directory per product team
├── team-payments/
│ ├── namespace.yaml
│ ├── rbac.yaml
│ └── apps/
│ ├── checkout-api.yaml # ArgoCD Application
│ └── payment-worker.yaml
└── team-catalog/
├── namespace.yaml
└── apps/
product-team-payments/ # owned by Payments Team
├── services/
│ ├── checkout-api/
│ │ ├── src/
│ │ ├── Dockerfile
│ │ └── deploy/ # THIS is all a product team owns
│ │ ├── values.yaml # base values for their service
│ │ ├── values.dev.yaml
│ │ ├── values.staging.yaml
│ │ └── values.prod.yaml # PR to this = prod deploy
│ └── payment-worker/
│ └── deploy/
└── .github/
└── workflows/
└── update-image-tag.yaml # CI: PR to platform-infra on merge
The App-of-Apps pattern with ArgoCD
In large clusters, you don't want to manually create ArgoCD Application objects. The App-of-Apps pattern uses one root ArgoCD Application that watches a directory of other Application manifests and deploys them automatically — self-bootstrapping the entire cluster:
# platform/clusters/prod-eu-west-1/platform-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-apps
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "-1" # deploy infra before apps
spec:
source:
repoURL: https://github.com/myorg/platform-infra
path: tenants/ # watches ALL team application files
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
📐 Naming Conventions — The Rules That Save You
| Resource | Convention | Example | Why |
|---|---|---|---|
| Namespace | team-env or team | payments-prod, platform | Team ownership clear; RBAC maps cleanly |
| Helm release | service-env | checkout-api-prod | Unique across namespace; easy to grep |
| Image tag | v{semver} or {branch}-{sha:7} | v2.4.1, main-abc1234 | Traceability from deploy back to commit |
| Config labels | app.kubernetes.io/* standard labels | app.kubernetes.io/name: checkout-api | Interoperates with Prometheus, Grafana, ArgoCD |
| Values files | values.{env}.yaml | values.prod.yaml | Alphabetically sorted; glob-friendly |
| Chart version | SemVer; bump on breaking change | 1.0.0 → 1.1.0 → 2.0.0 | Allows pinning; changelog driven |
Standard labels — always include these
# In every Deployment/Pod template (Helm helper or Kustomize commonLabels)
labels:
app.kubernetes.io/name: checkout-api
app.kubernetes.io/instance: checkout-api-prod # Helm release name
app.kubernetes.io/version: v2.4.1 # image tag
app.kubernetes.io/component: api # api / worker / web
app.kubernetes.io/part-of: payments-platform # owning system
app.kubernetes.io/managed-by: Helm # or Kustomize
MEDIUM Pattern 2 — Dedicated Infra Repo + Helm Charts
Once you have 3+ services and a real CI/CD pipeline, the manifests outgrow the app repo. A dedicated infrastructure repository becomes the single source of truth for what is deployed where. This is also the natural home for a GitOps controller like ArgoCD or Flux.
infra/ # separate git repo: "my-org/infra"
├── charts/ # Helm charts owned by this team
│ ├── frontend/
│ │ ├── Chart.yaml
│ │ ├── values.yaml # defaults
│ │ └── templates/
│ ├── api/
│ │ ├── Chart.yaml
│ │ ├── values.yaml
│ │ └── templates/
│ └── database/
│ ├── Chart.yaml
│ └── templates/
│
├── environments/ # what is deployed in each env
│ ├── dev/
│ │ ├── frontend.values.yaml # dev-specific overrides
│ │ ├── api.values.yaml
│ │ └── argocd-apps.yaml # ArgoCD Application manifests
│ ├── staging/
│ │ ├── frontend.values.yaml
│ │ └── api.values.yaml
│ └── prod/
│ ├── frontend.values.yaml
│ └── api.values.yaml
│
├── platform/ # cluster-level resources
│ ├── namespaces.yaml
│ ├── rbac/
│ ├── network-policies/
│ └── monitoring/
│
└── scripts/
└── deploy.sh # helm upgrade --install wrapper
Key discipline: values files are the interface
The chart (in charts/) is shared infrastructure. Each environment's *.values.yaml is what changes between environments. A PR changing a production value file is the deployment artifact — reviewable, auditable, rollbackable via git revert.
# environments/prod/api.values.yaml
replicaCount: 3
image:
repository: ghcr.io/myorg/api
tag: v2.4.1 # ← CI bumps this via PR on merge to main
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { memory: 512Mi }
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
ingress:
host: api.example.com
env:
LOG_LEVEL: warn
DB_HOST: postgres.production.svc.cluster.local
# environments/dev/api.values.yaml
replicaCount: 1
image:
tag: main-abc123 # latest commit on main branch
resources:
requests: { cpu: 50m, memory: 64Mi }
autoscaling:
enabled: false
ingress:
host: api.dev.example.com
env:
LOG_LEVEL: debug
DB_HOST: postgres.dev.svc.cluster.local
image.tag in the right values file. The infra repo owns what is deployed; the app repo owns what the image contains. This clean separation is the foundation of GitOps.
ArgoCD Application per environment
# environments/prod/argocd-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-prod
namespace: argocd
spec:
project: production
source:
repoURL: https://github.com/myorg/infra
targetRevision: HEAD
path: charts/api
helm:
valueFiles:
- ../../environments/prod/api.values.yaml
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true # revert manual kubectl changes