Audience: freelancers and small teams who need reliable, inexpensive delivery to Kubernetes. This is a long, hands-on guide with lots of copy-pasteable code and extra explanations. Audience: freelancers and small teams who need reliable, inexpensive delivery to Kubernetes. This is a long, hands-on guide with lots of copy-pasteable code and extra explanations. TL;DR Build Docker images in GitHub Actions, tag each image with the commit SHA, push to GitHub Container Registry (GHCR) using the built-in GITHUB_TOKEN, and deploy to Kubernetes with Helm. This keeps infra simple and costs low because your code, CI, registry, and permissions all live inside GitHub. (GitHub Docs) Rely on Kubernetes Deployments for rolling updates, and wire readiness + liveness probes so pods don’t get traffic until they’re ready (and get restarted if they hang). Make the pipeline wait with helm --wait --atomic and kubectl rollout status so a “green” job actually means the app is healthy. (Kubernetes, Helm) Three recurring mistakes I see: (1) shipping :latest, (2) not waiting for rollouts, (3) giving CI cluster-admin. Fixes: tag or digest-pin images, wait on the rollout, and scope deploy permissions with RBAC in the target namespace. (Kubernetes) Build Docker images in GitHub Actions, tag each image with the commit SHA, push to GitHub Container Registry (GHCR) using the built-in GITHUB_TOKEN, and deploy to Kubernetes with Helm. This keeps infra simple and costs low because your code, CI, registry, and permissions all live inside GitHub. (GitHub Docs) GitHub Actions commit SHA GitHub Container Registry (GHCR) GITHUB_TOKEN Helm GitHub Docs Rely on Kubernetes Deployments for rolling updates, and wire readiness + liveness probes so pods don’t get traffic until they’re ready (and get restarted if they hang). Make the pipeline wait with helm --wait --atomic and kubectl rollout status so a “green” job actually means the app is healthy. (Kubernetes, Helm) Kubernetes Deployments rolling updates readiness liveness probes helm --wait --atomic kubectl rollout status Kubernetes Helm Three recurring mistakes I see: (1) shipping :latest, (2) not waiting for rollouts, (3) giving CI cluster-admin. Fixes: tag or digest-pin images, wait on the rollout, and scope deploy permissions with RBAC in the target namespace. (Kubernetes) :latest cluster-admin RBAC Kubernetes Table of Contents Why this stack fits freelancers The architecture in one picture One-time setup & context you’ll need A tiny sample app (Node.js) you can actually run Dockerfile: multi-stage build, explained Helm chart structure & values: what’s going on and why CI: GitHub Actions workflow to build/tag/push (with caching) CD: Helm upgrade, safe timeouts, and rollout checks Health probes that prevent surprise downtime Pitfalls I see weekly (and how to avoid them) Minimal RBAC for CI (no more cluster-admin) Private registries & imagePullSecrets (GHCR note) Rollbacks, who does what, and a quick “am I healthy?” A reusable repo layout & a secrets checklist Optional: the same loop with Kustomize Appendix: extra CI steps (tests, concurrency, smoke checks) References Why this stack fits freelancers The architecture in one picture One-time setup & context you’ll need A tiny sample app (Node.js) you can actually run Dockerfile: multi-stage build, explained Helm chart structure & values: what’s going on and why CI: GitHub Actions workflow to build/tag/push (with caching) CD: Helm upgrade, safe timeouts, and rollout checks Health probes that prevent surprise downtime Pitfalls I see weekly (and how to avoid them) Minimal RBAC for CI (no more cluster-admin) Private registries & imagePullSecrets (GHCR note) imagePullSecrets Rollbacks, who does what, and a quick “am I healthy?” A reusable repo layout & a secrets checklist Optional: the same loop with Kustomize Kustomize Appendix: extra CI steps (tests, concurrency, smoke checks) References 1) Why this stack fits freelancers When you’re solo or working with a tiny team, the time you don’t spend gluing tools together is time you can bill. The combination below keeps blast radius small and the moving parts understandable: don’t GitHub Actions + GHCR: you already store your code in GitHub; Actions runs your builds and GHCR keeps your images in the same trust boundary. With proper workflow permissions, the built-in GITHUB_TOKEN is enough to push images—no extra tokens to rotate, no additional accounts to manage. (GitHub Docs) Helm: clients expect Helm charts. Charts are just templates with a clear place for defaults (values.yaml), and a simple way to override them per environment. (Helm) Kubernetes Deployments: rolling updates are the default—Kubernetes gradually replaces old pods with new ones and only declares success after the new ReplicaSet becomes ready. That’s near-zero downtime when you wire probes and wait for the rollout. (Kubernetes) GitHub Actions + GHCR: you already store your code in GitHub; Actions runs your builds and GHCR keeps your images in the same trust boundary. With proper workflow permissions, the built-in GITHUB_TOKEN is enough to push images—no extra tokens to rotate, no additional accounts to manage. (GitHub Docs) GitHub Actions + GHCR GITHUB_TOKEN GitHub Docs Helm: clients expect Helm charts. Charts are just templates with a clear place for defaults (values.yaml), and a simple way to override them per environment. (Helm) Helm values.yaml Helm Kubernetes Deployments: rolling updates are the default—Kubernetes gradually replaces old pods with new ones and only declares success after the new ReplicaSet becomes ready. That’s near-zero downtime when you wire probes and wait for the rollout. (Kubernetes) Kubernetes Deployments when Kubernetes 2) The architecture in one picture The last step matters: your pipeline blocks until the Deployment is actually healthy (or times out), which means green pipelines line up with a healthy app, not just “manifests applied”. (Kubernetes) your pipeline blocks Kubernetes 3) One-time setup & context you’ll need kubectl on the runner and on your machine. Use the official install so versions match your cluster’s supported skew (typically ±1 minor). (Kubernetes) Helm v3 on the runner. The official install page documents the script many people use in CI. (Helm) GHCR enabled in your org/repo. Add the OCI label org.opencontainers.image.source in your Dockerfile so GHCR auto-links the package to your repo; use GITHUB_TOKEN with packages: write scoped by workflow permissions. (GitHub Docs) kubectl on the runner and on your machine. Use the official install so versions match your cluster’s supported skew (typically ±1 minor). (Kubernetes) kubectl Kubernetes Helm v3 on the runner. The official install page documents the script many people use in CI. (Helm) Helm v3 Helm GHCR enabled in your org/repo. Add the OCI label org.opencontainers.image.source in your Dockerfile so GHCR auto-links the package to your repo; use GITHUB_TOKEN with packages: write scoped by workflow permissions. (GitHub Docs) GHCR org.opencontainers.image.source GITHUB_TOKEN packages: write GitHub Docs Why versions/skew matters: if your kubectl is too far ahead/behind, you’ll chase weird errors. The official docs call out the “within one minor” rule. (Kubernetes) Why versions/skew matters: kubectl Kubernetes 4) A tiny sample app (Node.js) you can actually run Two endpoints that map directly to Kubernetes probes: // app/server.js import http from 'node:http'; const port = process.env.PORT || 3000; let ready = false; // Simulate warm-up work: DB connection, JIT, caches... setTimeout(() => (ready = true), 3000); const server = http.createServer((req, res) => { if (req.url === '/livez') return res.end('ok'); // liveness probe if (req.url === '/healthz') return res.end(ready ? 'ok' : 'starting'); // readiness probe res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello'); }); server.listen(port, () => console.log(`listening on ${port}`)); // app/server.js import http from 'node:http'; const port = process.env.PORT || 3000; let ready = false; // Simulate warm-up work: DB connection, JIT, caches... setTimeout(() => (ready = true), 3000); const server = http.createServer((req, res) => { if (req.url === '/livez') return res.end('ok'); // liveness probe if (req.url === '/healthz') return res.end(ready ? 'ok' : 'starting'); // readiness probe res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello'); }); server.listen(port, () => console.log(`listening on ${port}`)); A minimal package.json so CI can run tests quickly: package.json { "name": "demo-app", "type": "module", "scripts": { "start": "node app/server.js", "test": "node -e \"const http=require('http');http.get('http://127.0.0.1:3000',r=>process.exit(r.statusCode===200?0:1))\"" }, "dependencies": {} } { "name": "demo-app", "type": "module", "scripts": { "start": "node app/server.js", "test": "node -e \"const http=require('http');http.get('http://127.0.0.1:3000',r=>process.exit(r.statusCode===200?0:1))\"" }, "dependencies": {} } The point of /healthz and /livez: Kubernetes treats them differently—readiness gates traffic; liveness restarts stuck containers. We’ll wire both into the Deployment in a moment. (Kubernetes) /healthz /livez readiness liveness Kubernetes 5) Dockerfile: multi-stage build, explained Why multi-stage? Build tools (compilers, bundlers) aren’t needed at runtime. Splitting build and runtime stages makes smaller, faster, and safer images. The official Docker docs explicitly recommend multi-stage builds for production. (Docker Documentation) build runtime Docker Documentation # Dockerfile # -------- build stage -------- FROM node:20 AS build WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . # leave only prod deps RUN npm prune --omit=dev # -------- runtime stage -------- FROM node:20-slim WORKDIR /app COPY --from=build /app ./ EXPOSE 3000 CMD ["node","app/server.js"] # helps GHCR link this image to your repo automatically LABEL org.opencontainers.image.source="https://github.com/your-org/your-repo" # Dockerfile # -------- build stage -------- FROM node:20 AS build WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . # leave only prod deps RUN npm prune --omit=dev # -------- runtime stage -------- FROM node:20-slim WORKDIR /app COPY --from=build /app ./ EXPOSE 3000 CMD ["node","app/server.js"] # helps GHCR link this image to your repo automatically LABEL org.opencontainers.image.source="https://github.com/your-org/your-repo" If you ever need multi-arch images (arm64 laptop, amd64 server), Buildx and QEMU make that possible with one workflow. We’ll stick to a single arch for speed. (Docker Documentation) Buildx Docker Documentation 6) Helm chart structure & values: what’s going on and why Helm charts are folders with a specific layout; defaults live in values.yaml, and templates live under templates/. You can override defaults per environment with --values path.yaml or inline --set key=value. (Helm) values.yaml templates/ --values path.yaml --set key=value Helm chart/Chart.yaml chart/Chart.yaml apiVersion: v2 name: app description: Minimal demo app type: application version: 0.1.0 # chart version appVersion: "0.1.0" # your app version (for humans; not enforced) apiVersion: v2 name: app description: Minimal demo app type: application version: 0.1.0 # chart version appVersion: "0.1.0" # your app version (for humans; not enforced) chart/values.yaml (defaults you’ll override in CI) chart/values.yaml image: repository: ghcr.io/your-org/your-repo/app tag: "CHANGE_ME" # CI will set this to the commit SHA pullPolicy: IfNotPresent service: type: ClusterIP port: 80 targetPort: 3000 replicaCount: 2 resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: 500m, memory: 256Mi } # optional if using private images # imagePullSecrets: # - name: ghcr-pull image: repository: ghcr.io/your-org/your-repo/app tag: "CHANGE_ME" # CI will set this to the commit SHA pullPolicy: IfNotPresent service: type: ClusterIP port: 80 targetPort: 3000 replicaCount: 2 resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: 500m, memory: 256Mi } # optional if using private images # imagePullSecrets: # - name: ghcr-pull chart/templates/deployment.yaml (probes + rolling update) chart/templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "app.fullname" . }} labels: app.kubernetes.io/name: {{ include "app.name" . }} spec: replicas: {{ .Values.replicaCount }} strategy: { type: RollingUpdate } selector: matchLabels: app.kubernetes.io/name: {{ include "app.name" . }} template: metadata: labels: app.kubernetes.io/name: {{ include "app.name" . }} spec: {{- if .Values.imagePullSecrets }} imagePullSecrets: {{- range .Values.imagePullSecrets }} - name: {{ .name }} {{- end }} {{- end }} containers: - name: app image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: {{ .Values.service.targetPort }} readinessProbe: httpGet: { path: /healthz, port: {{ .Values.service.targetPort }} } initialDelaySeconds: 5 periodSeconds: 5 livenessProbe: httpGet: { path: /livez, port: {{ .Values.service.targetPort }} } initialDelaySeconds: 15 periodSeconds: 10 apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "app.fullname" . }} labels: app.kubernetes.io/name: {{ include "app.name" . }} spec: replicas: {{ .Values.replicaCount }} strategy: { type: RollingUpdate } selector: matchLabels: app.kubernetes.io/name: {{ include "app.name" . }} template: metadata: labels: app.kubernetes.io/name: {{ include "app.name" . }} spec: {{- if .Values.imagePullSecrets }} imagePullSecrets: {{- range .Values.imagePullSecrets }} - name: {{ .name }} {{- end }} {{- end }} containers: - name: app image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: {{ .Values.service.targetPort }} readinessProbe: httpGet: { path: /healthz, port: {{ .Values.service.targetPort }} } initialDelaySeconds: 5 periodSeconds: 5 livenessProbe: httpGet: { path: /livez, port: {{ .Values.service.targetPort }} } initialDelaySeconds: 15 periodSeconds: 10 chart/templates/service.yaml chart/templates/service.yaml apiVersion: v1 kind: Service metadata: name: {{ include "app.fullname" . }} spec: type: {{ .Values.service.type }} selector: app.kubernetes.io/name: {{ include "app.name" . }} ports: - port: {{ .Values.service.port }} targetPort: {{ .Values.service.targetPort }} apiVersion: v1 kind: Service metadata: name: {{ include "app.fullname" . }} spec: type: {{ .Values.service.type }} selector: app.kubernetes.io/name: {{ include "app.name" . }} ports: - port: {{ .Values.service.port }} targetPort: {{ .Values.service.targetPort }} If you prefer a more DRY approach, add a _helpers.tpl to standardize names/labels. Helm’s best-practices guide covers how to structure values and helpers cleanly. (Helm) _helpers.tpl Helm 7) CI: GitHub Actions to build, tag, and push (with caching) We’ll use Docker’s official Actions: setup-buildx, login, and build-push. We tag the image with the commit SHA so every deploy is traceable and immutable. Enable packages: write for GITHUB_TOKEN. (GitHub, GitHub Docs) setup-buildx login build-push commit SHA packages: write GITHUB_TOKEN GitHub GitHub Docs .github/workflows/ci.yml .github/workflows/ci.yml name: ci on: push: branches: [ "main" ] permissions: contents: read packages: write # allow pushing to GHCR (least privilege) jobs: build-and-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # optional: speed up local tests - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm test - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}/app:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max name: ci on: push: branches: [ "main" ] permissions: contents: read packages: write # allow pushing to GHCR (least privilege) jobs: build-and-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # optional: speed up local tests - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm test - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}/app:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max A few gotchas here: Workflow permissions must allow writing packages, or GHCR will reject pushes. You can set defaults in repo settings and still restrict per workflow/job. (GitHub Docs) Labeling images with org.opencontainers.image.source helps GHCR connect the package back to the repo UI. (GitHub Docs) Buildx is the recommended way to build images in Actions, with caching and multi-arch support documented in Docker’s CI guide. (Docker Documentation) Workflow permissions must allow writing packages, or GHCR will reject pushes. You can set defaults in repo settings and still restrict per workflow/job. (GitHub Docs) Workflow permissions GitHub Docs Labeling images with org.opencontainers.image.source helps GHCR connect the package back to the repo UI. (GitHub Docs) Labeling images org.opencontainers.image.source GitHub Docs Buildx is the recommended way to build images in Actions, with caching and multi-arch support documented in Docker’s CI guide. (Docker Documentation) Buildx Docker Documentation Why SHA tags instead of :latest? Kubernetes treats image references literally. Mutable :latest tags make it harder to know what’s running and can trip pull-policy defaults. Use an immutable tag (SHA) or even a digest (image@sha256:...) for perfect reproducibility—Kubernetes supports digests natively. (Kubernetes) Why SHA tags instead of :latest :latest digest image@sha256:... Kubernetes 8) CD: Helm upgrade, safe timeouts, and rollout checks Deploy right after a successful push. helm upgrade --install handles both first-time and repeat deploys. Add --wait (block until ready), --timeout (don’t wait forever), and --atomic (automatic rollback on failure). Then, as an extra safety net, run kubectl rollout status to stream progress into the job log. (Helm, Kubernetes) helm upgrade --install --wait --timeout --atomic kubectl rollout status Helm Kubernetes deploy: needs: build-and-push runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - name: Install kubectl (official) run: | curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl && sudo mv kubectl /usr/local/bin/ - name: Install Helm run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - name: Configure kubeconfig env: { KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }} } run: | mkdir -p $HOME/.kube echo "$KUBECONFIG_DATA" | base64 -d > $HOME/.kube/config - name: Helm upgrade (create or update) run: | helm upgrade --install app ./chart \ --namespace prod --create-namespace \ --set image.tag=${{ github.sha }} \ --wait --timeout=10m --atomic - name: Rollout status (extra verification) run: kubectl rollout status deploy/app -n prod --timeout=180s deploy: needs: build-and-push runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - name: Install kubectl (official) run: | curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl && sudo mv kubectl /usr/local/bin/ - name: Install Helm run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - name: Configure kubeconfig env: { KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }} } run: | mkdir -p $HOME/.kube echo "$KUBECONFIG_DATA" | base64 -d > $HOME/.kube/config - name: Helm upgrade (create or update) run: | helm upgrade --install app ./chart \ --namespace prod --create-namespace \ --set image.tag=${{ github.sha }} \ --wait --timeout=10m --atomic - name: Rollout status (extra verification) run: kubectl rollout status deploy/app -n prod --timeout=180s Why both --wait and kubectl rollout status? Helm’s --wait bubbles up a release-level success/fail; kubectl rollout status prints per-step progress and gives fast, readable feedback when something’s off. Helm’s --atomic turns failures into an automatic rollback—useful when you’d rather be safe than “half updated”. (Helm, Kubernetes) Why both --wait kubectl rollout status --wait kubectl rollout status --atomic Helm Kubernetes 9) Health probes that prevent surprise downtime You can have a perfect rolling update and still serve errors if the app accepts traffic before it’s warmed up. That’s what readiness probes prevent: they gate Service endpoints until your pod says “ready.” Liveness probes are your safety rope if the app wedges itself; the kubelet restarts the container after repeated failures. The official docs cover these semantics and when to use startup probes for slower apps. (Kubernetes) readiness probes Liveness probes Kubernetes Practical tuning guidance: If your app needs 20s to warm up, set initialDelaySeconds on readiness accordingly. If your app occasionally blocks (e.g., deadlock), prefer an HTTP or TCP liveness probe that hits a trivial endpoint or port. If your app is slow to start, add a startup probe to delay liveness until boot completes. (Kubernetes) If your app needs 20s to warm up, set initialDelaySeconds on readiness accordingly. initialDelaySeconds readiness If your app occasionally blocks (e.g., deadlock), prefer an HTTP or TCP liveness probe that hits a trivial endpoint or port. HTTP TCP If your app is slow to start, add a startup probe to delay liveness until boot completes. (Kubernetes) slow startup probe Kubernetes 10) Pitfalls I see weekly (and how to avoid them) 10.1 Shipping :latest :latest What goes wrong: The same tag points to different bits over time; pull cache and policy quirks make it unclear whether a node actually pulled the new image. The fix: Tag images by commit SHA or use digests (@sha256:...). Kubernetes supports digest references; it will always fetch the exact image. The official image docs explain how Kubernetes resolves image references, tags, and digests. (Kubernetes) What goes wrong: The same tag points to different bits over time; pull cache and policy quirks make it unclear whether a node actually pulled the new image. What goes wrong: The fix: Tag images by commit SHA or use digests (@sha256:...). Kubernetes supports digest references; it will always fetch the exact image. The official image docs explain how Kubernetes resolves image references, tags, and digests. (Kubernetes) The fix: commit SHA digests @sha256:... Kubernetes 10.2 Not waiting for rollouts What goes wrong: Your pipeline finishes “green,” but users see errors for 30–60 seconds because the Deployment hasn’t converged yet. The fix: Add --wait --timeout (and optionally --atomic) to Helm, and run kubectl rollout status so failures show up clearly in logs. (Helm, Kubernetes) What goes wrong: Your pipeline finishes “green,” but users see errors for 30–60 seconds because the Deployment hasn’t converged yet. What goes wrong: The fix: Add --wait --timeout (and optionally --atomic) to Helm, and run kubectl rollout status so failures show up clearly in logs. (Helm, Kubernetes) The fix: --wait --timeout --atomic kubectl rollout status Helm Kubernetes 10.3 Over-privileged CI (cluster-admin) What goes wrong: A leaked token equals full cluster compromise. The fix: Create a ServiceAccount + Role + RoleBinding scoped to the target namespace, with only the verbs and resources your chart needs. The RBAC docs call this model out explicitly. (Kubernetes) What goes wrong: A leaked token equals full cluster compromise. What goes wrong: The fix: Create a ServiceAccount + Role + RoleBinding scoped to the target namespace, with only the verbs and resources your chart needs. The RBAC docs call this model out explicitly. (Kubernetes) The fix: Role RoleBinding Kubernetes 11) Minimal RBAC for CI (copy/paste) # rbac/deployer.yaml apiVersion: v1 kind: ServiceAccount metadata: name: helm-deployer namespace: prod --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: helm-deployer namespace: prod rules: - apiGroups: ["apps"] resources: ["deployments","replicasets"] verbs: ["get","list","watch","create","update","patch"] - apiGroups: [""] resources: ["services","configmaps","secrets"] verbs: ["get","list","watch","create","update","patch"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get","list","watch","create","update","patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: helm-deployer namespace: prod subjects: - kind: ServiceAccount name: helm-deployer namespace: prod roleRef: kind: Role name: helm-deployer apiGroup: rbac.authorization.k8s.io # rbac/deployer.yaml apiVersion: v1 kind: ServiceAccount metadata: name: helm-deployer namespace: prod --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: helm-deployer namespace: prod rules: - apiGroups: ["apps"] resources: ["deployments","replicasets"] verbs: ["get","list","watch","create","update","patch"] - apiGroups: [""] resources: ["services","configmaps","secrets"] verbs: ["get","list","watch","create","update","patch"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get","list","watch","create","update","patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: helm-deployer namespace: prod subjects: - kind: ServiceAccount name: helm-deployer namespace: prod roleRef: kind: Role name: helm-deployer apiGroup: rbac.authorization.k8s.io This follows the least privilege principle: the SA can only touch the namespace and resource types your chart manages. If a token leaks, the damage is limited to those verbs/resources in prod. RBAC is the supported authorization model in Kubernetes. (Kubernetes) least privilege prod Kubernetes 12) Private registries & imagePullSecrets (GHCR) imagePullSecrets Public GHCR packages: anyone can pull anonymously; no secret required. Private GHCR packages: create a Docker registry secret and reference it in your pod spec (or attach it to your ServiceAccount). Kubernetes documents both the Secret type and the imagePullSecrets mechanism. (Kubernetes) Public GHCR packages: anyone can pull anonymously; no secret required. Public GHCR packages: Private GHCR packages: create a Docker registry secret and reference it in your pod spec (or attach it to your ServiceAccount). Kubernetes documents both the Secret type and the imagePullSecrets mechanism. (Kubernetes) Private GHCR packages: imagePullSecrets Kubernetes Create a pull secret and use it in prod: prod kubectl create secret docker-registry ghcr-pull \ --docker-server=ghcr.io \ --docker-username=YOUR_GITHUB_USERNAME \ --docker-password=YOUR_GHCR_TOKEN_OR_PAT \ --docker-email=you@example.com \ -n prod kubectl create secret docker-registry ghcr-pull \ --docker-server=ghcr.io \ --docker-username=YOUR_GITHUB_USERNAME \ --docker-password=YOUR_GHCR_TOKEN_OR_PAT \ --docker-email=you@example.com \ -n prod Then in values.yaml for prod: values.yaml imagePullSecrets: - name: ghcr-pull imagePullSecrets: - name: ghcr-pull Official docs show the same flow for private registries; GHCR uses standard Docker auth. (Kubernetes) Kubernetes 13) Rollbacks, who does what, and a quick “am I healthy?” Two layers can help you: Helm release history → helm history/rollback. Deployment history → kubectl rollout undo/status. Helm release history → helm history/rollback. Helm helm history/rollback Deployment history → kubectl rollout undo/status. Deployment kubectl rollout undo/status Typical commands: # See release history managed by Helm helm history app -n prod # Roll back to the previous release helm rollback app 1 -n prod # Verify the Deployment converges kubectl rollout status deploy/app -n prod --timeout=180s # See release history managed by Helm helm history app -n prod # Roll back to the previous release helm rollback app 1 -n prod # Verify the Deployment converges kubectl rollout status deploy/app -n prod --timeout=180s Helm’s --atomic already auto-rolls back on failures during the upgrade. kubectl rollout status is still useful for transparent logs during manual ops or in CI. (Helm, Kubernetes) --atomic kubectl rollout status Helm Kubernetes 14) A reusable repo layout & a secrets checklist repo-root/ app/ # your code Dockerfile chart/ Chart.yaml values.yaml values-staging.yaml values-prod.yaml templates/ deployment.yaml service.yaml _helpers.tpl rbac/ deployer.yaml .github/workflows/ci.yml repo-root/ app/ # your code Dockerfile chart/ Chart.yaml values.yaml values-staging.yaml values-prod.yaml templates/ deployment.yaml service.yaml _helpers.tpl rbac/ deployer.yaml .github/workflows/ci.yml Secrets (GitHub → Settings → Secrets and variables → Actions): Secrets (GitHub → Settings → Secrets and variables → Actions): KUBECONFIG_DATA – base64 kubeconfig for the helm-deployer ServiceAccount. GITHUB_TOKEN – auto-injected; set workflow permissions: packages: write to push to GHCR (use repo/org defaults or YAML). (GitHub Docs) KUBECONFIG_DATA – base64 kubeconfig for the helm-deployer ServiceAccount. KUBECONFIG_DATA helm-deployer GITHUB_TOKEN – auto-injected; set workflow permissions: packages: write to push to GHCR (use repo/org defaults or YAML). (GitHub Docs) GITHUB_TOKEN permissions: packages: write GitHub Docs 15) Optional: the same loop with Kustomize Kustomize Prefer overlays without templating? Kustomize is built into kubectl. Your pipeline becomes: build → push → kubectl apply -k overlays/prod → kubectl rollout status. It’s great for single-service repos and small customizations; Helm tends to be handier when you need packagability or your client already uses charts. (Kubernetes) Kustomize kubectl kubectl apply -k overlays/prod kubectl rollout status Kubernetes Quick sketch: Quick sketch: kustomize/ base/ deployment.yaml service.yaml kustomization.yaml overlays/ prod/ kustomization.yaml # patches image tag to $GIT_SHA kustomize/ base/ deployment.yaml service.yaml kustomization.yaml overlays/ prod/ kustomization.yaml # patches image tag to $GIT_SHA 16) Appendix: extra CI steps (tests, concurrency, smoke checks) A tiny smoke check after deploy (port-forward for 1 request): A tiny smoke check after deploy - name: Port-forward and smoke test run: | kubectl -n prod port-forward svc/app 8080:80 & PF_PID=$! sleep 3 curl -fsS http://127.0.0.1:8080/healthz | grep -q "ok" kill $PF_PID - name: Port-forward and smoke test run: | kubectl -n prod port-forward svc/app 8080:80 & PF_PID=$! sleep 3 curl -fsS http://127.0.0.1:8080/healthz | grep -q "ok" kill $PF_PID Digest pinning (even stricter than tags): Digest pinning (even stricter than tags): # Check the pushed image's digest docker buildx imagetools inspect ghcr.io/${OWNER}/${REPO}/app:${GITHUB_SHA} # Then set in values-prod.yaml: # image: # repository: ghcr.io/${OWNER}/${REPO}/app # tag: "" # leave empty when using a digest # digest: "sha256:..." # Check the pushed image's digest docker buildx imagetools inspect ghcr.io/${OWNER}/${REPO}/app:${GITHUB_SHA} # Then set in values-prod.yaml: # image: # repository: ghcr.io/${OWNER}/${REPO}/app # tag: "" # leave empty when using a digest # digest: "sha256:..." If you want to template digest usage into Helm, adjust your image stanza and template: # values.yaml (allow either tag OR digest) image: repository: ghcr.io/your-org/your-repo/app tag: "CHANGE_ME" digest: "" # values.yaml (allow either tag OR digest) image: repository: ghcr.io/your-org/your-repo/app tag: "CHANGE_ME" digest: "" # templates/deployment.yaml (image reference) image: "{{ .Values.image.repository }}{{- if .Values.image.digest -}}@{{ .Values.image.digest }}{{- else -}}:{{ .Values.image.tag }}{{- end }}" # templates/deployment.yaml (image reference) image: "{{ .Values.image.repository }}{{- if .Values.image.digest -}}@{{ .Values.image.digest }}{{- else -}}:{{ .Values.image.tag }}{{- end }}" Kubernetes understands @sha256: digests directly. It’s the most deterministic way to roll out exactly what you built. (Kubernetes) @sha256: exactly Kubernetes Multi-arch builds (if you need arm64): add platforms: linux/amd64,linux/arm64 to build-push-action and include QEMU if needed. Docker’s docs show both. (Docker Documentation) Multi-arch builds (if you need arm64): platforms: linux/amd64,linux/arm64 build-push-action Docker Documentation 17) References Kubernetes Deployments (rolling update behavior & concepts). (Kubernetes) Rolling updates overview (tutorial). (Kubernetes) kubectl rollout status (command reference). (Kubernetes) Probes (liveness, readiness, startup). (Kubernetes) Images: tags, digests, and imagePullSecrets. (Kubernetes) RBAC authorization (official). (Kubernetes) Install kubectl and version skew note. (Kubernetes) Helm helm upgrade and flags (--install, --wait, --atomic). (Helm) Installing Helm (CI script). (Helm) Chart structure & values reference and best practices. (Helm) GitHub Actions & GHCR Publishing Docker images with Actions. (GitHub Docs) Using GITHUB_TOKEN with least-privilege permissions. (GitHub Docs) Working with the Container registry (GHCR) and OCI labels. (GitHub Docs) Docker official GitHub Actions: setup-buildx, login, build-push. (GitHub) Docker’s CI guide for GitHub Actions. (Docker Documentation) Docker Multi-stage builds (why/how), best practices, multi-platform. (Docker Documentation) Kubernetes Deployments (rolling update behavior & concepts). (Kubernetes) Rolling updates overview (tutorial). (Kubernetes) kubectl rollout status (command reference). (Kubernetes) Probes (liveness, readiness, startup). (Kubernetes) Images: tags, digests, and imagePullSecrets. (Kubernetes) RBAC authorization (official). (Kubernetes) Install kubectl and version skew note. (Kubernetes) Kubernetes Kubernetes Kubernetes kubectl rollout status Kubernetes Kubernetes imagePullSecrets Kubernetes Kubernetes kubectl Kubernetes Helm helm upgrade and flags (--install, --wait, --atomic). (Helm) Installing Helm (CI script). (Helm) Chart structure & values reference and best practices. (Helm) Helm helm upgrade --install --wait --atomic Helm Helm Helm GitHub Actions & GHCR Publishing Docker images with Actions. (GitHub Docs) Using GITHUB_TOKEN with least-privilege permissions. (GitHub Docs) Working with the Container registry (GHCR) and OCI labels. (GitHub Docs) Docker official GitHub Actions: setup-buildx, login, build-push. (GitHub) Docker’s CI guide for GitHub Actions. (Docker Documentation) GitHub Actions & GHCR GitHub Docs GITHUB_TOKEN permissions GitHub Docs GitHub Docs GitHub Docker Documentation Docker Multi-stage builds (why/how), best practices, multi-platform. (Docker Documentation) Docker Docker Documentation Closing thoughts This is the path I reach for when a client says: “we want reliable deploys this week, not a platform rebuild.” It scales from a single service to many, and every part of it is explainable to non-platform folks: “we want reliable deploys this week, not a platform rebuild.” One chart, one workflow, one registry. Immutable images (SHA tags or digests), probes that reflect real app health, and rollouts that wait. Scoped deploy permissions, not cluster-admin. One chart, one workflow, one registry. Immutable images (SHA tags or digests), probes that reflect real app health, and rollouts that wait. wait Scoped deploy permissions, not cluster-admin. Stay Connected If you enjoyed this article and want to explore more about web development, feel free to connect with me on various platforms: dev.to dev.to hackernoon.com hackernoon.com hashnode.com hashnode.com twitter.com twitter.com instagram.com instagram.com personal portfolio v1 personal portfolio v1 Your feedback and questions are always welcome. Keep learning, coding, and creating amazing web applications.