CI/CD pipeline — GitHub Actions, Docker, Kubernetes
Build one immutable, signed image in CI; promote that exact image by digest through environments in CD, each stage gated by checks and — for production — a GitHub Environment approval. Details differ per app; the sequence, the boundaries and the failure modes do not.
- CI/CD — GitHub Actions. Registry — GHCR (
ghcr.io/<org>/<app>). Build — docker buildx.
- Deploy target — a Kubernetes cluster (Deployment + Service + Ingress, via Helm or Kustomize). A single-host Docker Compose target is the lightweight option.
Reference architecture
- Delivery config separate from app code — a
deploy/ folder or a dedicated repo. Reason: different change cadence and review rules. It holds the workflows and per-environment values.
- Build once, promote by digest. One image per release candidate, identified by content digest (
@sha256:…). The same digest flows staging → production — never rebuilt or re-tagged per environment. Tag with the version (X.Y.Z), pin by digest in every manifest. Environment is a deploy-time input.
- Promotion, not branches. Trunk-based: one
main, every merge is a release candidate, promoted through workflow stages gated by tests, policy and approvals. Environment chosen by a workflow input — one parameterized workflow, not drifting copies. (Branch-per-environment works too; map the stages onto branches.)
- Keyless auth. Workflows reach the cluster and cloud APIs via GitHub OIDC, not stored long-lived credentials. GHCR uses
GITHUB_TOKEN with packages: write.
- Scoped identity. Each deploy identity is bound to one environment and expires. No human has standing write access to production; changes go through the pipeline. Break-glass is pre-approved, logged, time-boxed.
- Version marker: a signed git tag
X.Y.Z (SemVer). CI and CD fail closed when it is absent.
Core rules
- Inventory before any file. No manifest, workflow or secret declaration without the full dependency inventory (next section). Missing item → list it and stop. Never infer a value from a template default or another app.
- Zero assumptions about real config. Ports, DB hosts, domains, credentials, sizing, versions — confirmed with whoever operates the target. When migrating, every inherited value is a candidate to confirm, not copy.
- Dev/infra boundary. The
Dockerfile and source belong to the developer — do not edit them. Manifests, workflows, secrets, networking, ingress, DNS belong to infra. A build/code failure → report and wait; do not work around it in the pipeline. For a framework-level issue, say which value is missing and which layer owns it (manifest = infra; Dockerfile/app config = dev), then hand the check back.
- Build once, promote by digest. The image validated in staging is bit-for-bit the one that reaches production.
- Supply-chain integrity. CI generates an SBOM, signs the image, attaches provenance (GitHub artifact attestations); vulnerability and secret scanning are blocking gates; the cluster verifies signature and provenance before admitting the image (admission policy).
- No secret in plaintext — not in a repo, manifest, workflow, build arg, or image layer. Prefer runtime injection into the workload over writing secrets to disk or the job environment.
- Immutable infrastructure. Config change → redeploy through the pipeline, never a live
kubectl edit/exec in production. Roll the whole workload.
- Validate before commit. Render every manifest with values resolved and lint it (
helm template/lint, kustomize build, kubectl apply --dry-run=server, docker compose config); fail on any unresolved required value.
- Health-gated, progressive delivery. CD reports success only when the new version serves: readiness passed,
kubectl rollout status converged, smoke test green, error/latency SLOs held. Any failure → automatic rollback. In production use a strategy that limits blast radius (Part 1).
- Production is gated and audited. A GitHub Environment with required reviewers and a freeze policy stands between a green build and production; the approval and outcome are recorded.
- Schema changes are backward-compatible (expand/contract). Old and new code both run against the DB during a rollout; migrations are decoupled from the code deploy and safe while the previous version still serves; never destructive in the same release that stops using the column.
- Rollback is a first-class path, exercised, run through the pipeline (redeploy the previous digest) — not a manual cluster edit.
- Observe the deploy. Emit a deployment marker; track DORA metrics; correlate alerts with deploys.
- Document on completion. Record the app in the wiki (delivery repo, environments, identities, strategy, rollback command) and every deviation.
Dependency inventory
Per application, and per environment where values differ:
- Cluster + namespace; deploy path (OIDC role,
kubeconfig context)
- Every workload (Deployment / StatefulSet / CronJob) and per workload: image (own vs upstream), ports, env vars, mounted config, command/args
- Secrets: which values, where consumed, which store holds them
- External dependencies with real addresses (DB, cache, broker, SSO/LDAP, object storage, APIs) — host, port, DB/bucket
- Persistence: PVCs, storage class, size, backup/restore expectation
- Networking: who talks to whom, internal vs exposed, NetworkPolicy
- Exposure: domain, public URL, Ingress class, TLS termination and cert source, DNS record
- Health: readiness/liveness/startup endpoints, expected response, startup time, SLOs to gate on
- Resources: requests, memory limit, replica count, HPA bounds, PodDisruptionBudget
- Deployment strategy and its parameters; previous known-good digest and where it is recorded
Partial inventory → stop.
Placeholders
<org>/<repo> · <env>/<ENV> · <image> = ghcr.io/<org>/<app> · <digest> = sha256:… from CI · <workload> = Deployment name · <namespace> · <SECRET> = name identical to its manifest reference.
Common pitfalls
Manifest
- Indentation — a misindented
env:/volumeMounts:/resources: attaches to the wrong object or is dropped silently (compose: networks:/volumes: under services: become a bogus service). Render and diff.
- Unresolved reference — a value with no binding resolves empty; the workload starts without the credential. Fail the render.
- Mutable image reference —
latest, a floating minor, an overwritable tag. Pin by digest.
- Isolation — wrong namespace, or missing
Service/NetworkPolicy → unreachable.
- Naming drift — inconsistent names/labels, or a leftover
-staging string in production values.
- No
securityContext — baseline: runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, drop ALL caps, seccompProfile: RuntimeDefault.
- Probe mistakes — no readiness; liveness and readiness on the same dependency-heavy endpoint (a downstream outage restart-loops every pod); no startup probe for a slow starter.
- Single replica / no PodDisruptionBudget — a node drain takes the service down.
Workflow
- Third-party actions pinned to a tag or
@main, not a commit SHA.
- Over-broad
permissions:; secrets exposed to untrusted PR code (pull_request_target + PR-head checkout).
- Misaligned step blocks (
env:, with:).
- Build+publish for an upstream image (unneeded), or missing for one you own.
- Environment selected by drifting copy-pasted workflows instead of one parameterized workflow with an
environment: input.
- OIDC role / cluster context not scoped to the target environment.
- CD on
on: push instead of after the previous stage's gate.
Deploy execution
- Success reported on
kubectl apply without kubectl rollout status.
- A non-backward-compatible migration in the same release as the code; a migration Job run non-blocking.
- CD re-deriving the version from git instead of consuming the digest CI produced.
- Auth not refreshed each run — an expired OIDC token fails with
unauthorized.
Part 1 — Building the pipeline
Once per application: produces the parameterized workflow plus one environment (typically staging). Adding production is Part 2.
1.1 Prerequisites
- App repo;
main protected (PR + review, required status checks, linear history)
deploy/ folder (or delivery repo), same protection
- GHCR enabled;
GITHUB_TOKEN packages: write in the build job
- Per environment: a namespace + scoped ServiceAccount/RBAC, reached from Actions via an OIDC role
- A secret store for real secrets only; non-sensitive values stay in the manifest
- Cluster admission policy: verify signature + provenance, enforce Pod Security "restricted", forbid mutable tags
- GitHub Environments (
staging, production); production with required reviewers and a freeze policy
- Observability: a deployment-marker integration; a dashboard and SLO alerts
1.2 Layout
.github/workflows/ci.yml build, scan, sign, publish — emits digest + version
.github/workflows/cd.yml reusable; inputs: environment, digest
deploy/
base/ Helm chart or Kustomize base
envs/<env>/values.yaml per-environment overrides only
1.3 Deployment manifest
From the developer's reference manifest, produce base/ + envs/<env>/:
- Image
<image>@${digest} — digest supplied at deploy time, never an inline build or mutable tag
securityContext restricted; readiness + liveness + startup probes distinct; requests set, memory limit = request, no CPU limit
- Replica count, HPA and PodDisruptionBudget on every stateless workload
- Ports per the inventory; secrets via
secretKeyRef from a Secret synced by the store, never inlined
Service/Ingress/PVC at the right scope; NetworkPolicy for anything not fully public
- Remove dev-only config (debug, hot-reload, local hosts)
- The per-environment file holds only what differs (replicas, resources, hostnames)
- Validate:
helm template / kustomize build + kubectl apply --dry-run=server
1.4 CI (ci.yml)
Steps, in order: trigger on merge to main and tag v* → checkout at the tag → resolve the version from the signed tag, exit non-zero if none → docker login ghcr.io with GITHUB_TOKEN → docker buildx build --push to <image>:X.Y.Z, capture the digest from --metadata-file → generate SBOM → scan image + deps + secrets, block on policy violations → sign the digest and attach provenance → set digest and version as job outputs.
permissions: { contents: read, packages: write, id-token: write, attestations: write }
# key steps
- id: ver
run: |
TAG=$(git describe --tags --exact-match) || { echo "no release tag"; exit 1; }
echo "version=$TAG" >> "$GITHUB_OUTPUT"
- id: build
uses: docker/build-push-action@<sha>
with: { push: true, tags: "ghcr.io/${{ github.repository }}:${{ steps.ver.outputs.version }}" }
- uses: actions/attest-build-provenance@<sha>
with: { subject-name: "ghcr.io/${{ github.repository }}", subject-digest: "${{ steps.build.outputs.digest }}" }
1.5 CD (cd.yml, reusable)
Inputs: environment, digest (from CI — never re-derived). Steps: environment: on the job so GitHub enforces reviewers/rules → assume the environment's OIDC role, set the kubeconfig context → backward-compatible migration Job if any (apply, wait, abort on failure) → deploy with the digest pinned → wait for convergence + health, roll back on failure → smoke test by the service address → emit the deployment marker.
on: { workflow_call: { inputs: {
environment: { type: string, required: true }, digest: { type: string, required: true } } } }
permissions: { id-token: write, contents: read }
jobs:
deploy:
environment: ${{ inputs.environment }} # GitHub enforces reviewers / rules
steps:
- uses: actions/checkout@<sha>
- uses: <helm-setup-action>@<sha> # + your cloud's OIDC login action for the kubeconfig
- run: |
helm upgrade --install <workload> deploy/base -n <namespace>-${{ inputs.environment }} \
-f deploy/envs/${{ inputs.environment }}/values.yaml \
--set image.digest=${{ inputs.digest }} --wait --timeout 5m --atomic
kubectl rollout status deploy/<workload> -n <namespace>-${{ inputs.environment }} --timeout=5m
1.6 Wire the stages
ci.yml on merge to main → on success call cd.yml with environment: staging (automatic) → on staging success call cd.yml with environment: production (the Environment's required reviewers gate it).
1.7 Deployment strategy (production)
Pick by: can two versions run at once? and is there a metric to gate on?
- Rolling (default, stateless) —
RollingUpdate, maxSurge: 25%, maxUnavailable: 0. Needs backward-compatible changes.
- Recreate —
strategy: Recreate. Only when two versions cannot coexist; accepts downtime.
- Blue-green — a second Deployment behind the same
Service; flip the selector once healthy; keep the old for instant rollback. ~2× resources briefly.
- Canary — a small second Deployment + a weighted
Ingress split; grow the weight while watching error rate / latency, abort on regression. Strongest gate; needs reliable metrics and, in practice, a progressive-delivery controller for the analysis.
1.8 External exposure
- DNS → the Ingress controller's load balancer
Ingress in deploy/ with the environment's class and annotations
- TLS from an automated in-cluster certificate issuer, not a hand-placed key
- Expose only the intended host/path; everything else
ClusterIP + NetworkPolicy
1.9 Cutover (only when migrating an existing deployment)
Deploy the new pipeline's build to a new namespace alongside the old stack → validate → shift traffic (DNS / Ingress weight) gradually, keeping the old stack until stable → decommission the old stack and its manual access.
1.10 Post-deploy verification
- Manifest renders clean; running pods report the expected digest
- Each workload answers on its port; readiness/liveness/startup healthy;
rollout status clean
- Auth; DB and external-service access; data-writing paths; CronJobs
- Secrets resolved at runtime (nothing started empty); no plaintext secret on a pod filesystem
- Signature enforcement works — an unsigned image is rejected
- Logs and metrics reach the sinks; the deployment marker landed
- PDB present; the rollout survives a node drain; previous known-good digest recorded
Reviewing an existing set: diff against another environment's config and walk 1.3–1.6 + the pitfalls; the checks not visible in files are the protected branch, the gated Environment, per-environment secrets and OIDC trust, and the cluster admission policy.
Part 2 — Adding production
Production is another call of cd.yml, not a copy.
Reused: the workflows, the GHCR package and OIDC trust, the image (same digest), the smoke-test suite.
Add:
- A production namespace + a separate scoped OIDC role, never staging's
- Production secrets — production values differ from staging's; never reuse one
- The
production GitHub Environment: required reviewers, deployment branch/tag rule, freeze calendar
deploy/envs/production/values.yaml: replica count and sizing for real load (≠ staging), production hostnames, HPA bounds, PDB
- The production deployment strategy (1.7) and its analysis metrics
- Alert routing and on-call
Confirm real production values (do not copy from staging): DB server/instance/port/name; integration hosts; domain and public URL; certificate source; replicas, sizing, HPA bounds; canary metrics. Each confirmed with whoever operates production.
Validate: run the review; verify the Environment approval actually blocks; verify signature enforcement and Pod Security in the production cluster; rehearse a rollback in staging first.
Part 3 — Secrets
Order of preference: (1) a Secret synced from your cluster/cloud secret store by an operator or CSI driver — the value never touches Actions, rotation is automatic; (2) the workflow writes a Secret at deploy time (GitOps: commit a sealed/encrypted reference only); (3) the workflow injects the value into the deploy step's environment. Never (4) plaintext in a repo/manifest/image.
- Runtime injection (preferred): a secrets-store CSI driver or a secret-sync operator pulls from the store and materializes a
Secret; the manifest references it by secretKeyRef/envFrom. Nothing sensitive in CI.
- Workflow-mediated (fallback): authenticate to the store via OIDC in the deploy job, then
kubectl create secret generic <name> --from-literal=<SECRET>="$VALUE" --dry-run=client -o yaml | kubectl apply -f -, or inject at the step (env: with ${{ secrets.<NAME> }}) only for values the render needs as env vars.
- Rules: name identical to the manifest reference; least-privilege read; never
echo it or put it in a step name; never a Docker build-arg (it lands in a layer); rotate on schedule and on suspected exposure; keep ${{ secrets.* }} out of untrusted-PR workflows.
- TLS / file secrets: prefer an automated in-cluster issuer so no static key exists. Otherwise pull it at deploy time into a
Secret mounted readOnly, defaultMode: 0400; never commit or bake it in; rotate.
Part 4 — Operation
Triggering: CI on merge to main (release candidate) or a signed tag X.Y.Z (release), fail closed with no tag. CD staging automatic on green CI. CD production behind the Environment's required reviewers + deployment rule + freeze check.
Rollback — through the pipeline, the same mechanism as a deploy, so the audit trail holds and the next deploy does not re-ship the bad version:
- Helm —
helm rollback <release> <revision> as an immediate stop-gap, then re-run cd.yml pinning the previous digest.
- Kustomize / raw —
kubectl rollout undo deploy/<workload> stop-gap, then re-run cd.yml with the previous digest.
--atomic — a failed helm upgrade already rolled itself back; the job just fails loudly.
- Blue-green / canary — flip the selector back / drop the canary weight to zero; the stable set never left.
- Compose —
DIGEST=<previous> docker compose … up -d --wait.
The previous known-good digest comes from the last successful production deploy (every published digest is in GHCR). Verify convergence and health after a rollback as for a deploy, and open a fix-forward — a rollback is a pause, not a resolution.
1---2name: cicd-pipeline3description: Method and checklists for shipping a containerized application with GitHub Actions, Docker and Kubernetes — build once, publish a signed immutable image, promote that exact digest through gated environments to production. Covers the inventory to gather first, the dev/infra boundary, build-once/promote-by-digest, supply-chain integrity, deployment strategies, secrets, health-gated rollout, GitHub Environments approvals, and rollback. Use when asked to create or configure a pipeline, onboard an app to CI/CD, set up an environment, promote to production, or migrate a hand-run deploy to an automated one.4---56# CI/CD pipeline — GitHub Actions, Docker, Kubernetes78Build one immutable, signed image in CI; promote that exact image by digest through environments in CD, each stage gated by checks and — for production — a GitHub Environment approval. Details differ per app; the sequence, the boundaries and the failure modes do not.910- **CI/CD** — GitHub Actions. **Registry** — GHCR (`ghcr.io/<org>/<app>`). **Build** — `docker buildx`.11- **Deploy target** — a Kubernetes cluster (Deployment + Service + Ingress, via Helm or Kustomize). A single-host Docker Compose target is the lightweight option.1213## Reference architecture1415- **Delivery config separate from app code** — a `deploy/` folder or a dedicated repo. Reason: different change cadence and review rules. It holds the workflows and per-environment values.16- **Build once, promote by digest.** One image per release candidate, identified by content digest (`@sha256:…`). The same digest flows staging → production — never rebuilt or re-tagged per environment. Tag with the version (`X.Y.Z`), pin by digest in every manifest. Environment is a deploy-time input.17- **Promotion, not branches.** Trunk-based: one `main`, every merge is a release candidate, promoted through workflow stages gated by tests, policy and approvals. Environment chosen by a workflow input — one parameterized workflow, not drifting copies. *(Branch-per-environment works too; map the stages onto branches.)*18- **Keyless auth.** Workflows reach the cluster and cloud APIs via GitHub OIDC, not stored long-lived credentials. GHCR uses `GITHUB_TOKEN` with `packages: write`.19- **Scoped identity.** Each deploy identity is bound to one environment and expires. No human has standing write access to production; changes go through the pipeline. Break-glass is pre-approved, logged, time-boxed.20- **Version marker:** a signed git tag `X.Y.Z` (SemVer). CI and CD fail closed when it is absent.2122## Core rules23241. **Inventory before any file.** No manifest, workflow or secret declaration without the full dependency inventory (next section). Missing item → list it and **stop**. Never infer a value from a template default or another app.252. **Zero assumptions about real config.** Ports, DB hosts, domains, credentials, sizing, versions — confirmed with whoever operates the target. When migrating, every inherited value is a candidate to confirm, not copy.263. **Dev/infra boundary.** The `Dockerfile` and source belong to the developer — do not edit them. Manifests, workflows, secrets, networking, ingress, DNS belong to infra. A build/code failure → report and wait; do not work around it in the pipeline. For a framework-level issue, say **which value is missing** and **which layer owns it** (manifest = infra; `Dockerfile`/app config = dev), then hand the check back.274. **Build once, promote by digest.** The image validated in staging is bit-for-bit the one that reaches production.285. **Supply-chain integrity.** CI generates an SBOM, signs the image, attaches provenance (GitHub artifact attestations); vulnerability and secret scanning are **blocking** gates; the cluster verifies signature and provenance before admitting the image (admission policy).296. **No secret in plaintext** — not in a repo, manifest, workflow, build arg, or image layer. Prefer runtime injection into the workload over writing secrets to disk or the job environment.307. **Immutable infrastructure.** Config change → redeploy through the pipeline, never a live `kubectl edit`/`exec` in production. Roll the whole workload.318. **Validate before commit.** Render every manifest with values resolved and lint it (`helm template`/`lint`, `kustomize build`, `kubectl apply --dry-run=server`, `docker compose config`); fail on any unresolved required value.329. **Health-gated, progressive delivery.** CD reports success only when the new version serves: readiness passed, `kubectl rollout status` converged, smoke test green, error/latency SLOs held. Any failure → automatic rollback. In production use a strategy that limits blast radius (Part 1).3310. **Production is gated and audited.** A GitHub Environment with required reviewers and a freeze policy stands between a green build and production; the approval and outcome are recorded.3411. **Schema changes are backward-compatible (expand/contract).** Old and new code both run against the DB during a rollout; migrations are decoupled from the code deploy and safe while the previous version still serves; never destructive in the same release that stops using the column.3512. **Rollback is a first-class path**, exercised, run **through the pipeline** (redeploy the previous digest) — not a manual cluster edit.3613. **Observe the deploy.** Emit a deployment marker; track DORA metrics; correlate alerts with deploys.3714. **Document on completion.** Record the app in the wiki (delivery repo, environments, identities, strategy, rollback command) and every deviation.3839## Dependency inventory4041Per application, and per environment where values differ:4243- Cluster + namespace; deploy path (OIDC role, `kubeconfig` context)44- Every workload (Deployment / StatefulSet / CronJob) and per workload: image (own vs upstream), ports, env vars, mounted config, command/args45- Secrets: which values, where consumed, which store holds them46- External dependencies with **real** addresses (DB, cache, broker, SSO/LDAP, object storage, APIs) — host, port, DB/bucket47- Persistence: PVCs, storage class, size, backup/restore expectation48- Networking: who talks to whom, internal vs exposed, NetworkPolicy49- Exposure: domain, public URL, Ingress class, TLS termination and cert source, DNS record50- Health: readiness/liveness/startup endpoints, expected response, startup time, SLOs to gate on51- Resources: requests, memory limit, replica count, HPA bounds, PodDisruptionBudget52- Deployment strategy and its parameters; previous known-good digest and where it is recorded5354Partial inventory → stop.5556## Placeholders5758`<org>`/`<repo>` · `<env>`/`<ENV>` · `<image>` = `ghcr.io/<org>/<app>` · `<digest>` = `sha256:…` from CI · `<workload>` = Deployment name · `<namespace>` · `<SECRET>` = name identical to its manifest reference.5960---6162## Common pitfalls6364**Manifest**65- **Indentation** — a misindented `env:`/`volumeMounts:`/`resources:` attaches to the wrong object or is dropped silently (compose: `networks:`/`volumes:` under `services:` become a bogus service). Render and diff.66- **Unresolved reference** — a value with no binding resolves empty; the workload starts without the credential. Fail the render.67- **Mutable image reference** — `latest`, a floating minor, an overwritable tag. Pin by digest.68- **Isolation** — wrong namespace, or missing `Service`/`NetworkPolicy` → unreachable.69- **Naming drift** — inconsistent names/labels, or a leftover `-staging` string in production values.70- **No `securityContext`** — baseline: `runAsNonRoot`, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false`, drop `ALL` caps, `seccompProfile: RuntimeDefault`.71- **Probe mistakes** — no readiness; liveness and readiness on the same dependency-heavy endpoint (a downstream outage restart-loops every pod); no startup probe for a slow starter.72- **Single replica / no PodDisruptionBudget** — a node drain takes the service down.7374**Workflow**75- Third-party actions pinned to a tag or `@main`, not a commit SHA.76- Over-broad `permissions:`; secrets exposed to untrusted PR code (`pull_request_target` + PR-head checkout).77- Misaligned step blocks (`env:`, `with:`).78- Build+publish for an upstream image (unneeded), or missing for one you own.79- Environment selected by drifting copy-pasted workflows instead of one parameterized workflow with an `environment:` input.80- OIDC role / cluster context not scoped to the target environment.81- CD on `on: push` instead of after the previous stage's gate.8283**Deploy execution**84- Success reported on `kubectl apply` without `kubectl rollout status`.85- A non-backward-compatible migration in the same release as the code; a migration Job run non-blocking.86- CD re-deriving the version from git instead of consuming the digest CI produced.87- Auth not refreshed each run — an expired OIDC token fails with `unauthorized`.8889---9091## Part 1 — Building the pipeline9293Once per application: produces the parameterized workflow plus one environment (typically staging). Adding production is Part 2.9495### 1.1 Prerequisites96- App repo; `main` protected (PR + review, required status checks, linear history)97- `deploy/` folder (or delivery repo), same protection98- GHCR enabled; `GITHUB_TOKEN` `packages: write` in the build job99- Per environment: a namespace + scoped ServiceAccount/RBAC, reached from Actions via an OIDC role100- A secret store for real secrets only; non-sensitive values stay in the manifest101- Cluster admission policy: verify signature + provenance, enforce Pod Security "restricted", forbid mutable tags102- GitHub Environments (`staging`, `production`); `production` with required reviewers and a freeze policy103- Observability: a deployment-marker integration; a dashboard and SLO alerts104105### 1.2 Layout106107```108.github/workflows/ci.yml build, scan, sign, publish — emits digest + version109.github/workflows/cd.yml reusable; inputs: environment, digest110deploy/111 base/ Helm chart or Kustomize base112 envs/<env>/values.yaml per-environment overrides only113```114115### 1.3 Deployment manifest116117From the developer's reference manifest, produce `base/` + `envs/<env>/`:118119- Image `<image>@${digest}` — digest supplied at deploy time, never an inline build or mutable tag120- `securityContext` restricted; readiness + liveness + startup probes distinct; requests set, memory limit = request, no CPU limit121- Replica count, HPA and PodDisruptionBudget on every stateless workload122- Ports per the inventory; secrets via `secretKeyRef` from a `Secret` synced by the store, never inlined123- `Service`/`Ingress`/`PVC` at the right scope; `NetworkPolicy` for anything not fully public124- Remove dev-only config (debug, hot-reload, local hosts)125- The per-environment file holds only what differs (replicas, resources, hostnames)126- Validate: `helm template` / `kustomize build` + `kubectl apply --dry-run=server`127128### 1.4 CI (`ci.yml`)129130Steps, in order: trigger on merge to `main` and tag `v*` → checkout at the tag → resolve the version from the signed tag, exit non-zero if none → `docker login ghcr.io` with `GITHUB_TOKEN` → `docker buildx build --push` to `<image>:X.Y.Z`, capture the digest from `--metadata-file` → generate SBOM → scan image + deps + secrets, **block** on policy violations → sign the digest and attach provenance → set `digest` and `version` as job outputs.131132```yaml133permissions: { contents: read, packages: write, id-token: write, attestations: write }134# key steps135- id: ver136 run: |137 TAG=$(git describe --tags --exact-match) || { echo "no release tag"; exit 1; }138 echo "version=$TAG" >> "$GITHUB_OUTPUT"139- id: build140 uses: docker/build-push-action@<sha>141 with: { push: true, tags: "ghcr.io/${{ github.repository }}:${{ steps.ver.outputs.version }}" }142- uses: actions/attest-build-provenance@<sha>143 with: { subject-name: "ghcr.io/${{ github.repository }}", subject-digest: "${{ steps.build.outputs.digest }}" }144```145146### 1.5 CD (`cd.yml`, reusable)147148Inputs: `environment`, `digest` (from CI — never re-derived). Steps: `environment:` on the job so GitHub enforces reviewers/rules → assume the environment's OIDC role, set the `kubeconfig` context → backward-compatible migration Job if any (apply, wait, abort on failure) → deploy with the digest pinned → wait for convergence + health, roll back on failure → smoke test by the service address → emit the deployment marker.149150```yaml151on: { workflow_call: { inputs: {152 environment: { type: string, required: true }, digest: { type: string, required: true } } } }153permissions: { id-token: write, contents: read }154jobs:155 deploy:156 environment: ${{ inputs.environment }} # GitHub enforces reviewers / rules157 steps:158 - uses: actions/checkout@<sha>159 - uses: <helm-setup-action>@<sha> # + your cloud's OIDC login action for the kubeconfig160 - run: |161 helm upgrade --install <workload> deploy/base -n <namespace>-${{ inputs.environment }} \162 -f deploy/envs/${{ inputs.environment }}/values.yaml \163 --set image.digest=${{ inputs.digest }} --wait --timeout 5m --atomic164 kubectl rollout status deploy/<workload> -n <namespace>-${{ inputs.environment }} --timeout=5m165```166167### 1.6 Wire the stages168`ci.yml` on merge to `main` → on success call `cd.yml` with `environment: staging` (automatic) → on staging success call `cd.yml` with `environment: production` (the Environment's required reviewers gate it).169170### 1.7 Deployment strategy (production)171172Pick by: *can two versions run at once?* and *is there a metric to gate on?*173174- **Rolling** (default, stateless) — `RollingUpdate`, `maxSurge: 25%`, `maxUnavailable: 0`. Needs backward-compatible changes.175- **Recreate** — `strategy: Recreate`. Only when two versions cannot coexist; accepts downtime.176- **Blue-green** — a second Deployment behind the same `Service`; flip the selector once healthy; keep the old for instant rollback. ~2× resources briefly.177- **Canary** — a small second Deployment + a weighted `Ingress` split; grow the weight while watching error rate / latency, abort on regression. Strongest gate; needs reliable metrics and, in practice, a progressive-delivery controller for the analysis.178179### 1.8 External exposure180- DNS → the Ingress controller's load balancer181- `Ingress` in `deploy/` with the environment's class and annotations182- TLS from an automated in-cluster certificate issuer, not a hand-placed key183- Expose only the intended host/path; everything else `ClusterIP` + `NetworkPolicy`184185### 1.9 Cutover (only when migrating an existing deployment)186Deploy the new pipeline's build to a new namespace alongside the old stack → validate → shift traffic (DNS / Ingress weight) gradually, keeping the old stack until stable → decommission the old stack and its manual access.187188### 1.10 Post-deploy verification189- Manifest renders clean; running pods report the expected digest190- Each workload answers on its port; readiness/liveness/startup healthy; `rollout status` clean191- Auth; DB and external-service access; data-writing paths; CronJobs192- Secrets resolved at runtime (nothing started empty); no plaintext secret on a pod filesystem193- Signature enforcement works — an unsigned image is rejected194- Logs and metrics reach the sinks; the deployment marker landed195- PDB present; the rollout survives a node drain; previous known-good digest recorded196197**Reviewing an existing set:** diff against another environment's config and walk 1.3–1.6 + the pitfalls; the checks not visible in files are the protected branch, the gated Environment, per-environment secrets and OIDC trust, and the cluster admission policy.198199---200201## Part 2 — Adding production202203Production is another call of `cd.yml`, not a copy.204205**Reused:** the workflows, the GHCR package and OIDC trust, the image (same digest), the smoke-test suite.206207**Add:**208- A production namespace + a separate scoped OIDC role, never staging's209- Production secrets — **production values differ from staging's; never reuse one**210- The `production` GitHub Environment: required reviewers, deployment branch/tag rule, freeze calendar211- `deploy/envs/production/values.yaml`: replica count and sizing for real load (≠ staging), production hostnames, HPA bounds, PDB212- The production deployment strategy (1.7) and its analysis metrics213- Alert routing and on-call214215**Confirm real production values** (do not copy from staging): DB server/instance/port/name; integration hosts; domain and public URL; certificate source; replicas, sizing, HPA bounds; canary metrics. Each confirmed with whoever operates production.216217**Validate:** run the review; verify the Environment approval actually blocks; verify signature enforcement and Pod Security in the production cluster; rehearse a rollback in staging first.218219---220221## Part 3 — Secrets222223**Order of preference:** (1) a `Secret` synced from your cluster/cloud secret store by an operator or CSI driver — the value never touches Actions, rotation is automatic; (2) the workflow writes a `Secret` at deploy time (GitOps: commit a sealed/encrypted reference only); (3) the workflow injects the value into the deploy step's environment. Never (4) plaintext in a repo/manifest/image.224225- **Runtime injection (preferred):** a secrets-store CSI driver or a secret-sync operator pulls from the store and materializes a `Secret`; the manifest references it by `secretKeyRef`/`envFrom`. Nothing sensitive in CI.226- **Workflow-mediated (fallback):** authenticate to the store via OIDC in the deploy job, then `kubectl create secret generic <name> --from-literal=<SECRET>="$VALUE" --dry-run=client -o yaml | kubectl apply -f -`, or inject at the step (`env:` with `${{ secrets.<NAME> }}`) only for values the render needs as env vars.227- **Rules:** name identical to the manifest reference; least-privilege read; never `echo` it or put it in a step name; never a Docker `build-arg` (it lands in a layer); rotate on schedule and on suspected exposure; keep `${{ secrets.* }}` out of untrusted-PR workflows.228- **TLS / file secrets:** prefer an automated in-cluster issuer so no static key exists. Otherwise pull it at deploy time into a `Secret` mounted `readOnly`, `defaultMode: 0400`; never commit or bake it in; rotate.229230---231232## Part 4 — Operation233234**Triggering:** CI on merge to `main` (release candidate) or a signed tag `X.Y.Z` (release), fail closed with no tag. CD staging automatic on green CI. CD production behind the Environment's required reviewers + deployment rule + freeze check.235236**Rollback** — through the pipeline, the same mechanism as a deploy, so the audit trail holds and the next deploy does not re-ship the bad version:237238- **Helm** — `helm rollback <release> <revision>` as an immediate stop-gap, then re-run `cd.yml` pinning the previous digest.239- **Kustomize / raw** — `kubectl rollout undo deploy/<workload>` stop-gap, then re-run `cd.yml` with the previous digest.240- **`--atomic`** — a failed `helm upgrade` already rolled itself back; the job just fails loudly.241- **Blue-green / canary** — flip the selector back / drop the canary weight to zero; the stable set never left.242- **Compose** — `DIGEST=<previous> docker compose … up -d --wait`.243244The previous known-good digest comes from the last successful production deploy (every published digest is in GHCR). Verify convergence and health after a rollback as for a deploy, and open a fix-forward — a rollback is a pause, not a resolution.