The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

Most developers stop at hosting a site.

But SaaS isn’t about hosting, it’s about operating a product.

That means logins, billing, observability, CI/CD pipelines, and a stack that doesn’t crumble the moment you leave localhost.

The good news? You don’t need a $10k AWS bill or a 10-person SRE team to get there. This guide shows the roadmap from a Docker MVP to a production-ready Kubernetes setup with GitOps, monitoring, and security.

By the end, you’ll have a complete MVP-to-SaaS pipeline:
Code → CI/CD → GitOps → Kubernetes → Monitoring → Security

The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

I’ve always been curious how real teams keep dev, staging, and prod in sync without breaking things.

My first MVP? Everything lived together: one Deployment, one Namespace, one Cluster. It worked… but it didn’t feel professional.

In production, you can’t afford that. You need separation:

  • Development for fast iteration and experiments
  • Staging as a safe dress rehearsal
  • Production as the customer-facing, no-surprises zone

Part 1 — Just Make It Work (Dockerfile + Docker Compose)

When you’re starting out, the goal isn’t to make it bulletproof. No clusters, no automation, just proof that your app builds and serves correctly.

The MVP flow:

Code → Dockerfile → Docker Compose → Running app.

If you stopped here, you’d already have something that looks impressive. But I knew this was just step one.

The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

Step 1: Build It the Right Way (Multi-Stage Dockerfile)

# Stage 1 – Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent --prefer-offline --no-audit
COPY . .
RUN npm run build:prod

# Stage 2 – Serve
FROM nginx:1.25-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
RUN echo "ok" > /usr/share/nginx/html/health

USER nginx
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://localhost:8080/health || exit 1
CMD ["nginx","-g","daemon off;"]

Why this setup works:

  • Multi-stage build keeps the runtime image small and secure (no Node in prod).
  • Nginx runtime is optimized for serving static assets, faster, safer, and more battle-tested than running Node in production.
  • Non-root user adds a layer of security, and the healthcheck ensures Kubernetes and Docker know when the container is healthy.

Why Nginx and not Node?

Node’s dev server (npm run start) is great locally, not for scale. The common pattern is: Node builds it → Nginx serves it.

Step 2: Package It With Docker Compose

Once the image builds, I want a quick way to test it. That’s where Docker Compose comes in:

version: '3.8'
services:
  saas-frontend:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime
    ports: ["8080:8080"]
    healthcheck:
      test: ["CMD","curl","-fsS","http://localhost:8080/health"]
      interval: 30s
      retries: 3

One command (docker compose up) and you can verify the build works.

Visit http://localhost:8080/health → see ok → you’re done.
You’ve got a working container.

MVP recap:

  • ✅ Multi-stage Dockerfile
  • ✅ Local test with Compose
  • ✅ Clean, minimal image ready for deployment

Part 2 — From Local to Cluster (Kubernetes + Kustomize)

Docker proves it works.
Kubernetes makes it reliable and repeatable.

The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

This step turns your single container into a self-healing, multi-environment app.

Step 1: Core Kubernetes Manifests

Your app needs three building blocks:

  • Deployment → defines how pods run
  • Service → gives them a stable internal endpoint
  • Ingress → exposes them to the world through Traefik

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: saas-frontend
  namespace: saas-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: saas-frontend
  template:
    metadata:
      labels:
        app: saas-frontend
    spec:
      containers:
      - name: frontend
        image: ghcr.io/pablodelarco/saas-project:sha-xxx
        ports:
        - containerPort: 8080

Service:

apiVersion: v1
kind: Service
metadata:
  name: saas-frontend-svc
  namespace: saas-dev
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: saas-frontend

Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: saas-ingress
  namespace: saas-dev
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
  ingressClassName: traefik
  rules:
  - host: saas-project.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: saas-frontend-svc
            port:
              number: 80

Step 2: Organize with Kustomize (start with 1 env, grow to 3)

When you’re moving from MVP to “real,” don’t over-engineer day one.
Start with one environment (call it production for now), prove your deploy loop, then split into dev / staging / production once you need safety nets.

Phase A: Start simple (one environment)

k8s/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   └── kustomization.yaml
└── environments/
    └── production/
        ├── kustomization.yaml
        └── replica-patch.yaml

Base (shared config):

# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml

Production overlay (the only env at first):

# k8s/env/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patchesStrategicMerge:
  - replica-patch.yaml
images:
  - name: ghcr.io/pablodelarco/saas-frontend
    newDigest: sha256:REPLACED_BY_CI
# k8s/environments/production/replica-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: saas-frontend
spec:
  replicas: 1

Phase B: Scale safely (add dev & staging)

When you need safer promotion and testing, add dev and staging overlays by copying the production one and changing only what’s different (replicas, hostnames, etc.):

k8s/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   └── kustomization.yaml
└── environments/
    ├── dev/                   # Nº replicas: 1
    │   ├── kustomization.yaml
    │   └── replica-patch.yaml
    ├── staging/               # Nº replicas: 2
    │   ├── kustomization.yaml
    │   └── replica-patch.yaml
    └── production/            # Nº replicas: 3
        ├── kustomization.yaml
        ├── replica-patch.yaml
        └── hpa.yaml

