kadet-hub logokadet-hub

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.

What it deploys

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

The recipe's files are in kadet-hub/recipes. All recipes