Soomin Hwang
DevOps / Cloud infrastructure

From Commit to Production: Jenkins + K3s + ArgoCD GitOps Pipeline

Role
DevOps Engineer (solo)
Timeline
Jan 2024 – present
Stack
JenkinsDockerGitHub Container RegistryK3sArgoCDGitOpsNginxTailscale
Disclosure
Public repo →
Deployment time
Manual SSH< 5 min autofully automated
Infrastructure
Single VPSK3s clusterhigh availability

Context

The Next14-R3F portfolio project (see separate case study) needed a production-grade deployment pipeline. Instead of the typical "SSH in, pull, build, restart" cycle, I built a complete GitOps pipeline:

Developer push → GitHub → Jenkins build & push → GitOps repo update → ArgoCD sync → K3s cluster

GitHub: Lajancia/Next14-R3F (contains Jenkinsfile)

Pipeline architecture

GitHub → Jenkins trigger

A webhook configured in GitHub sends push events to the Jenkins server. Upon receiving a push to the main branch, the pipeline starts automatically.

Stage 1: Checkout

Jenkins checks out the repository at the pushed commit, ensuring the build matches exactly what the developer intended.

Stage 2: Docker build & push

stage('Docker Build & Push') {
    steps {
        script {
            def shortCommit = sh(returnStdout: true,
              script: 'git rev-parse --short HEAD').trim()
            sh "docker build -f dockerfile -t ${GHCR_IMAGE}:${shortCommit} ."
            sh "docker tag ${GHCR_IMAGE}:${shortCommit} ${GHCR_IMAGE}:latest"
            sh "docker push ${GHCR_IMAGE}:${shortCommit}"
            sh "docker push ${GHCR_IMAGE}:latest"
        }
    }
}

The multi-stage Docker build uses pnpm for dependency management:

FROM node:20-alpine AS base
RUN corepack enable && corepack prepare [email protected] --activate

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --no-frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm panda && pnpm build

FROM base AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

Key optimizations:

  • pnpm for faster installs and disk efficiency vs npm
  • output: 'standalone' for minimal Docker image (~110 MB runner stage)
  • Multi-stage build — deps cache layer prevents reinstall on code-only changes

Stage 3: GitOps repo update

Instead of SSH-ing into the server, Jenkins updates a separate GitOps repository:

stage('Update GitOps Repo') {
    steps {
        script {
            def shortCommit = sh(...)
            sh """
                git clone https://github.com/<gitops-org>/next-r3f-ops.git
                sed -i 's|image: ...|image: ${GHCR_IMAGE}:${shortCommit}|' next-r3f.yaml
                git commit -m "chore: update image tag to ${shortCommit}"
                git push
            """
        }
    }
}

The GitOps repo contains a single Kubernetes manifest (next-r3f.yaml) that ArgoCD watches.

Stage 4: ArgoCD sync

ArgoCD, running on the K3s cluster, polls the GitOps repository every 3 minutes. When it detects a change to the image tag, it automatically syncs the new manifest to the cluster — rolling update with zero downtime.

Infrastructure diagram

Deployment and serving are two separate paths. The deploy path runs one way, on every merge to main:

GitHub push
     ↓
PR reviewed & merged to main
     ↓
Jenkins webhook
     ↓
Docker image build → push to GHCR
     ↓
GitOps repo update (image tag bump)
     ↓
ArgoCD sync
     ↓
K3s cluster deploy (rolling update)

The serving path is unrelated to the above: Nginx terminates SSL and proxies requests to the Next.js application running on K3s. Tailscale covers administrative access to the cluster — it sits on neither path.

Infrastructure details

K3s cluster

  • Lightweight Kubernetes distribution ideal for single-VPS deployments
  • Runs on a VPS behind Nginx reverse proxy
  • Services exposed via Tailscale VPN for secure administrative access

Nginx

  • Reverse proxy handling SSL termination (Let's Encrypt)
  • Routes traffic to the Next.js application running on K3s
  • Static asset caching headers configured for performance

Tailscale

  • WireGuard-based mesh VPN for secure cluster access
  • No open ports on the K3s nodes — all traffic through Tailscale
  • Administrative dashboard and monitoring accessible only via VPN

Results

MetricBeforeAfter
Deployment methodManual SSHGitOps (fully automated)
Deployment time~20 min< 5 min
RollbackManualgit revert + push
AccessPublic portsTailscale VPN + Nginx

How to verify

  1. Check the Jenkinsfile: Lajancia/Next14-R3F/blob/main/Jenkinsfile
  2. View the Docker multi-stage build: Lajancia/Next14-R3F/blob/main/dockerfile

Related

See the 3D Portfolio case study for the application this pipeline deploys.

Found this case study interesting?

Always up for a conversation — whether or not there's a project attached.