All overlays point to the same base. CI updates the digest in dev; you promote the same digest to staging and production via PRs.

No rebuilds. No config drift.

💡 Helm or Kustomize?

Use Helm for third-party stacks (Prometheus, cert-manager, Postgres).
Use Kustomize for your app (clean patches, zero templating).
ArgoCD handles both.

Part 3 — CI/CD + GitOps (Automate the Loop)

At first, kubectl apply works. Then it doesn’t.
Deploys get risky, versions go missing, and “what’s running where?” becomes a daily mystery.

Time to automate.

The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

We keep the Dockerfile and Kustomize structure.
We replace manual deploys with CI/CD pipelines and GitOps.

  • CI (GitHub Actions): builds, scans, signs, and pushes the image; then updates the env overlay with the image digest.
  • CD (ArgoCD): watches Git and reconciles the cluster to match; deploys, prunes, and self-heals automatically.

The result? A fully automated deployment pipeline:

Deployment Flow

Deployment Flow

Step 1: GitHub Actions (CI)

Every push builds an immutable image, resolves its digest, and updates the dev overlay. Promotion to staging/prod is a PR that bumps the same digest, no rebuilds.

name: CI/CD (Minimal)

on:
  push:
    branches: [main]

permissions:
  contents: write
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: saas-frontend

jobs:
  build-push-update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - uses: actions/setup-node@v4
        with: { node-version: '18' }

      - run: |
          npm ci
          npm run build:prod

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & Push (immutable tag)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Resolve image digest
        id: digest
        run: |
          DIGEST=$(docker buildx imagetools inspect $REGISTRY/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }} | grep -m1 "Digest:" | awk '{print $2}')
          echo "digest=$DIGEST" >> $GITHUB_OUTPUT

      - name: Update Kustomize overlay (dev)
        run: |
          git clone https://github.com/<you>/env-config.git
          cd env-config/k8s/environments/dev
          yq -i '.images[0].newDigest = "'${{ steps.digest.outputs.digest }}'"' kustomization.yaml
          git config user.email "action@github.com"
          git config user.name "GitHub Action"
          git checkout -b bump-${{ steps.digest.outputs.digest }}
          git add kustomization.yaml
          git commit -m "chore: promote digest to dev: ${{ steps.digest.outputs.digest }} [skip ci]"
          git push origin HEAD
          # optionally open a PR with gh cli if available

What this gives you:

  • Every commit = new immutable image.
  • Overlays updated by digest (safer than tags).
  • No manual builds, no guessing which image is live.

Step 2: Continuous Delivery (CD) with ArgoCD (GitOps)

Once your CI updates Git, ArgoCD takes over.
It continuously compares what’s in Git to what’s in the cluster, and syncs them automatically.

We keep one ArgoCD Application per environment so each env has its own lifecycle, guardrails, and blast-radius.

  • dev: auto-sync, auto-prune, self-heal (fast feedback)
  • staging: auto-sync but gated by PR checks
  • production: manual sync (or progressive delivery controller), change windows, strict RBAC
├── argo-apps/
│   ├── app-dev.yaml
│   ├── app-staging.yaml
│   └── app-prod.yaml

Example (dev):

# argocd/app_prod.yaml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: saas-project
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/xxxxx/xxxxxx
    targetRevision: main
    path: k8s/env/dev
  destination:
    server: https://kubernetes.default.svc
    namespace: saas_namespace
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Dev and staging apps are identical, but point to k8s/environments/dev and k8s/environments/staging, and live in their own Argo Projects with tailored RBAC/sync policies.

If you don’t have ArgoCD installed, you can check my previous post where I cover the installation process step by step.

The Flow:

  • CI updates the digest in environments/dev
  • ArgoCD’s dev Application auto-syncs and deploys
  • You open a PR to bump the same digest in staging → merge → auto-sync
  • You promote to production → manual approval (or progressive rollout)

Each environment is isolated, self-healing, and fully traceable.
Git is the source of truth. ArgoCD keeps it alive.

✅ Automation recap

  • Git-driven deploys, zero kubectl apply
  • Per-env Argo apps with distinct policies and RBAC
  • Drift detection, auto-prune, and instant rollback (revert the PR)

Part 4 — Production Hardening (TLS 🔒, Monitoring 📊, Secrets 🧩, and High Availability ⚙️)

You’ve got automation — now make it trustworthy.

Commits flow from GitHub → GHCR → ArgoCD → cluster.
Everything’s automated.
But automation alone doesn’t make a system production-grade.

Production is where the quiet parts matter: encryption, observability, resilience, and the ability to recover when things go sideways.

Let’s harden your SaaS.

The Easy Way to Build a SaaS with Kubernetes and CI/CD (From Scratch)

🔒 Step 1: SSL/TLS with cert-manager + Let’s Encrypt

When your app first goes live, it’s probably served over plain HTTP. That’s fine for testing, but in production, that’s not even an option.

Users expect that little padlock. Browsers demand HTTPS. And you definitely don’t want to renew certificates manually.

The fix: cert-manager + Let’s Encrypt:

