There comes a point in every developers life when they think to themselves, “I should self host this.” After the numerous outages with Github and my disdain for Microsoft as a company. I decided to self-host Forgejo, I picked Forgejo over Gitea, because of its truly open-source nature and it being run by a non-profit. Now I understand at the end of the day, the world needs for profit businesses so people can put food on the table, but that isn’t my problem with for-profit companies. My only problem is when they shove unnecessary things into every product which I don’t care for (AI) all I want is a nice pretty UI to look at my code, but now I can’t even look at how many lines code I’ve commited because I don’t have Github premium; or click anywhere without seeing an ad for Copilot.

Why Kubernetes?

Now you may be thinking, “Just use a $5 VPS, Kubernetes is overkill,’ and you would be absolutely right. Except I do not care and it’s a great learning experience, Forgejo does not have documentation for running it on Kubernetes so there’s no step by step tutorial, which means I can really put my knowledge to the test. I’m still in the process of reading Kubernetes in Action, which is a fantastic book that does a great job of not only explaining Kubernetes, but also touches on how containers work and why they only Work on Linux. It’s a great read that I highly recommend for anyone getting started out with Kubernetes.

The app I’m currently working on Sellflow Rewards will also be run on Kubernetes with CloudNativePG and the Gateway API, so getting experience with both will be very useful. It’s also just a great tool to have under your belt as Kubernetes is a super powerful software that can do some amazing things.

I am not yet an expert

I am still in the process of learning Kubernetes and the Gateway API is very complicated, so please do not take this post as a tutorial. These are just the steps I took to setup Forgejo in my Kubernetes cluster, if I got any information wrong or could’ve done something better feel free to leave a comment I love constructive criticism.

Forgejo config

The Forgejo documentation has a Docker installation page with this file.

networks:
  forgejo:
    external: false

services:
  server:
    image: codeberg.org/forgejo/forgejo:15
    container_name: forgejo
    environment:
      - USER_UID=1000
      - USER_GID=1000
    restart: always
    networks:
      - forgejo
    volumes:
      - ./forgejo:/data
      - /etc/localtime:/etc/localtime:ro
    ports:
      - '3000:3000'
      - '222:2'

Translating this over to Kubernetes looks something like this

apiVersion: v1
kind: Pod
metadata:
  name: forgejo
  namespace: forgejo
spec:
  securityContext: 101
  volumes:
  - name: time
    hostPath:
      path: /etc/localtime
  containers:
  - image: codeberg.org/forgejo/forgejo:15
    name: forgejo
    env:
    - name: USER_UID
      value: "1000"
    - name: USER_GID
      value: "1000"
    ports:
    - containerPort: 27017
      protocol: TCP
    volumeMounts:
    - name: time
      mountPath: /etc/localtime
      readOnly: true

I’ll try my best to explain all of this in detail.

  • apiVersion - This lets Kubernetes know which version of its API this file is for as Kubernetes is constantly updating all of it’s API’s so having backwards compatibility is crucial for production workloads that you don’t want to break.

  • kind - Kind is the type of resource the file is for, this file is for a Pod the smallest deployable unit in Kubernetes, while a Pod usually has only one container it is not limited to one container

  • metadata - Unlike most software metadata actually matters in Kubernetes, the name is just the name of the Pod in this file, but the namespace allows you to seperate pieces of your cluster, to avoid naming collisions and prevent secrets from being viewed by the wrong apps. When working on this, I forgot the namespace mattered and had my Load Balancer in the default namespace while Forgejo was in the forgejo namespace. I couldn’t figure out why my Load Balancer wasn’t sending requests to Forgejo. While namespaces don’t provide complete isolation they are great for prevent various collisions across projects.

  • spec - The spec provides the characteristics you want the resource to have in this case it defines the desired container for the pod to run along with any environment vairables or other configuration necessary for the container.

  • securityContext - I’m going to be honest I forgot exactly what this one did, all I know is it was important for the hostPath volume security rules

  • volumes - Volumes are very similar for Docker volumes except they aren’t always files which live directly on the hosts machine indefinitely, volumes can just be files necessary to run a cache for example so it isn’t a huge deal if it’s deleted. Pods can be deleted at any time so if a volume of type emptyDir were to be used for a database with would be bad news. In this case hostPath volume is directly stored on the nodes file system which is why the securityContext is necessary. We use hostPath here because the pod only requires read access to the nodes timezone.

  • containers - These are the containers the Pod will actually run, Pod’s usually run one container, but can run multiple containers. While you can run multiple containers in one Pod, it’s best to run them in separate Pods if possible to allow both containers to scale independently. Most of the data is the same as the Docker container and behaves the same, except the ports which is not strictly necessary, by default every Pod in Kubernetes has access to everything within the same namespace, so this is not strictly necessary, but best practice. The hostPath volume described above doesn’t do anything until it is actually mounted to a container, each volume mount can be marked as readOnly as an extra precaution if the container does not require read access, in this case the container only needs to read the nodes timezone, so it is marked as readOnly.

