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.devwith a runner configured - Your Hugo repo at
https://forgejo.uclab.dev/affragak/uclabwith 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
- Make sure the Forgejo runner is online and has Docker available
- Push everything to
main:
git add .
git commit -m "feat: add CI/CD pipeline and k8s manifests"
git push origin main
- Watch the pipeline in Forgejo → Actions
- 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.