From Docker Compose to k3s: Migrating a Full-Stack App with Jenkins CI/CD

⏱ 13 min read

Six months ago, my app was running on Coolify behind a Cloudflare Tunnel. Deploys were automatic — every push to my self-hosted Gitea triggered a webhook, Coolify picked it up, rebuilt the Docker Compose stack, and shipped it. It worked. But there was no staging environment, no test gate before production, and no visibility into what was running where. This is the story of replacing that with something I actually understand end to end.

The Starting Point

The app was simple enough: a React SPA built with Vite, an Express API with Passport.js authentication, and PostgreSQL for data and sessions. Three services, one network, a persistent volume for Postgres — deployed on Coolify, running on a Proxmox VM. Cloudflare Tunnel handled HTTPS and zero-trust access with no firewall ports open.

docker-compose.yml
├── postgres:15        # database
├── server             # Express API (Node 20)
└── client             # Nginx serving React SPA

Coolify handled deploys automatically — a push to Gitea triggered a webhook, Coolify rebuilt and restarted the stack. No SSH, no manual commands. For a solo side project, that’s genuinely good enough.

But Coolify’s abstraction hides the details. There was no staging environment — every push went straight to production. No test gate. No visibility into container state beyond Coolify’s UI. And if I ever wanted to move off Coolify, the entire deployment model lived inside it, not in my repository.

That was the real motivation: I wanted to learn k3s, Kustomize, and Jenkins CI/CD on something real — and own the full pipeline in code, not in a PaaS UI. A toy tutorial app doesn’t teach you the sharp edges. A production app does.

The New Stack

Here’s what I built:

