Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
125 NOTES IN PRINT
FOLIO CXXV 2026-07-17 · 8 MIN · LONG-FORM

Your Git Forge Belongs on Your Cluster: Forgejo

Self-hosted source, CI/CD, and a registry, on the lightweight fork that left its for-profit parent

Diagram · folio cxxv
gitGraph
  commit id: "feat: change"
  branch ci
  checkout ci
  commit id: "build + test"
  commit id: "BuildKit image"
  checkout main
  merge ci tag: "v1.2.0"
  commit id: "push to registry" type: HIGHLIGHT

A platform needs a place to keep its code, run its pipelines, and store the images it builds. The 2020 edition ran GitLab CE on k3s for this, and that was the right call then. In 2026 I reach for something lighter and governed differently, which matters for the same reasons that run through this series: Forgejo, a self-hosted git forge that runs on the platform you have already built, backed by its Postgres and object storage and logged in through its single sign-on.

This series rebuilds my 2020 Apress book, Advanced Platform Development with Kubernetes, for 2026. The approach behind it comes from building and running data platforms in production for more than twenty years.

§Why the Forge Is the Wrong Place to Rent

The git forge is one of the most central tools a development team has. It holds the source, the issues, the pull requests, the CI/CD pipelines, the release artifacts, and increasingly the container registry too. GitHub and GitLab.com are good products, and they are also among the most embedded SaaS dependencies most teams have, where leaving means moving not just files but years of history, automation, and muscle memory. That is a lot of leverage to hand someone, and the per-seat bill grows with exactly the thing you want to grow, your team.

You do not have to. A self-hosted forge keeps the source and the pipelines on your own cluster, on the storage and identity you already run, and the modern ones are good enough that the hosted services become an optional convenience. The forge becomes another platform service, sitting next to the database and the object store rather than an account somewhere else that the rest of your work reaches up to.

§Forgejo, and the Fork That Made It

Which forge is itself a small lesson in the thesis of this series. For years the lightweight self-hosted answer was Gitea, a friendly Go application that did most of what GitLab did at a fraction of the weight. In 2022 the Gitea project moved its trademark and control into a for-profit company, formed without the consent of much of the community that had built it. The maintainers and contributors who believed they were improving a shared commons rather than a private company’s product did what open-source licensing permits: they forked. Forgejo is that fork, governed by Codeberg e.V., a non-profit, with the explicit aim of staying community-controlled.

This is the same story as Elasticsearch and OpenSearch, MinIO and SeaweedFS, Redis and Valkey, told one layer up the stack. A project recruited contributors and advocates on the promise of being a shared open thing, then changed who controlled it, and the community that did not want to be someone’s unpaid product moved to the fork that kept the original promise. Choosing Forgejo over Gitea is less about features, since the two are close, than the same risk management that runs through every component choice in this platform. You build on the project whose governance you trust, and a non-profit holding the trademark is unlikely to wake up one day as someone’s revenue target.

§A Database and Storage

Forgejo keeps its state in a database and its large files in object storage, both of which the platform already provides. Ask CloudNativePG for a forgejo database the same way the other tools got theirs.

apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
  name: forgejo
  namespace: data
spec:
  cluster:
    name: platform-pg
  name: forgejo
  owner: app

Git LFS objects and uploaded attachments go to SeaweedFS rather than a local disk, so the forge stays stateless apart from the repositories themselves. Create a bucket and a secret for its credentials.

kubectl create namespace forge
aws --endpoint-url http://seaweedfs.storage:8333 s3 mb s3://forgejo

kubectl -n forge create secret generic forgejo-secrets \
  --from-literal=s3-secret-key='REPLACE_WITH_SEAWEEDFS_SECRET' \
  --from-literal=oidc-client-secret='the-forgejo-client-secret-from-keycloak'

§Deploy Forgejo

Forgejo is a single Go binary, which keeps the deployment small. It runs as a StatefulSet with the repositories on a rook-ceph-block volume, configured through FORGEJO__section__KEY environment variables that map onto its app.ini, pointed at the forgejo database, SeaweedFS for LFS, and Keycloak for login. The ROOT_URL is the one setting people forget, and it has to be the external address the gateway presents or every generated link and the OAuth callback will be wrong.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: forgejo
  namespace: forge