With this config file looking pretty good (although not complete) we’ll now configure the database, which is a piece of cake with CNPG as the only problem I had was an incorrectly configured PVC (Persistent Volume Claim) which took five minutes to fix.

Postgres

CNPG makes setting up Postgres a breeze, it’s almost like having Amazon’s RDS on standby. For the most up to date setup guide please visit thier Simply run this command to setup the CRD’s (Customer Resource Definitions)

kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yaml

Then you can test that it worked by running this command kubectl rollout status deployment \ -n cnpg-system cnpg-controller-manager.

Catalog & Cluster

CNPG allows for you to update postgres through image catalogs, decoupled from the Postgres image lifecycle. For a much better explanation on exactly what the catalog is and how to use it see the documentation. First I installed the catalog using the following command.

kubectl apply -f \
  https://raw.githubusercontent.com/cloudnative-pg/artifacts/refs/heads/main/image-catalogs/catalog-minimal-trixie.yaml

Then I wrote the following image catalog file, I just went with a simple catalog with the latest version of Postgres at the time of writing.

#catalog.postgres.yaml

apiVersion: postgresql.cnpg.io/v1
kind: ClusterImageCatalog
metadata:
  name: postgresql-global
spec:
  images:
    - major: 18
      image: ghcr.io/cloudnative-pg/postgresql:18.3-system-trixie

Using this we can then run kubectl apply -f catalog.postgres.yaml now we can finally create our CNPG cluster:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres
  namespace: forgejo
spec:
  imageCatalogRef:
    apiGroup: postgresql.cnpg.io
    kind: ClusterImageCatalog # Or 'ImageCatalog'
    name: postgresql-minimal-trixie
    major: 18
  instances: 3
  storage:
    size: 1Gi

Now as for how the catalog works and if it’s strictly necessary, I have no idea. The instances: 3 create a primary read/write db with two read replicas. CNPG automatically provisions PVC’s for you, so if you’re good with the defaults there’s no need to configure this. With that we have our database setup!

Forgejo Volume

Forgejo needs a place to store all of its data, to do this we need to create a PVC for long term storage for all of the code, images, files, etc.

#pvc.forgejo.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: forgejo-pvc
  namespace: forgejo
spec:
  storageClassName: "linode-block-storage-retain" 
  accessModes:
    - ReadWriteOnce
  volumeMode: Filesystem
  resources:
    requests:
      storage: 5Gi

The apiVersion, kind, and metadata all mean the same thing as before, but the spec is a little different. The storageClassName varies from hosting providers I’m using Linode, so this is one of the storage options. Basically all this is, is the type of storage you want some cloud providers may provide hard drive, or SSD options for higher performance at a higher cost. The name for this varies from provider to provider, if not specified whatever the cloud provider has set as the default will be chosen. You can view you providers options for storage classes by running kubectl get sc.

It is important that you pay attention to your cloud providers storage class reclaim policy, which can be Retain or Delete. Retain keeps the Volume alive even if the PVC has been destroyed, Delete destroys the Volume when the PVC is destroyed.

