Hugo Blog — Full CI/CD GitOps

Forgejo → k3s with Flux


Overview

Stack: Hugo + Risotto theme (git submodule) → Forgejo Actions → Forgejo Container Registry → k3s → Flux

Flow:

git push 
→ Forgejo Actions builds Hugo image 
→ pushes to Forgejo registry
→ updates image tag in k8s manifest 
→ commits back to repo
→ Flux detects commit 
→ applies Deployment 
→ k3s rolls out new image

No Bitnami charts. No Flux image automation controllers. No runtime git cloning. Just a plain nginx Deployment running your pre-built static site.


Prerequisites

  • k3s cluster running
  • Flux already bootstrapped
  • Forgejo instance at forgejo.uclab.dev with a runner configured
  • Your Hugo repo at https://forgejo.uclab.dev/affragak/uclab with the Risotto theme as a git submodule

Repository Structure

Your repo should look like this when done:

uclab/
├── .forgejo/
│   └── workflows/
│       └── build.yml          # CI pipeline
├── themes/
│   └── risotto/               # git submodule
├── content/
├── static/
├── config.toml                # or hugo.yaml
├── Dockerfile
├── nginx.conf
└── .gitmodules

Step 1 — Verify Your Git Submodule

Make sure the submodule is correctly configured. Your .gitmodules should look like:

[submodule "themes/risotto"]
    path = themes/risotto
    url = https://codeberg.org/ristomolnar/risotto.git
    branch = main

Step 2 — Dockerfile

Create this at the repo root. Both stages are pinned to specific versions — update them intentionally.

# Dockerfile
FROM hugomods/hugo:0.157.0 AS builder

WORKDIR /site

# Copy everything including the resolved submodule
COPY . .

# Build the site
RUN hugo --minify

# ---

FROM nginx:1.27-alpine

# Remove the default nginx config
RUN rm /etc/nginx/conf.d/default.conf

# Copy built site and custom nginx config
COPY --from=builder /site/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 8080

Step 3 — nginx Config

# nginx.conf
server {
    listen 8080;
    root /usr/share/nginx/html;
    index index.html;

    # Serve pre-compressed files if available
    gzip_static on;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Cache static assets aggressively
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # No caching for HTML
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }
}

The try_files $uri.html fallback lets Hugo’s ugly URLs (e.g. /about/about.html) work without trailing slashes.


Step 4 — Forgejo Secrets

In your Forgejo repo go to Settings → Secrets and Variables → Actions and add:

Secret Name Value
REGISTRY_USER Your Forgejo username (e.g. affragak)
REGISTRY_TOKEN A Forgejo access token with write:packages scope

To create the token: Forgejo → User Settings → Applications → Generate Token → check write:packages and read:packages.

GitHub Deploy Key

Since Forgejo CI needs to push to a GitHub repo, you need a deploy key with write access.

ssh-keygen -t ed25519 -C "forgejo-ci" -f forgejo-ci-key -N ""
# creates forgejo-ci-key (private) and forgejo-ci-key.pub (public)

Add the public key to GitHub:
Go to github.com/affragak/pi5cluster → Settings → Deploy keys → Add deploy key → paste forgejo-ci-key.pub → check Allow write access
Add the private key to Forgejo:
Go to your Hugo repo in Forgejo → Settings → Secrets → add secret named GH_DEPLOY_KEY → paste the contents of forgejo-ci-key (the private key)


Step 5 — Forgejo Actions Workflow

# .forgejo/workflows/build.yml
name: Build and Deploy
on:
  push:
    branches: [main]
env:
  REGISTRY: forgejo.uclab.dev
  IMAGE: forgejo.uclab.dev/affragak/uclab
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Hugo repo (with submodules)
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Set image tag
        run: |
          SHORT_SHA=$(git rev-parse --short HEAD)
          echo "TAG=${SHORT_SHA}" >> $GITHUB_ENV

      - name: Login to Forgejo registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin

      - name: Build and push image
        run: |
          docker build -t ${{ env.IMAGE }}:${{ env.TAG }} .
          docker push ${{ env.IMAGE }}:${{ env.TAG }}

      - name: Setup SSH for GitHub
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.GH_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan github.com >> ~/.ssh/known_hosts

      - name: Clone k3s repo and update image tag
        run: |
          git clone [email protected]:affragak/pi5cluster.git /tmp/pi5cluster
          cd /tmp/pi5cluster
          git config user.name "forgejo-ci"
          git config user.email "[email protected]"
          DEPLOY_FILE="blog/base/uclab/deployment.yaml"
          sed -i "s|image: ${{ env.IMAGE }}:.*|image: ${{ env.IMAGE }}:${{ env.TAG }}|" $DEPLOY_FILE
          git add $DEPLOY_FILE
          git diff --staged --quiet && echo "No changes to commit" && exit 0
          git commit -m "chore: deploy hugo ${{ env.TAG }}"
          git push

