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: trueI’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
hostPathvolume security rulesvolumes - 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
emptyDirwere to be used for a database with would be bad news. In this casehostPathvolume is directly stored on the nodes file system which is why thesecurityContextis necessary. We usehostPathhere 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
hostPathvolume described above doesn’t do anything until it is actually mounted to a container, each volume mount can be marked asreadOnlyas 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 asreadOnly.
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.yamlThen 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.yamlThen 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-trixieUsing 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: 1GiNow 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: 5GiThe 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: 15I’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.yamlWe 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: falseThen 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-namespaceNow 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-controllerNow 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: SameNow 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: 22Now 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: forgejoNow 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.