The access mode is also another important one there are ReadWriteOnce, ReadWriteMany, ReadOnlyMany. Your options vary from cloud provider, but all of them Support ReadWriteOnce while only some support ReadWriteMany due to it being very difficult to implement. ReadWriteOnce allows only one node to read and write to the persistent volume, ReadWriteMany allows multiple nodes to read and write to the persistent volume concurrently. Linode only supports ReadWriteOnce and I have no need for ReadWriteMany,so that’s what I went with.

The volumeMode has two options Filesystem or Block filesystem is the default regular storage, Block is a more performant storage options for specialized applications such as databases. The reason I’m not using it here is because I don’t really understand what the differences are and if it didn’t work I wouldn’t know how to fix it. Filesystem just works so that’s what I went with, the extra performance would only really matter at scale so this works fine for me.

resources aren’t what the PVC is limited to it’s more of a starting point to get the Pod running, this cluster begins with 5gi of storage. The PVC allows for the volume to expand as the database stores more data, as shown by the storage class ALLOWVOLUMEEXPANSION property when running kubectl get sc.

With the PVC setup run kubectl apply -f pvc.forgejo.yaml which should create the PVC. Now that we’ve got the database and PVC created for forgejo, we can go ahead and edit out pod.forgejo.yaml file.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: forgejo
  namespace: forgejo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: forgejo
  template:
    metadata:
      labels:
        app: forgejo
    spec:
      securityContext:
        fsGroup: 101
      volumes:
      - name: time
        hostPath:
          path: /etc/localtime
          type: File
      - name: forgejo-data
        persistentVolumeClaim:
          claimName: forgejo-pvc
      containers:
      -  name: forgejo
         image: codeberg.org/forgejo/forgejo:15
         env:
           - name: USER_UID
             value: "101"
           - name: USER_GID
             value: "101"
           - name: FORGEJO__database__DB_TYPE
             value: postgres
           - name: FORGEJO__database__HOST
             valueFrom:
               secretKeyRef:
                 name: postgres-app
                 key: host
           - name: FORGEJO__database__NAME
             valueFrom:
               secretKeyRef:
                 name: postgres-app
                 key: dbname
           - name: FORGEJO__database__USER
             valueFrom:
               secretKeyRef:
                 name: postgres-app
                 key: username
           - name: FORGEJO__database__PASSWD
             valueFrom:
               secretKeyRef:
                 name: postgres-app
                 key: password
         volumeMounts:
         - name: time
           mountPath: /etc/localtime 
           readOnly: true
         - name: forgejo-data
           mountPath: /data
         ports:
         - containerPort: 3000
           protocol: TCP
         - containerPort: 22
           protocol: TCP
         livenessProbe:
           httpGet:
             path: /api/healthz
             port: 3000
           initialDelaySeconds: 15

I’ve added the PVC in persistentVolumeClaim using the name we created in the pvc.forgejo.yaml file which is then mounted in volumeMounts. I’ve also added a livenessProbe so Kubernetes can monitor the health of the Pod and restart if necessary. Additionally I’ve added the following environment variables and secrets, the keys of each secret have to be typed exactly as follows or the will not work. The name is <postgress-cluster-name>-app and the key is one of the following:

  • host

  • dbname

  • username

  • password

I couldn’t find these keys anywhere in the documentation so hopefully those help you. With this you can run your Forgejo pod with kubectl apply -f pod.forgejo.yaml. This will get the pod running, but to expose it to the internet with TLS is a bit tricky, especially when you factor in SSH.

Gateway API

Initially I went with the Ingress API to allow external traffic into my cluster, however I found that setting up SSH was rather tricky and seeing as the Ingress API is not longer being actively maintained, I figured the Gateway API was best. Most of this won’t be explained since it’s either self-explanatory or I don’t understand it. To install the Gateway API I ran the following:

kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/experimental-install.yaml

We went with the experimental install, due to the fact that TCPRoute is required for SSH and is currently in the experimental state.

