Gitea on Kubernetes without Helm, with Kapitan
Gitea 28.3.0, a self-hosted Git service 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 Gitea’s Helm chart 12.7.0. The included recipe postgresql is the database, valkey holds cache, sessions and queues.
- Latest version:
1.0.1, published - Source chart:
oci://docker.gitea.com/charts/gitea:12.7.0@sha256:4b709d7e9214bef9e1c71383924e88944cdb0d6dcd892a32971aca52ccc5f596 - 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 8 findings, one per advisory and package, in the 1 image of version 1.0.1: CRITICAL 0, HIGH 5, MEDIUM 2, LOW 0, UNKNOWN 1. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.gitea.com/gitea.
What it deploys
recipes.gitea(init.yml)recipes.gitea.gitea(gitea.yml):gitea
Required parameters
applications.gitea.admin.username and applications.gitea.admin.password
are the login and password of the admin account. 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. Gitea refuses to set a password of
fewer than 8 characters, which then stops the pod’s start.
applications.gitea.secret_key and applications.gitea.internal_token are
SECRET_KEY and INTERNAL_TOKEN of [security] in app.ini, random
strings as openssl rand -hex 32 prints them. Gitea encrypts stored secrets
such as two-factor keys with the first one. The init container
init-app-ini writes both only while the claim holds no app.ini, so a
later change in the target does not reach Gitea.
applications.valkey.password is the password of valkey. Gitea reads it
inside a redis:// URL that the recipe does not encode, so use letters and
digits only, as openssl rand -hex 32 prints them.
Set the passwords, the key and the token to refs with the backend of your
choice. Without one of them 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.gitea
parameters:
namespace: gitea
kapitan:
vars:
target: gitea
applications:
gitea:
admin:
username: <login>
password: <ref>
secret_key: <ref>
internal_token: <ref>
valkey:
password: <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.1:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/gitea:1.0.1@sha256:ac8b258d091a975bf8adddcc5d5ec14536bbd5e23b1db674506513a2f829ec0c
output_path: inventory/classes/recipes/gitea
Version 1.0.1 includes postgresql 1.1.1 and valkey 1.0.3. 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
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5
mkdir -p inventory/classes/recipes/valkey
tar -xf "$tmp/valkey.tar" -C inventory/classes/recipes/valkey --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/gitea, 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/gitea@sha256:ac8b258d091a975bf8adddcc5d5ec14536bbd5e23b1db674506513a2f829ec0c \
--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
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5 \
--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.gitea, <ini> for
<c>.init_containers.init-app-ini.env.
| Chart value | Inventory |
|---|---|
image.tag |
<c>.image, tag and digest, and applications.gitea.labels.version |
gitea.admin.email |
applications.gitea.admin.email, gitea@local.domain |
gitea.admin.passwordMode |
<c>.init_containers.configure-gitea.env.GITEA_ADMIN_PASSWORD_MODE, keepUpdated |
gitea.config.<section>.<KEY> |
<ini>.GITEA__<SECTION>__<KEY>, such as GITEA__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.ssh.type, service.ssh.annotations |
<c>.additional_services.gitea-ssh.type, .annotations, with headless: false for a type other than ClusterIP |
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 gitea-inline-config and then from the GITEA__* variables of its
environment, which win. The recipe keeps the chart’s ROOT_URL
http://git.example.com, so set <ini>.GITEA__SERVER__ROOT_URL,
GITEA__SERVER__DOMAIN and GITEA__SERVER__SSH_DOMAIN to the address users
reach Gitea at. The Services gitea-http on port 3000 and gitea-ssh on
port 22 are headless, as the chart renders them. The API does not change
the clusterIP of an existing Service, so delete the Service before
switching headless off. The chart’s Ingress,
Gateway API routes and OpenShift Route are off by default and not part of
the recipe.
Cache, sessions and queues use valkey, which the chart’s own Valkey
subcharts would provide. The database connection uses sslmode=require,
because CloudNativePG serves TLS. All containers run with
runAsNonRoot: true, the chart’s containerSecurityContext in
tests/values.yaml. 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 Gitea with
kubectl rollout restart deployment/gitea. The scripts in the Secrets
gitea and gitea-init are the chart’s, with its license in
gitea.yml.LICENSE.
The chart 12.7.0 has no Actions runner. act_runner, which needs Docker in Docker, has a chart of its own in gitea/helm-actions and is not part of the recipe. The chart’s LDAP and OAuth sources, GPG signing and metrics are off by default and not part of the recipe either. Its PostgreSQL, PostgreSQL HA, Valkey and Valkey Cluster subcharts are Bitnami’s and stay off.
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/gitea-test-connection.
Proof
check gitea --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 and valkey 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 Services target the ports by name, http and ssh, the
chart by number, 3000 and 2222. 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 through the
Service and the API server’s service proxy, which must report pass for
the database and the cache. /api/v1/version must report the version of
the Deployment’s image tag. As the admin it creates the repository smoke
through POST /api/v1/user/repos. A pod running Gitea’s image clones it
over HTTP through the Service gitea-http, commits a file and pushes it,
and the API must return that commit for the branch main and the file’s
content. Valkey must then hold keys.
Versions
1.0.1, :sha256:ac8b258d091a975bf8adddcc5d5ec14536bbd5e23b1db674506513a2f829ec0c1.0.0, :sha256:12d127bbd2eaab3ec4a366c76569788cb96dbd1744bde58ef4ab4241eae1c875
The recipe's files are in kadet-hub/recipes. All recipes