DEV Community

Cover image for Ship small, ship often: Practical Kubernetes CI/CD on a budget (GitHub Actions + Helm)
Leandro Nuñez for Digital Pollution

Posted on

Ship small, ship often: Practical Kubernetes CI/CD on a budget (GitHub Actions + Helm)

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)

Table of Contents

  1. Why this stack fits freelancers
  2. The architecture in one picture
  3. One-time setup & context you’ll need
  4. A tiny sample app (Node.js) you can actually run
  5. Dockerfile: multi-stage build, explained
  6. Helm chart structure & values: what’s going on and why
  7. CI: GitHub Actions workflow to build/tag/push (with caching)
  8. CD: Helm upgrade, safe timeouts, and rollout checks
  9. Health probes that prevent surprise downtime
  10. Pitfalls I see weekly (and how to avoid them)
  11. Minimal RBAC for CI (no more cluster-admin)
  12. Private registries & imagePullSecrets (GHCR note)
  13. Rollbacks, who does what, and a quick “am I healthy?”
  14. A reusable repo layout & a secrets checklist
  15. Optional: the same loop with Kustomize
  16. Appendix: extra CI steps (tests, concurrency, smoke checks)
  17. 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:

  • 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)

2) The architecture in one picture

Architecture flow

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)


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)

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)


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}`));
Enter fullscreen mode Exit fullscreen mode

A minimal package.json so CI can run tests quickly:

{
  "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": {}
}
Enter fullscreen mode Exit fullscreen mode

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)


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)

# 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"
Enter fullscreen mode Exit fullscreen mode

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)


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)

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)
Enter fullscreen mode Exit fullscreen mode

chart/values.yaml (defaults you’ll override in CI)

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
Enter fullscreen mode Exit fullscreen mode

chart/templates/deployment.yaml (probes + rolling update)

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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)


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)

.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
Enter fullscreen mode Exit fullscreen mode

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)

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)


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)

  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
Enter fullscreen mode Exit fullscreen mode

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)


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)

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)

10) Pitfalls I see weekly (and how to avoid them)

10.1 Shipping :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)

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)

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)

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
Enter fullscreen mode Exit fullscreen mode

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)


12) Private registries & imagePullSecrets (GHCR)

  • 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)

Create a pull secret and use it in 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
Enter fullscreen mode Exit fullscreen mode

Then in values.yaml for prod:

imagePullSecrets:
  - name: ghcr-pull
Enter fullscreen mode Exit fullscreen mode

Official docs show the same flow for private registries; GHCR uses standard Docker auth. (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.

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
Enter fullscreen mode Exit fullscreen mode

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)


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
Enter fullscreen mode Exit fullscreen mode

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)

15) Optional: the same loop with Kustomize

Prefer overlays without templating? Kustomize is built into kubectl. Your pipeline becomes: build → push → kubectl apply -k overlays/prodkubectl 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)

Quick sketch:

kustomize/
  base/
    deployment.yaml
    service.yaml
    kustomization.yaml
  overlays/
    prod/
      kustomization.yaml   # patches image tag to $GIT_SHA
Enter fullscreen mode Exit fullscreen mode

16) Appendix: extra CI steps (tests, concurrency, smoke checks)

A tiny smoke check after deploy (port-forward for 1 request):

      - 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
Enter fullscreen mode Exit fullscreen mode

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:..."
Enter fullscreen mode Exit fullscreen mode

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: ""
Enter fullscreen mode Exit fullscreen mode
# templates/deployment.yaml (image reference)
image: "{{ .Values.image.repository }}{{- if .Values.image.digest -}}@{{ .Values.image.digest }}{{- else -}}:{{ .Values.image.tag }}{{- end }}"
Enter fullscreen mode Exit fullscreen mode

Kubernetes understands @sha256: digests directly. It’s the most deterministic way to roll out exactly what you built. (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)


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)


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:

  • 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.

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

hackernoon.com

hashnode.com

twitter.com

instagram.com

personal portfolio v1

Your feedback and questions are always welcome.
Keep learning, coding, and creating amazing web applications.

Top comments (0)