Forgejo on Kubernetes without Helm, with Kapitan
Forgejo 15.0.11, a self-hosted Git forge with a web UI and an API, as one replica with its admin account, rendered by the kadet-hub kubernetes generator 1.14.0 from Forgejo’s Helm chart 17.3.2. The included recipe postgresql is the database.
- Latest version:
1.0.0, published - Source chart:
oci://code.forgejo.org/forgejo-helm/forgejo:17.3.2@sha256:73accaef0604d6e701a7b141e71a8b9a007c1475807240403d8bca0aeda24b74 - Proof level:
deploys, compared with the chart by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Prerequisites, installed once per cluster before the recipe:
cnpg - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade D 15 findings, one per advisory and package, in the 1 image of version 1.0.0: CRITICAL 0, HIGH 9, MEDIUM 4, LOW 0, UNKNOWN 2. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:code.forgejo.org/forgejo/forgejo.
What it deploys
recipes.forgejo(init.yml)recipes.forgejo.forgejo(forgejo.yml):forgejo
Required parameters
applications.forgejo.admin.username and applications.forgejo.admin.password
are the login and password of the admin account, which the recipe writes
into the Secret forgejo-admin. The init container configure-gitea
creates the account on the first start and sets its password on every later
start, the chart’s gitea.admin.passwordMode: keepUpdated. Forgejo rejects
the login admin.
applications.forgejo.secret_key is SECRET_KEY of [security] in
app.ini, a random string as openssl rand -hex 32 prints it. Forgejo
encrypts stored secrets such as two-factor keys with it, and they live in
the database, apart from the claim. The init container init-app-ini
writes the key only while the claim holds no app.ini, so a later change
in the target does not reach Forgejo.
Set the password and the key to refs with the backend of your choice.
Without one of the three parameters the compile stops with
Interpolation key '<key>' not found. postgresql requires no parameter.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.forgejo
parameters:
namespace: forgejo
kapitan:
vars:
target: forgejo
applications:
forgejo:
admin:
username: <login>
password: <ref>
secret_key: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.0:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/forgejo:1.0.0@sha256:f5d9f119a41a950e18888879e611bcca03b969f58d327da65aea031038972f40
output_path: inventory/classes/recipes/forgejo
Version 1.0.0 includes postgresql 1.1.1. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:
tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9
mkdir -p inventory/classes/recipes/postgresql
tar -xf "$tmp/postgresql.tar" -C inventory/classes/recipes/postgresql --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/forgejo, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/forgejo@sha256:f5d9f119a41a950e18888879e611bcca03b969f58d327da65aea031038972f40 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
<c> stands for components.forgejo, <ini> for
<c>.init_containers.init-app-ini.env.
| Chart value | Inventory |
|---|---|
image.tag |
<c>.image, tag and digest, and applications.forgejo.labels.version |
gitea.admin.email |
applications.forgejo.admin.email, gitea@local.domain |
gitea.admin.passwordMode |
<c>.init_containers.configure-gitea.env.GITEA_ADMIN_PASSWORD_MODE, keepUpdated |
gitea.config.<section>.<KEY> |
<ini>.FORGEJO__<SECTION>__<KEY>, such as FORGEJO__SERVER__ROOT_URL |
gitea.additionalConfigFromEnvs |
<ini> |
gitea.config.database.HOST, .NAME |
applications.postgresql.cluster and .database, which postgresql’s README lists |
persistence.size, persistence.storageClass |
<c>.persistent_volume_claims.gitea-shared-storage.storage, .storage_class, 10Gi |
service.http.type, service.ssh.type, their annotations |
<c>.service.type, <c>.additional_services.forgejo-ssh.type, .annotations |
resources |
<c>.resources |
initContainers.resources |
<c>.init_containers.init-directories.resources, which the other init containers take |
nodeSelector, tolerations, affinity |
<c>.node_selector, <c>.tolerations, <c>.affinity |
init-app-ini writes app.ini on the claim on every start, from the
Secret forgejo-inline-config and then from the FORGEJO__* variables of
its environment, which win. The recipe keeps the chart’s ROOT_URL
http://git.example.com, so set <ini>.FORGEJO__SERVER__ROOT_URL,
FORGEJO__SERVER__DOMAIN and FORGEJO__SERVER__SSH_DOMAIN to the address
users reach Forgejo at. The Services are forgejo-http on port 3000 and
forgejo-ssh on port 22. The chart’s Ingress, Gateway API routes and
OpenShift Route are off by default and not part of the recipe.
The database connection uses sslmode=require, because CloudNativePG
serves TLS. Cache and sessions stay in Forgejo’s memory and the queue in a
LevelDB on the claim, the chart’s defaults, so sessions end with the pod.
For Valkey, include recipes.valkey and set the FORGEJO__CACHE__*,
FORGEJO__SESSION__* and FORGEJO__QUEUE__* variables in <ini> as the
chart’s README describes. The Deployment replaces its pod with the strategy
Recreate, because the claim is ReadWriteOnce. The rootless image runs
as UID 1000.
The pod annotation checksum/config is the chart’s hash of its config
Secrets for the values of tests/values.yaml. It does not follow the
target’s values, so after changing a Secret restart Forgejo with
kubectl rollout restart deployment/forgejo. The scripts in the Secrets
forgejo and forgejo-init are the chart’s, with its license in
forgejo.yml.LICENSE.
The chart’s LDAP and OAuth sources, GPG and SSH commit signing, metrics, ServiceMonitor, PodDisruptionBudget and ServiceAccount are off by default and not part of the recipe. It renders no Forgejo Actions runner.
Any list set in the target is appended to the recipe’s list. To replace
one, set the key to ~ in a class included after the recipe and the new
list in the target.
Left out
The chart’s test pod runs only inside helm test. tests/smoke checks the
same and more on the installed recipe. It is not rendered:
Pod/forgejo-test-connection.
Proof
check forgejo --level deploys with tests/values.yaml and
tests/target.yml, on kind for each Kubernetes minor CI tests, with cnpg
installed first. The objects of postgresql are left out of the comparison
and applied with the recipe. The Deployment and the two Services select on
name, and the Services also on the label version, the chart on
app.kubernetes.io/name and instance. All three select the same pods.
The container ports are sorted by name. The pod sets
automountServiceAccountToken: true, the API default the chart leaves
unset. The chart binds no role. tests/target.yml runs one PostgreSQL
instance.
tests/smoke waits for the Cluster and gets /api/healthz and the web UI
through the Service and the API server’s service proxy. The first must
report pass for the database. /api/v1/version must report the version
of the Deployment’s image tag, and the login of forgejo-admin must be an
admin. As that account it creates the repository smoke through
POST /api/v1/user/repos. A pod running Forgejo’s image clones it over
HTTP through the Service forgejo-http, commits a file and pushes it, and
the API must return that commit for the branch main and the file’s
content. The test then deletes the Forgejo pod and reads both back again
from the pod that replaces it.
Versions
1.0.0, :sha256:f5d9f119a41a950e18888879e611bcca03b969f58d327da65aea031038972f40
The recipe's files are in kadet-hub/recipes. All recipes