spec:
  serviceName: forgejo
  replicas: 1
  selector:
    matchLabels:
      app: forgejo
  template:
    metadata:
      labels:
        app: forgejo
    spec:
      containers:
        - name: forgejo
          image: codeberg.org/forgejo/forgejo:11   # pin a current version
          ports:
            - { name: http, containerPort: 3000 }
            - { name: ssh, containerPort: 22 }
          env:
            - { name: FORGEJO__server__ROOT_URL, value: "https://git.apk8s.dev/" }
            - { name: FORGEJO__server__DOMAIN, value: "git.apk8s.dev" }
            - { name: FORGEJO__database__DB_TYPE, value: postgres }
            - { name: FORGEJO__database__HOST, value: "platform-pg-rw.data:5432" }
            - { name: FORGEJO__database__NAME, value: forgejo }
            - { name: FORGEJO__database__USER, value: app }
            - name: FORGEJO__database__PASSWD
              valueFrom:
                secretKeyRef: { name: platform-pg-app, key: password }
            # large files to SeaweedFS instead of local disk
            - { name: FORGEJO__lfs__STORAGE_TYPE, value: minio }
            - { name: FORGEJO__lfs__MINIO_ENDPOINT, value: "seaweedfs.storage:8333" }
            - { name: FORGEJO__lfs__MINIO_BUCKET, value: forgejo }
            - { name: FORGEJO__lfs__MINIO_USE_SSL, value: "false" }
            - { name: FORGEJO__lfs__MINIO_ACCESS_KEY_ID, value: platform }
            - name: FORGEJO__lfs__MINIO_SECRET_ACCESS_KEY
              valueFrom:
                secretKeyRef: { name: forgejo-secrets, key: s3-secret-key }
          volumeMounts:
            - { name: data, mountPath: /data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: rook-ceph-block
        resources: { requests: { storage: 50Gi } }
---
apiVersion: v1
kind: Service
metadata:
  name: forgejo
  namespace: forge
spec:
  selector:
    app: forgejo
  ports:
    - { name: http, port: 3000, targetPort: 3000 }
kubectl apply -f forgejo-statefulset.yaml -f forgejo-service.yaml
kubectl -n forge rollout status statefulset/forgejo

Put it behind the gateway with an HTTPRoute at git.apk8s.dev, the same pattern every other tool uses.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: forgejo
  namespace: forge
spec:
  parentRefs:
    - name: platform-gateway
      namespace: gateway
  hostnames:
    - "git.apk8s.dev"
  rules:
    - backendRefs:
        - name: forgejo
          port: 3000

On first run, complete the initial admin setup, then wire authentication to Keycloak: add an OAuth2 authentication source pointing at the realm’s discovery URL with the forgejo client credentials, so your team logs in with the same single sign-on as everything else rather than a separate Forgejo account.

§CI/CD With Forgejo Actions

The forge also runs the pipelines. Forgejo Actions is a CI/CD system that is compatible with GitHub Actions workflow syntax, so the .forgejo/workflows/*.yaml files look like the Actions files most people already know, and much of the existing ecosystem of actions works unchanged. The jobs run on a separate runner you deploy, act_runner, which registers to Forgejo with a token and then picks up jobs.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: forgejo-runner
  namespace: forge
spec:
  replicas: 2
  selector:
    matchLabels:
      app: forgejo-runner
  template:
    metadata:
      labels:
        app: forgejo-runner
    spec:
      containers:
        - name: runner
          image: code.forgejo.org/forgejo/runner:6   # pin a current version
          command: ["forgejo-runner", "daemon"]
          env:
            - { name: FORGEJO_INSTANCE_URL, value: "http://forgejo.forge:3000" }
            - name: FORGEJO_RUNNER_REGISTRATION_TOKEN
              valueFrom:
                secretKeyRef: { name: forgejo-runner-token, key: token }

A typical pipeline checks out the code, runs the tests, and builds a container image. The image build does not use a Docker daemon, because mounting one into a CI job is the security hole this platform avoids; it uses BuildKit, which builds images rootless and is the subject of its own post in this series. The built image lands in Forgejo’s own package registry, which every Forgejo instance includes, so the forge holds your source, your pipelines, and your images in one place you own.

§Operating the Forge

A hosted forge runs your CI fleet, your registry, and your backups behind the bill. Own it and those are yours, and Forgejo keeps them simple.

Scale the CI fleet. Build capacity is the number of runner replicas. More concurrent pipelines is more act_runner pods, scheduled across the cluster like any other workload, and they cost nothing when idle. There is no per-minute CI meter, the constraint is your own nodes, which you can see and grow.

Use the built-in registry. Forgejo ships a package registry that speaks the container, npm, PyPI, and Maven protocols, among others. The platform does not need a separate registry deployment; the forge is the registry, and images built in CI push to it under the same authentication as everything else.

Back it up, which you already do. Forgejo’s state is the forgejo Postgres database, the LFS objects in SeaweedFS, and the repositories on a Ceph volume. All three are covered by the backups from earlier in the series: the database by Postgres point-in-time recovery, the object store by its own durability, the repository volume by Ceph snapshots. There is no special forge backup to invent; the platform’s existing discipline covers it.

Mirror for resilience. Forgejo can push-mirror repositories to another remote, so a copy of critical repos lives off the cluster as well, which is cheap insurance for the one system you least want to lose.

§When Something Is Wrong

Generated links and OAuth callbacks point at the wrong host. The ROOT_URL. Behind a gateway it must be the external https://git.apk8s.dev/, not the pod’s internal address; Forgejo builds every URL from it, including the Keycloak redirect.

Runners never pick up jobs. Registration. Confirm the registration token is valid and the runner can reach the instance URL http://forgejo.forge:3000; a runner that cannot register sits idle and silent.

LFS pushes fail. The SeaweedFS settings. Forgejo speaks the MinIO/S3 protocol to it, so confirm the endpoint, bucket, and credentials, and that MINIO_USE_SSL matches how SeaweedFS is exposed inside the cluster.

Keycloak login fails or loops. The same OIDC suspects as every other tool: the forgejo client’s redirect URI must match the callback under https://git.apk8s.dev, exactly.

§What You Have

A self-hosted git forge with CI/CD and a container registry, on a lightweight project whose governance you can trust, running on the platform’s own database, object storage, and single sign-on, behind its gateway. It does what GitHub and GitLab sell, on infrastructure you own, with no per-seat bill and no critical dependency on an account somewhere else. The source, the pipelines, and the images that drive the whole platform now live on the platform.

The pipelines need a safe way to build container images without a Docker daemon, and that is next: rootless, in-cluster image builds with BuildKit.

← back to all notes