It is a continuation of previous article wordpress-quartz. I will describe how to run it locally, run it using k3s, and how to design ci/cd pipeline. This pipeline will work as follows:

  • to add a new article I need to create a new commit. What will happen next is:
  • Forgejo Action reads the currently deployed version
  • builds a Docker image with a new tag
  • pushes the Docker image
  • adds a commit to the repository, updating the deployment manifest
  • ArgoCD picks up that change, deploys the newer version
  • k3s performs a rolling update without downtime

Running locally

First, without jumping into automation, lets run Quarz locally. Assuming you have Node 22+, all you need to do is clone the repo, then from the quartz/ directory:

git clone https://github.com/jackyzha0/quartz.git
cd quartz
npm ci
npm run serve

This builds the site and starts a dev server with live reload at http://localhost:8080. Content changes trigger an automatic rebuild.

running in kubernetes environment

First step to run an application in k8s would be writing a dockerfile. But before that, we need to select one of two approaches:

dynamic: node + mounted content

Run Node.js with npm run serve, mount the content directory as a volume. File watcher rebuilds on change.

Pros:

  • Instant updates when editing files
  • no deploy step for content changes
  • good for rapid iteration. Cons:
  • Node runtime (~200MB+ memory)
  • larger attack surface

static: nginx

Pre-build the site with npm run build, copy the output into an nginx image, serve static files. No Node.js.

Pros:

  • small image (~40MB)
  • low memory
  • fast serving
  • minimal attack surface

Cons

  • Rebuild and redeploy required for every content change
  • CI/CD or manual process needed

I will choose generatic static content, is more secure, and will use less RAM. To mitigate an issue with rebuild and redeploy, I will implement forgejo action.

Dockerfile

The upstream Quartz Dockerfile has two drawbacks. First, it keeps Node.js in the final image even though serving static HTML only needs a web server - that means a ~200MB image instead of ~40MB, more RAM, and a larger attack surface. Second, it runs npm run build at container startup, so every pod restart (rollout, node drain, crash) triggers a full rebuild - slow startup and wasted CPU.

A 2-stage build fixes both: build once at image build time, then discard Node.js and ship only the static output in a minimal nginx image. No runtime rebuild, no Node in production.

FROM node:22.22.1-slim AS builder
WORKDIR /app
COPY quartz/package.json quartz/package-lock.json* ./
RUN npm ci
COPY quartz/ .
RUN npm run build
 
FROM nginx:1.28.2-alpine
COPY blog/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/public /usr/share/nginx/html
EXPOSE 80

Stage 1 installs dependencies and generates static files into public/. The COPY package.json before COPY quartz/ ensures Docker layer caching - npm ci is skipped when only content changes.

Stage 2 copies the static output into an nginx:alpine image. The final image is ~40MB instead of ~200MB, starts instantly, and serves with production-grade gzip and caching.

nginx config

The nginx config used in the dockerfile, contains few directives. Let me explain them.

try_files

For a request to /cicd/quartz-blog-pipeline/, nginx tries in order:

  • (1) a file at that exact path,
  • (2) quartz-blog-pipeline/index.html,
  • (3) quartz-blog-pipeline.html. Step 2 matches, so the page loads. Without this, clean URLs would 404.

Gzip

compresses text before sending. A 100KB HTML file might become 20KB over the wire. The gzip_types line lists which formats to compress: HTML, CSS, JS, JSON, XML, SVG. Images (PNG, JPG) are already compressed, so we skip them.

Caching

The first location block matches URLs ending in .js, .css, .png, etc. For those, we tell the browser: “cache this for 30 days, don’t revalidate.” That works because Quartz puts hashes in filenames (e.g. styles.abc123.css). When you publish a new version, the filename changes, so the browser fetches the new file. HTML pages are not in this block — they get normal short cache, so readers see updates when you deploy.

server {
    listen 80;
    root /usr/share/nginx/html;
 
    gzip on;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml image/svg+xml;
 
    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
 
    location / {
        try_files $uri $uri/index.html $uri.html =404;
    }
 
    error_page 404 /404.html;
}

Kubernetes deployment

The blog/ directory contains standard k8s manifests:

  • Deployment - single replica, RollingUpdate strategy, readiness/liveness probes on /
  • Service - ClusterIP on port 80
  • Ingress - Traefik with cert-manager TLS for blog.example.com
  • Secret - Harbor registry pull credentials (imagePullSecrets)

blog/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: blog
  namespace: blog
  labels:
    app.kubernetes.io/name: blog
spec:
  revisionHistoryLimit: 3
  replicas: 1
  strategy:
    type: RollingUpdate
  selector:
    matchLabels:
      app.kubernetes.io/name: blog
  template:
    metadata:
      labels:
        app.kubernetes.io/name: blog
    spec:
      imagePullSecrets:
        - name: registry-auth
      containers:
        - name: blog
          image: registry.example.com/blog/quartz:0.5
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          resources:
            requests:
              memory: "32Mi"
              cpu: "10m"
            limits:
              memory: "128Mi"
              cpu: "100m"
          livenessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 5
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 3
            periodSeconds: 10