Why fetch-depth: 0? Without it, git rev-parse --short HEAD may not work correctly in shallow clones.

Why not tag latest? Kubernetes won’t re-pull a latest tag unless imagePullPolicy: Always is set, which causes an unnecessary pull on every pod restart. Using the git sha means every image is unique and immutable.


Step 6 — Kubernetes Manifests

blog/
├── athena
│   ├── kustomization.yaml
│   └── uclab
│       ├── cloudflare-secret.yaml
│       ├── cloudflare.yaml
│       └── kustomization.yaml
└── base
    └── uclab
        ├── deployment.yaml
        ├── kustomization.yaml
        ├── namespace.yaml
        ├── secret.yaml
        └── service.yaml

base/namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: uclab

base/deployment.yaml

The image: line on the container is what the CI sed command rewrites. Keep it on its own line exactly as shown.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: uclab
  namespace: uclab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: uclab
  template:
    metadata:
      labels:
        app: uclab
    spec:
      imagePullSecrets:
        - name: forgejo-registry
      containers:
        - name: nginx
          image: forgejo.uclab.dev/affragak/uclab:placeholder
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              cpu: 200m
              memory: 64Mi
          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 30

base/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: uclab
  namespace: uclab
spec:
  selector:
    app: uclab
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

Step 7 — Registry Pull Secret

k3s needs credentials to pull from your private Forgejo registry. Create vault secret and add external-secret declarative.

❯ vault kv put -mount=apps forgejo-registry \
  username=forgejo-username \
  password=forgejo-registry-password \
  server=forgejo.uclab.dev

base/secret.yaml

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: forgejo-registry
spec:
  refreshInterval: "15s"
  secretStoreRef:
    name: vault-backend-global
    kind: ClusterSecretStore
  target:
    name: forgejo-registry
    creationPolicy: Owner
    template:
      type: kubernetes.io/dockerconfigjson
      data:
        .dockerconfigjson: |
          {"auths":{"{{ .server }}":{"username":"{{ .username }}","password":"{{ .password }}","auth":"{{ printf "%s:%s" .username .password | b64enc }}"}}}
  data:
    - secretKey: username
      remoteRef:
        key: forgejo-registry
        property: username
    - secretKey: password
      remoteRef:
        key: forgejo-registry
        property: password
    - secretKey: server
      remoteRef:
        key: forgejo-registry
        property: server

Step 8 - Flux kustomization

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: blog
  namespace: flux-system
spec:
  interval: 1m
  retryInterval: 1m
  timeout: 5m
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./blog/athena
  prune: true

Step 9 — First Deploy

  1. Make sure the Forgejo runner is online and has Docker available
  2. Push everything to main:
git add .
git commit -m "feat: add CI/CD pipeline and k8s manifests"
git push origin main
  1. Watch the pipeline in Forgejo → Actions
  2. Once the image is pushed and the manifest is updated, Flux will pick it up within 1 minute:
# Watch Flux reconcile
flux get kustomizations

# Watch the rollout
kubectl rollout status deployment/uclab -n uclab

# Check pods
kubectl get pods -n uclab

Day-to-Day Workflow

To publish a new post:

# Write your post
hugo new content/posts/my-new-post.md
# ... edit the file ...

git add .
git commit -m "post: my new post title"
git push origin main

That’s it. The pipeline handles everything else. In about 2-3 minutes (build + push + Flux reconcile) your post is live.

my DevOps Odyssey

“Σα βγεις στον πηγαιμό για την Ιθάκη, να εύχεσαι να ‘ναι μακρύς ο δρόμος, γεμάτος περιπέτειες, γεμάτος γνώσεις.” - Kavafis’ Ithaka.