Deploy cert-manager via ArgoCD:

# argocd/apps/cert-manager.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.jetstack.io
    chart: cert-manager
    targetRevision: v1.15.0
    helm:
      values: |
        installCRDs: true
  destination:
    server: https://kubernetes.default.svc
    namespace: cert-manager
  syncPolicy:
    automated: {}

Create a ClusterIssuer:

# k8s/infra/cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@saas-project.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
    - http01:
        ingress:
          class: traefik

Patch Ingress for TLS:

# k8s/overlays/production/ingress-patch.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: saas-project-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - hosts:
        - saas-project.com
      secretName: saas-project-tls

Done.
Your app now serves over HTTPS, with automatic renewals and zero downtime.

📊 Step 2: Monitoring & Observability

In the MVP phase, you’re probably using kubectl logs for debugging.

That’s fine, until you have multiple pods, environments, and users.

Production needs observability, the ability to see everything.

We’ll use Prometheus (metrics) and Grafana (dashboards).

Deploy the Monitoring Stack via ArgoCD:

# argocd/apps/monitoring.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kube-prometheus-stack
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: kube-prometheus-stack
    targetRevision: 61.2.0
    helm:
      values: |
        grafana:
          admin:
          existingSecret: grafana-admin
          ingress:
            enabled: true
            ingressClassName: traefik
            hosts:
              - grafana.saas-project.com
  destination:
    server: https://kubernetes.default.svc
    namespace: monitoring
  syncPolicy:
    automated: {}

Once synced, you’ll have:

  • Prometheus scraping pod metrics (cpu, memory, restarts)
  • Grafana dashboards at grafana.saas-project.com

You can now track:

  • Requests per second
  • Pod restarts
  • Latency
  • Error rates

⚙️ Step 3: High Availability

The MVP runs a single pod. It works, until it doesn’t.

In production, downtime isn’t an option. You need redundancy and resilience.

We’ll add three things:

  • Horizontal Pod Autoscaler (HPA) → scales replicas based on CPU/memory/load
  • PodDisruptionBudgets (PDBs) → guarantee a minimum number of pods stay running during updates
  • Liveness/Readiness probes → Kubernetes knows when to restart or stop routing traffic

🔹 Horizontal Pod Autoscaler

# k8s/env/prod/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: saas-frontend
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: saas-frontend
  minReplicas: 2
  maxReplicas: 6
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

🔹 Pod Disruption Budget

# k8s/env/prod/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: saas-frontend-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: saas-frontend

🔹 Add Probes to Deployment

# k8s/env/prod/deployment-patch.yaml
spec:
  template:
    spec:
      containers:
      - name: frontend
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 15

Now Kubernetes knows when to scale up, when to restart, and when to stop routing traffic to a pod that isn’t ready.

🧩 Step 4: Secrets Management (External Secrets Operator)

Rule #1: never store raw secrets in Git.
Not even for five minutes.

That .env file you pushed by accident? It’s already cached in history, cloned by bots, and indexed forever.
So let’s fix that the right way.

You’ve got two clean, production-grade options.

Option A: External Secrets Operator (ESO) 🪄

This is the cloud-native way.
Your app never sees plaintext keys, Kubernetes fetches them automatically from your cloud’s Secret Manager and injects them at runtime.

Deploy ESO via ArgoCD (GitOps-style):

# argocd/apps/external-secrets.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: external-secrets, namespace: argocd }
spec:
  source:
    repoURL: https://charts.external-secrets.io
    chart: external-secrets
    targetRevision: 0.10.5
  destination:
    namespace: external-secrets
    server: https://kubernetes.default.svc
  syncPolicy: { automated: {} }

Then define where your secrets live:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: roomio-db-credentials
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secretsmanager
    kind: SecretStore
  target:
    name: db-credentials
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: prod/db-url

✅ Kubernetes now syncs secrets automatically from:

  • AWS Secrets Manager
  • GCP Secret Manager
  • HashiCorp Vault
  • Azure Key Vault

Your manifests stay clean.
Your keys never touch Git.
And ArgoCD still manages everything declaratively.

Option B: SOPS + Git Encryption 🔐

If you prefer keeping secrets inside Git but still secure, use Mozilla SOPS.

It encrypts only the sensitive parts of your YAML (using your GPG keys or cloud KMS), so you can safely commit the encrypted file.

Example:

sops --encrypt secrets.yaml > secrets.enc.yaml

ArgoCD can then decrypt them automatically at deploy time, no humans, no copy-paste keys.

Final Recap

Most teams try to jump straight into Kubernetes and drown in YAML.
The trick isn’t to do everything, it’s to evolve one layer at a time:

  • Docker to make it work
  • Kustomize to make it scale
  • GitOps to keep it consistent
  • Hardening to make it reliable

Each layer turns chaos into structure.

Add security, observability, and clear environments, and suddenly you’re not just deploying an app, you’re building a system you can trust.

Start small. Evolve fast.

That’s how your MVP becomes a real SaaS.

💡 Want more hands‑on tips about Kubernetes, Cloud, and DevOps?
👉 Follow me here on Medium and let’s connect on LinkedIn!