blog/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: blog
  namespace: blog
  labels:
    app.kubernetes.io/name: blog
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: http
      protocol: TCP
      name: http
  selector:
    app.kubernetes.io/name: blog

blog/ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: blog
  namespace: blog
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    traefik.ingress.kubernetes.io/router.middlewares: cert-manager-redirect-https@kubernetescrd
spec:
  ingressClassName: traefik
  rules:
    - host: blog.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: blog
                port:
                  number: 80
  tls:
    - hosts:
        - blog.example.com
      secretName: blog-tls

blog/secret.yaml — a kubernetes.io/dockerconfigjson secret for Harbor pull credentials. Create it with kubectl create secret docker-registry registry-auth ... or store the base64-encoded .dockerconfigjson in the manifest.

ArgoCD manages everything via argocd/apps/blog-app.yaml pointing to the blog/ directory. I will cover argoCD later on.

Zero-downtime deployments

With RollingUpdate and 1 replica, Kubernetes defaults to maxUnavailable: 0 and maxSurge: 1. This means it creates the new pod first, waits for the readiness probe to pass, then kills the old one. Since nginx starts in under a second, there’s no downtime during image updates.

CI/CD with Forgejo Actions

A workflow in .forgejo/workflows/blog-deploy.yaml automates the build-deploy cycle:

  1. Trigger on push to main that touches quartz/**, or manually via workflow_dispatch
  2. Read the current image version from blog/deployment.yaml
  3. Bump the patch version (0.1 0.2 0.3 …)
  4. docker build and docker push to Harbor at registry.example.com
  5. Update deployment.yaml with the new version
  6. Commit and push with [skip ci] to avoid infinite loops

The trigger path filter (quartz/**) is the primary loop prevention - the commit only changes blog/deployment.yaml, which doesn’t match the trigger.

.forgejo/workflows/blog-deploy.yaml:

name: Build and Deploy Blog
 
on:
  push:
    branches: [main]
    paths:
      - 'quartz/**'
  workflow_dispatch:
 
jobs:
  build-and-deploy:
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    runs-on: alpine-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.PUSH_TOKEN }}
          fetch-depth: 0
 
      - name: Install dependencies
        run: apk add --no-cache docker-cli git
 
      - name: Read current version
        id: version
        run: |
          CURRENT=$(sed -n 's/.*quartz:\([0-9]*\.[0-9]*\).*/\1/p' blog/deployment.yaml)
          MAJOR=$(echo "$CURRENT" | cut -d. -f1)
          MINOR=$(echo "$CURRENT" | cut -d. -f2)
          NEW_MINOR=$((MINOR + 1))
          NEW_VERSION="${MAJOR}.${NEW_MINOR}"
          echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
          echo "new=$NEW_VERSION" >> "$GITHUB_OUTPUT"
 
      - name: Build image
        run: docker build -f blog/Dockerfile -t registry.example.com/blog/quartz:${{ steps.version.outputs.new }} .
 
      - name: Push image
        run: |
          echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
          docker push registry.example.com/blog/quartz:${{ steps.version.outputs.new }}
 
      - name: Update deployment manifest
        run: sed -i "s|quartz:${{ steps.version.outputs.current }}|quartz:${{ steps.version.outputs.new }}|" blog/deployment.yaml
 
      - name: Commit and push
        run: |
          git clone --depth=1 https://your-username:${{ secrets.PUSH_TOKEN }}@forgejo.example.com/your-username/your-repo.git /tmp/repo
          cp blog/deployment.yaml /tmp/repo/blog/deployment.yaml
          cd /tmp/repo
          git config user.name "forgejo-runner"
          git config user.email "runner@example.com"
          git add blog/deployment.yaml
          git commit -m "bump blog image to ${{ steps.version.outputs.new }} [skip ci]"
          git push

Runner setup

The Forgejo runner runs as a StatefulSet in the forgejo namespace with a DinD (Docker-in-Docker) sidecar for container builds. The runner label alpine-latest maps to node:22-alpine, which provides both Node.js (needed by actions/checkout) and apk for installing docker-cli.

The runner config mounts the DinD socket into workflow containers:

container:
  options: "-v /var/run/docker.sock:/var/run/docker.sock"
  valid_volumes:
    - "/var/run/docker.sock"
  docker_host: "tcp://localhost:2375"

Triggering manually

curl -s -X POST \
  -H "Authorization: token $(cat .forgejo-token)" \
  -H "Content-Type: application/json" \
  -d '{"ref": "main"}' \
  https://forgejo.example.com/api/v1/repos/your-username/your-repo/actions/workflows/blog-deploy.yaml/dispatches

Domain