With this, we need a values.yaml file, I believe this is Helm specific for configuration, but correct me if I’m wrong. This is important for actually exposing the necessary ports to the internet and turning on the experimental TCPRoute necessary for SSH.

#values.yaml
logs:
  access:
    enabled: true
ports:
  web: 
    port: 80
  websecure:
    port: 443
    exposedPort: 443
  ssh:
    port: 22
    expose:
      default: true
    exposedPort: 22
    protocol: TCP

# we enable gateway-api features
providers:
  kubernetesGateway:
    enabled: true
    experimentalChannel: true

# we disable gateway-api defaults because we want to provide our own
gatewayClass:
  enabled: false

# we disable gateway-api defaults because we want to provide our own
gateway:
  enabled: false

Then following this tutorial I ran:

CHART_VERSION="40.0.0" # I had problems installing v37.0.3
helm repo add traefik https://helm.traefik.io/traefik
helm repo update
helm search repo --versions 

helm install traefik traefik/traefik \
 --version $CHART_VERSION \
 --values values.yaml \
 --namespace traefik \
 --create-namespace

Now we can create our gateway class which our gateway will actually use to run:

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: traefik
  namespace: forgejo
spec:
  controllerName: traefik.io/gateway-controller

Now create the gateway itself which is what handles the traffic and sends it off to our routes:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: traefik-gateway
  namespace: forgejo
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  gatewayClassName: traefik
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Same
    - name: https
      protocol: HTTPS
      port: 443
      hostname: code.echostudios.dev
      tls:
        mode: Terminate
        certificateRefs:
        - group: ""
          kind: Secret
          name: letsencrypt-secret-prod
      allowedRoutes:
        namespaces:
          from: Same
    - name: ssh
      protocol: TCP
      port: 22
      allowedRoutes:
        namespaces:
          from: Same

Now this has an HTTPS redirect, if you don’t have a cert-manager already setup follow this Linode tutorial. The power of the Gateway API is decoupling everything from one gigantic file with all of your routes and configuration into separate files. Here we’ll define our routes which tell the ports from the gateway file which service to actually send the traffic to.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: forgejo-https-route
  namespace: forgejo
spec:
  hostnames:
  - code.echostudios.dev
  parentRefs:
  - name: traefik-gateway
    sectionName: https
    kind: Gateway
  rules:
  - backendRefs:
    - name: forgejo
      port: 3000
    matches:
      - path:
          type: PathPrefix
          value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: forgejo-http-route
  namespace: forgejo
spec:
  hostnames:
  - code.echostudios.dev
  parentRefs:
  - name: traefik-gateway
    sectionName: http
    kind: Gateway
  rules:
  - filters:
    - requestRedirect:
        scheme: https
        statusCode: 301
      type: RequestRedirect
    matches:
      - path:
          type: PathPrefix
          value: /
---
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
  name: ssh-route
  namespace: forgejo
spec:
  parentRefs:
  - name: traefik-gateway 
    port: 22
  rules:
  - backendRefs:
      - name: forgejo
        port: 22

Now with that we can finally setup our ClusterIP service to point directly to our Pod deployment. You can create a separate file for this or append it to your pod.forgejo.yaml.

apiVersion: v1
kind: Service
metadata:
  name: forgejo
  namespace: forgejo
spec:
  type: ClusterIP
  ports:
  - name: ssh
    port: 22
    targetPort: 22
  - name: https
    port: 3000
    targetPort: 3000
    appProtocol: http
  selector:
    app: forgejo

Now just apply all of those files with kubectl apply -f one by one or kubectl apply -R to apply all files recursively. Now we have our very own self-hosted Forgejo instance! 🎊

Conclusion

While I had tons of fun working on this project and learned a lot, there’s still a lot of work to do. Namely backups and getting Forgejo runners working, the community is working hard to get runners working natively on Kubernetes, however at the time of writing there are only community made solutions for this as Forgejo is still deeply coupled with Docker and relies on Docker in Docker features.

Working on this project allowed me to finally tie together everything I learned about Kubernetes by applying everything I’ve learned thus far.

Reply

Avatar

or to participate

Keep Reading