| name | iac-discipline |
| description | Use this skill when working on Yandex Cloud infrastructure — provisioning clusters, databases, buckets, VMs; writing OpenTofu / Argo CD / Crossplane; setting up GitLab CI pipelines for infra; onboarding a new tenant; or reviewing a `tofu plan`. Encodes the modern 2026 default stack (OpenTofu Stacks + Argo CD + Crossplane + external-secrets → YC Lockbox), the blast-radius classification for applies, and the discipline rules that keep IaC repeatable — plan-as-review-artifact, no long-lived tokens, semver-pinned modules, no manual UI steps in bootstrap. |
IaC Discipline
Rule: infrastructure code is provable, not persuadable. The deliverable is not «it worked once from my laptop», it is «tofu plan in a merge request reads clean, matches the SPEC, and applies idempotently on a fresh account». Everything below exists to make that possible.
Read this skill in full before touching any infrastructure code, opening an infra MR, or reviewing an infra plan. For code style, tests, and GitOps details, cite the referenced skills — don't restate them.
Default stack (2026 modern)
Use these unless the ticket says otherwise:
| Layer | Choice | Notes |
|---|
| Provisioning | OpenTofu 1.10+ | Not Terraform. Community-standard fork since 2024. |
| Multi-env | OpenTofu Stacks | Native 1.9+ feature. Groups modules into stacks; each env is a deployment of the stack. Replaces Terragrunt's DRY layer. |
| State backend | YC Object Storage + YDB locks + KMS encryption | S3-compatible, native YC. Never local state past the bootstrap step. |
| Module registry | GitLab Terraform Module Registry (native) | Modules pinned by semver tag (version = "2.2.5"), never main, never a branch. |
| k8s app delivery | Argo CD with app-of-apps + ApplicationSet | No helm upgrade from CI. CI builds images; Argo CD syncs manifests. |
| k8s day-2 resources | Crossplane with YC provider | App teams request DBs / buckets via kubectl apply on a Claim; platform team owns Compositions. |
| Secrets | YC Lockbox + external-secrets operator | Never env vars, never k8s Secret checked in, never plain values in Helm values. |
| Cluster boot | cert-manager · external-dns · external-secrets · reloader · kyverno | Applied by Argo CD from a cluster-bootstrap/ app-of-apps. |
| Observability | VictoriaMetrics + VictoriaLogs + Grafana | VL replaced Loki by 2025 benchmarks. YC Monitoring is opt-in for small envs. |
| CI/CD auth | GitLab OIDC → YC IAM federation ($CI_JOB_JWT_V2) | No long-lived tokens. No YCLOUD_TOKEN env var. |
| CI plan pattern | tofu plan on MR → comment; apply on merge as manual job on protected environment | Never auto-apply on merge. Never apply from a laptop past bootstrap. |
| Version bumps | Renovate | Modules, providers, base images. Auto-MR, human-approved. |
| Security scan | checkov + tflint + trivy config | Pre-commit + CI. Blocks MR on high-severity. |
Style / testing / GitOps details are elsewhere. Cite these, don't restate:
- HCL style →
terraform-style-guide skill
- Module tests (
.tftest.hcl) → terraform-test skill
- Module library shape (
main.tf / variables.tf / outputs.tf / README.md) → terraform-module-library skill
- Argo CD setup + sync policies →
gitops-workflow skill
- GitLab CI stanzas (caching, artifacts, needs graph) →
gitlab-ci-patterns skill
Repository shape
One repo per tenant. Do not co-mingle tenants — separate GitLab project, separate YC organization/cloud, separate state buckets.
<tenant>-infra/
├── .gitlab-ci.yml # plan-on-MR + manual apply per env
├── .pre-commit-config.yaml # tofu fmt · tflint · checkov · trivy config
├── CLAUDE.md # tenant-specific overrides
├── kb/
│ ├── conventions.md # naming, tagging, tenant-specific rules
│ ├── verify.md # verification commands (tofu fmt/plan/tflint/...)
│ ├── architecture.md # this tenant's topology
│ └── document.md # what to document per module
├── bootstrap/ # LOCAL state, applied once per tenant
│ └── main.tf # folder + KMS + state bucket + YDB lock table + IAM
├── stacks/
│ ├── platform/ # per-region cluster + shared services
│ │ ├── stack.tofu # OpenTofu Stacks manifest
│ │ ├── components/
│ │ │ ├── vpc/
│ │ │ ├── k8s/
│ │ │ ├── postgresql/
│ │ │ ├── object-storage/
│ │ │ └── lockbox/
│ │ └── deployments/
│ │ ├── dev.tfdeploy.hcl
│ │ ├── stage.tfdeploy.hcl
│ │ └── prod.tfdeploy.hcl
│ └── app/ # per-app / per-team stacks if needed
├── argocd/
│ ├── bootstrap/ # root App-of-Apps (applied once)
│ ├── platform/ # cluster-wide operators (cert-manager, esso, kyverno, ...)
│ └── apps/ # ApplicationSet per team/service
└── crossplane/
└── compositions/ # YC DB / bucket / IAM claims for app teams
Guardrails:
- One stack per bounded lifecycle. VPC + k8s + core services in
platform; app-team resources via Crossplane, not in the platform stack.
- Each stack's
deployments/ directory is the ONLY place env-specific values live. Modules never count = var.env == "prod".
- No file above 300 lines in a component. Split.
Bootstrap — the chicken-and-egg
You cannot store OpenTofu state remotely until the state backend exists. The bootstrap/ module solves this in ONE apply from a laptop, then never gets touched again by humans.
What bootstrap/ provisions (in order, in ONE tofu apply):
- YC folder for infra artifacts
- YC KMS key for state encryption
- YC Object Storage bucket for state (versioning ON, KMS-encrypted)
- YC YDB table
<tenant>-lock-table with column LockID (String) as partition key — the state lock. Provision via yandex_ydb_table resource, NOT via console UI.
- YC service account for GitLab CI federation, with the minimum IAM roles per environment
- YC IAM workload identity federation binding to the GitLab issuer (
https://<gitlab-host>) with subject match project_path:<group>/<tenant>-infra
The bootstrap module uses local state (terraform { backend "local" {} }). After apply:
- Commit the outputs (bucket name, KMS key ID, YDB table name, SA ID, federation subject) to
bootstrap/outputs.json — this is what every stack references.
- The local
terraform.tfstate from bootstrap is stored in Lockbox (bootstrap-state), not in git. If bootstrap ever needs re-apply: fetch state from Lockbox, run, push back.
- All subsequent stacks use the remote backend from step 3.
Never manually click in YC Console to create the lock table. Never commit bootstrap state to git. Never re-run bootstrap without fetching Lockbox state first — you will orphan the KMS key and lose the state bucket.
Modules
Shape details in terraform-module-library skill. Style in terraform-style-guide. Tests in terraform-test.
Blast-radius classification
Every plan lives on one of four tiers. Tier determines who can apply and how.
| Tier | Scope | Who applies | How |
|---|
| T0 Bootstrap | bootstrap/ — folder, KMS, state bucket, YDB lock, IAM federation | Human only, laptop | Once per tenant. Never in CI. |
| T1 Prod-destructive | k8s cluster delete, DB delete, on_delete change on tenant FK, KMS key rotation, IAM role removal, bucket lifecycle change on prod bucket | Human only, protected env | Manual GitLab job on main, per-command confirmation in MR. Two reviewers. |
| T2 Prod-safe | non-destructive changes on prod stack — new module, added output, larger node group, new secret | GitLab CI manual job | Protected env, one reviewer, manual trigger. |
| T3 Non-prod | any change on dev/stage | GitLab CI auto-apply on merge | Merged MR → CI tofu apply runs on-push. |
Classification is not the author's opinion — it's derived from the plan. MR pipeline runs tofu plan and posts the tier as a comment based on resource actions detected (-/+ replace, -delete, IAM changes). Author overrides with justification if needed.
A T1 plan without a REVIEW comment from a second engineer is not applied. Ever.
Secrets
- Storage: YC Lockbox, one payload per logical secret. No k8s Secret objects in git.
- Consumption:
external-secrets operator in every cluster, ExternalSecret resource references a SecretStore bound to Lockbox via the platform SA.
- App teams: request via
ExternalSecret in their Argo CD app manifest, never a raw k8s Secret.
- CI: GitLab masks values via CI variables — but the values themselves are set as CI variables from a Lockbox fetch step at pipeline start, not stored in GitLab.
- Rotation: Lockbox payload versions + external-secrets
refreshInterval: 1h. Application redeploy triggered by reloader on annotation change.
Never: SECRET_KEY = "..." in Helm values. sops in git (deprecated for new envs — was a stopgap before Lockbox). Long-lived service account keys.
CI/CD
Two pipelines:
1. Infra MR pipeline (on every push to feature branch)
Stages:
pre-commit — tofu fmt -check, tflint, checkov, trivy config
validate — tofu validate per stack
plan — tofu plan -out=plan.tfplan per changed stack, one job per env. Uploads plan artifact.
plan-comment — posts plan summary to MR via glab mr note. Classifies blast-radius, marks with 🟢 T3 / 🟡 T2 / 🔴 T1 / ⛔ T0.
security — checkov + tfsec against plan
cost — infracost or yc-cost-estimator (if wired), MR comment with cost delta
MR cannot merge without: clean plan, security = zero high-severity, human approval matching the blast tier.
2. Infra deploy pipeline (on merge to main)
- T3 (dev/stage): auto-apply
- T2/T1 (prod): manual jobs on protected environment.
apply job downloads the plan artifact from the MR pipeline (same plan file, no re-plan → guarantees «what was reviewed is what applies»).
- Every apply logs to CI + posts back to the source MR: apply timestamp, tofu version, provider versions, resources changed.
Auth: GitLab OIDC → YC IAM federation. id_tokens: block in .gitlab-ci.yml binds a token audience to the tenant's YC federation issuer. Service account gets short-lived (10 min) token per job. No YC_TOKEN variable in GitLab. No SA key checked in.
Stanzas / caching / needs graph → gitlab-ci-patterns skill.
GitOps for k8s
- Argo CD in every cluster, self-managed (Argo CD manages Argo CD via app-of-apps).
- Repo layout under
argocd/:
bootstrap/ — the root App-of-Apps, applied once via kubectl apply after cluster stand-up
platform/ — cluster-wide operators (cert-manager, external-dns, external-secrets, reloader, kyverno, VictoriaMetrics stack)
apps/ — ApplicationSet per team/service, drives multi-env via cluster generator or list generator
- Sync policies: automated with
prune: true + selfHeal: true for non-prod. Manual sync for prod, but auto-detect drift (alert only).
- App containers built by app-team CI, image tag written to
argocd/apps/<app>/values.yaml by their pipeline. Argo CD picks up the change.
- No
helm upgrade from CI. No kubectl apply from CI past bootstrap.
Full Argo CD setup / debug in gitops-workflow skill.
Crossplane for day-2 resource requests
Purpose: app teams shouldn't open an MR in <tenant>-infra to get a new Postgres DB. They should apply a Claim in their app repo and Crossplane provisions it.
Compositions are still Terraform-equivalent — same review discipline, same blast-radius rules apply to changes in crossplane/compositions/. Changes to a Composition can trigger reconciliation on every Claim; treat as T2 minimum.
Observability
- Metrics: VictoriaMetrics single-node for dev/stage, cluster for prod. Grafana Operator for dashboards.
- Logs: VictoriaLogs (replaced Loki 2025). App teams tag with
app_name + env.
- Traces: OpenTelemetry Collector → VictoriaTraces (or Tempo if not yet in VM stack for this tenant).
- Alerts: vmalert rules in git, PromQL. Route via Alertmanager to on-call.
- YC Monitoring: opt-in for small envs where self-hosted VM is overkill.
- Never bake dashboards / alerts by hand in Grafana UI — everything as code under
argocd/platform/observability/.
New tenant onboarding — checklist
For a fresh YC tenant (e.g. new client bootstrap):
- Create YC organization (or use an existing one; separate cloud is minimum).
- Create GitLab group
<tenant> and project <tenant>-infra. Enable Terraform Module Registry and protected environments.
- Clone
template-tenant-infra repo skeleton, replace tenant name, adjust bootstrap/main.tf inputs (folder name, KMS purpose, bucket name, SA name).
- Locally:
cd bootstrap && tofu init && tofu apply. Answer prompts. Store terraform.tfstate in Lockbox as <tenant>-bootstrap-state.
- Commit
bootstrap/outputs.json (public data) to git.
- Enable GitLab OIDC federation with the outputs from step 4.
- Push to
main; CI runs plan for stacks/platform/ (empty deploy on first run, but proves auth works).
- Fill in
stacks/platform/components/ (VPC → k8s → PG → Redis → buckets). One MR per component, tier T3 auto-applies to dev.
- Bootstrap Argo CD:
kubectl apply -f argocd/bootstrap/root-app.yaml, wait for cluster-bootstrap apps to converge.
- First app deploy via Argo CD ApplicationSet. Never a manual
helm install.
Total time on a familiar template: ~2–3 hours to reach a running dev cluster with observability + secrets pipeline.
Verification commands (kb/verify.md template)
Every infra repo's kb/verify.md includes:
tofu fmt -check -recursive
tofu validate
tflint --recursive
checkov -d .
trivy config .
tofu plan -out=/tmp/plan.tfplan
tofu show -json /tmp/plan.tfplan | jq '.resource_changes | group_by(.change.actions[0]) | map({action: .[0].change.actions[0], count: length})'
tofu plan -detailed-exitcode
argocd app list -o wide
argocd app diff <app>
kubectl get compositions,claims,composed -A
Never do
- Never
?ref=main on a module. Semver tag or nothing.
- Never apply on prod from a laptop. Even T1. Especially T1.
- Never store
terraform.tfstate in git. Not even bootstrap state. Lockbox or nothing.
- Never store secrets in Helm values. Lockbox + external-secrets.
- Never manually click in YC Console to create a resource that other resources depend on. If it's a dependency, it's in the bootstrap module or in a stack. UI operations are for one-off inspection, not provisioning.
- Never
--auto-approve in CI on any tier past T3. T2/T1 = manual job with human trigger.
- Never re-plan in the apply job. Download the plan artifact from the MR pipeline. Re-planning defeats the review — the world can shift between MR review and merge.
- Never mix tenants in one state file. Different tenant → different repo → different state bucket → different KMS key.
- Never let
helm upgrade run outside Argo CD past bootstrap. Argo CD owns k8s state; anything else is drift.
- Never long-lived service account keys in GitLab CI variables. OIDC federation only.
- Never bypass Crossplane guardrails by directly provisioning YC resources for an app team via MR. If a Composition is missing a size / region / feature, extend the Composition. Ad-hoc bypass = drift + tech debt.
- Never skip the blast-radius classification on a plan review. If the MR pipeline classification looks wrong, argue in the MR — don't silently override.