Internet → Cloudflare Tunnel → k3s Node → Traefik Ingress
                                                                                    ├── /api/*  → server:4000
                                                                                    └── /*       → client:80

k3s — single-node, lightweight, perfect for a homelab. It ships with Traefik as the default Ingress controller, so there’s no extra setup needed.

Jenkins — running in a Docker container on Coolify. The pipeline handles everything from build to production deploy.

GHCR — GitHub Container Registry for storing the two images: top-members-client and top-members-server.

Kustomize — three-layer structure: base/, staging/, and prod/. Same manifests, different config per environment.

Writing the Kubernetes Manifests

Client: Multi-Stage Build, Simplified Nginx

The React SPA is built with a two-stage Dockerfile. The builder stage compiles the Vite app; the runtime stage is just nginx:alpine serving the output:

FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

FROM nginx:alpine
LABEL org.opencontainers.image.source=#your repo url
COPY --from=builder /app/dist /usr/share/nginx/html

The original Docker Compose setup used Nginx to both serve static files and proxy /api/ requests to the backend. In Kubernetes, that proxy job moves to the Ingress controller. So the Nginx config becomes much simpler — serve files and handle SPA routing, nothing else:

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;
    location / {
        try_files $uri $uri/ /index.html;
    }
}

This config is injected via a ConfigMap at deploy time, which means the same Docker image works in both Compose and Kubernetes environments. The routing concerns are cleanly separated: Nginx owns static files, Traefik owns routing decisions.

Server: InitContainer for Database Readiness

The Express API was straightforward to containerize. The important addition was an InitContainer that waits for PostgreSQL to be ready before the main app starts — something depends_on: condition: service_healthy handled in Compose:

initContainers:
  - name: wait-for-postgres
    image: postgres:15
    command:
      - sh
      - -c
      - until pg_isready -h postgres -U "$POSTGRES_USER"; do
          echo waiting for postgres; sleep 2; done

Using pg_isready (from the Postgres image itself) is more reliable than a raw nc TCP check — it actually verifies Postgres is accepting connections, not just that the port is open.

PostgreSQL: StatefulSet + PVC

The database runs as a StatefulSet with a PersistentVolumeClaim. k3s ships with a local-path provisioner, so the PVC is satisfied automatically without configuring any storage classes:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  volumeClaimTemplates:
    - metadata:
        name: postgres-data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi

The schema is applied automatically via a ConfigMap mounted at /docker-entrypoint-initdb.d — the same pattern PostgreSQL uses in Docker. Any .sql files in that directory are executed on first startup.

Seed data — which contains bcrypt password hashes — is handled separately with a one-shot Kubernetes Job. That Job is generated by a local helper script and never committed to Git. This keeps sensitive data out of version control while still making the seed process reproducible.

Ingress: Routing Without proxy_pass

The Ingress resource replaces what proxy_pass /api/ used to do inside the Nginx container:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: top-members
spec:
  rules:
    - host: top-members.k3.l
      http:
        paths:
          - path: /api/
            pathType: Prefix
            backend:
              service:
                name: server
                port:
                  number: 4000
          - path: /
            pathType: Prefix
            backend:
              service:
                name: client
                port:
                  number: 80
    - host: top-members-k3s.sarawebs.com
      http:
        paths:
          - path: /api/
            pathType: Prefix
            backend:
              service:
                name: server
                port:
                  number: 4000
          - path: /
            pathType: Prefix
            backend:
              service:
                name: client
                port:
                  number: 80

Adding the public domain later required zero additional Traefik config — just an extra host entry pointing to the same services. Traefik routes based on the Host header.

Pi-hole resolves *.k3.l locally. Cloudflare Tunnel connects the public hostname to Traefik’s port. Still no open firewall ports.

Secrets

All sensitive values live in a Kubernetes Secret that is never committed to Git:

apiVersion: v1
kind: Secret
metadata:
  name: top-members-secrets
type: Opaque
stringData:
  POSTGRES_DB: saramsg
  POSTGRES_USER: saramsg
  POSTGRES_PASSWORD: <redacted>
  DATABASE_URL: postgres://saramsg:<redacted>@postgres:5432/saramsg
  MEMBERSHIP_PASSCODE: <redacted>
  SESSION_SECRET: <redacted>

An example-secrets.yaml template with dummy values is committed to the repo so new environments are easy to set up. The real secret is created with kubectl create secret and stays out of version control entirely.


Kustomize: One Base, Two Environments

The manifest structure follows the standard base/overlay pattern:

k8s/
├── base/
│   ├── client-deployment.yaml
│   ├── client-service.yaml
│   ├── server-deployment.yaml
│   ├── server-service.yaml
│   ├── postgres-statefulset.yaml
│   ├── postgres-service.yaml
│   ├── ingress.yaml          # placeholder host
│   ├── configmap.yaml
│   ├── nginx-configmap.yaml
│   ├── db-init-configmap.yaml
│   └── kustomization.yaml
├── staging/
│   ├── namespace.yaml
│   ├── ingress-patch.yaml    # host: app-staging.k3.l
│   └── kustomization.yaml
└── prod/
    ├── namespace.yaml
    ├── ingress-patch.yaml    # host: app.k3.l + public domain
    └── kustomization.yaml

The staging overlay sets namespace: staging and patches the Ingress host. The prod overlay uses namespace: top-members and adds the public Cloudflare domain. The base stays environment-agnostic — no environment-specific values anywhere in it.

Image tags are injected by the Jenkins pipeline using sed. More on that below.

The Jenkins Declarative Pipeline

This was the most rewarding part of the migration. The full flow:

Checkout → Build (npm ci + vite build) → Docker Build & Push → Deploy Staging → [Manual Approval] → Deploy Prod

Build Stage

The pipeline uses a node:20-alpine Docker agent to run npm ci and npm run build. Jenkins itself never touches Node.js — it only exists inside the ephemeral container. Clean host, reproducible builds.

Docker Build & Push

Two images are built and pushed to GHCR, tagged with the Jenkins BUILD_NUMBER and latest:

docker.withRegistry('https://ghcr.io', 'github-user-pass') {
    def clientImage = docker.build("${CLIENT_IMAGE}:${IMAGE_TAG}", './client')
    clientImage.push()
    clientImage.push('latest')

    def serverImage = docker.build("${SERVER_IMAGE}:${IMAGE_TAG}", './server')
    serverImage.push()
    serverImage.push('latest')
}

Tagging with the build number gives you a clean audit trail. You can always look at a running pod’s image tag and know exactly which pipeline run produced it.

Deploy to Staging

The kubeconfig is stored as a base64-encoded Secret text credential in Jenkins. At deploy time it’s decoded to a temp file, used for the deployment, then deleted:

withCredentials([string(credentialsId: 'k3s-kubeconfig', variable: 'KUBECONFIG_CONTENT')]) {
    sh '''
        echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/k3s-config
        chmod 600 /tmp/k3s-config
        sed -i 's|newTag: ".*"|newTag: "'"$IMAGE_TAG"'"|' k8s/staging/kustomization.yaml
        kubectl --kubeconfig=/tmp/k3s-config apply -k k8s/staging
        kubectl --kubeconfig=/tmp/k3s-config rollout status deployment/client -n staging --timeout=120s
    '''
}

The sed command injects the current build number into the Kustomize overlay before applying it. It replaces whatever newTag currently holds, so there’s no special placeholder syntax to maintain. The rollout status call blocks until the deployment completes (or fails), which means a bad deploy fails the pipeline stage immediately.

Deploy to Prod (With Graceful Skip)

Same as staging, but with a manual approval gate and a 1-hour timeout. Critically, the timeout is wrapped in a try/catch so the pipeline finishes green if nobody approves — the build succeeded, staging is updated, and production waits for the next run:

try {
    timeout(time: 1, unit: 'HOURS') {
        input message: 'Deploy to production?', ok: 'Yes, deploy'
    }
    // ... same deploy steps as staging, pointing at prod overlay ...
} catch (Exception e) {
    echo "Production deployment skipped: ${e.getMessage()}"
}

This pattern is important in practice. If you cut a build at the end of the day and nobody approves before the timeout, you don’t want a red pipeline blocking tomorrow’s work. Skip cleanly, re-run when ready.

The Real Challenges

Image Tag Injection: kustomize edit vs sed

The cleaner approach is kustomize edit set image, which modifies the kustomization.yaml in-place. The problem: it reformats the file, blowing away any comments or custom whitespace. sed is less elegant but fully predictable:

sed -i 's|newTag: ".*"|newTag: "'"$IMAGE_TAG"'"|' k8s/prod/kustomization.yaml

This requires the images block in kustomization.yaml to already have a newTag field before the pipeline runs — latest works as the default. The sed replaces only that line, preserving everything else in the file.

Old ReplicaSets Piling Up

Rolling updates leave orphaned ReplicaSets behind. After a dozen pipeline runs you’ll see something like this:

$ kubectl get rs -n top-members
NAME                  DESIRED   CURRENT   READY
client-7d9f8b6c4      1         1         1
client-6b5c7a3f1      0         0         0
client-5a4d6e2b8      0         0         0
client-4c3e5d1a7      0         0         0

Two options. Quick cleanup:

kubectl delete replicaset -n top-members \
  $(kubectl get replicaset -n top-members | awk '$2==0 && $3==0 {print $1}')

Or the better long-term fix — set revisionHistoryLimit in your Deployments:

spec:
  revisionHistoryLimit: 3

Kubernetes keeps only the 3 most recent ReplicaSets automatically, which still gives you rollback capability without the clutter.

Networking: Still No Open Ports

The networking model didn’t change from the Coolify setup — it just moved up a layer:

  • k3s Traefik handles Ingress routing inside the cluster
  • Cloudflare Tunnel connects the public hostname to Traefik, with no inbound firewall rules
  • Pi-hole resolves *.k3.l for local access to staging and prod
  • Tailscale handles remote kubectl access when I’m not on the home network

Both staging (internal only) and prod (public via Cloudflare) run on the same cluster node, separated by namespace and Ingress hostname. No separate VMs, no duplicate infrastructure.

Results

BeforeAfter
Coolify automatic compose up -dJenkins pipeline on every push
No staging environmentFull staging namespace with own Ingress
No container registryGHCR with versioned images
Single environmentKustomize overlays for staging + prod
No IaCFull K8s manifests in Git
No rollback strategykubectl rollout undo works out of the box

The pipeline runs in about 3 minutes from commit to staging deploy. Production requires a click but takes the same time. The entire stack — Nginx client, Express server, PostgreSQL — runs at roughly 60 MB of RAM total. Kubernetes doesn’t have to be heavyweight.

What’s Next

  • Prometheus + Grafana for metrics and alerting
  • Sealed Secrets or Vault for proper secret management
  • Jest ESM test suite wired into the Build stage
  • ArgoCD for GitOps-style reconciliation

The full manifests and documentation live in the repo under a docs/ folder covering architecture, deployment, CI/CD setup, and troubleshooting. The pattern works for any three-tier app — swap out the services and the same pipeline structure applies.

Oh hi there 👋 It’s nice to meet you.

Sign up to receive awesome content in your inbox, every month.

We don’t spam! Read our privacy policy for more info.

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox, every month.

We don’t spam! Read our privacy policy for more info.

Spread the love
0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x