Wherein I migrate my Gitea instance from Nomad to k8s.

This is part 17 of my k8s migration series.

I’ve been using Gitea as my Git forge for a while now. What’s now the Gitea instance started life is a Gogs instance in 2016, when I had to downsize my Homelab to a Raspberry Pi 3B that couldn’t handle all the things I wanted to run on it. I decided to get rid of my Gitlab instance and exchange it for Gogs. The switch back to Gitea then happened because Gitlab started eating 12% of my new home server’s CPU even when idle.

This is what the front page looks like when logged in:

A screenshot of the Gitea home page for a logged in user. At the top is a heat map, similar to the one on GitHub's user profile page. It shows a brighter color for days with a lot of activity, and a lighter color for days with less activity. It shows a full year's worth of activity, showing one colored box per day, with columns for weeks and rows for days of the week. My activity shows almost all weekend days with activity, while the winter months also show lots of activity on workdays. Below the heat map is an activity feed, showing activities from the last couple of days, like pushes to different repositories. Most of them are to the adm/homelab and mmeier/blog repository. On the right side is a list of repositories, showing ones like 'adm/homenet-docs', 'mmeier/smokes.cli' or 'learning/learning-go'. Next to some of them is a green check mark or a red cross, indicating the state of the last CI pipeline.

Screenshot of Gitea’s home page for my user.

I’m quite liking it and, in contrast to Gitlab, I never had any problems with it. It’s pretty snappy (again, especially in contrast to Gitlab) and relatively light on resources. Most of the time I can’t even tell whether it got assigned one of my beefier x86 nodes or a Raspberry Pi.

I’ve got 82 repositories stored in it, from relatively small dead projects which never got much farther than a README to extremely large repos containing 3D models and such for a Sins of a Solar Empire mod I was once involved in. Most repos don’t see a lot of activity and I’m the only user at the moment. The instance is not publicly accessible, but I might change that when the ForgeFed project matures.

My way of working depends on the repository. For my Homelab, this blog and my Homelab docs for example I’m just pushing to the master branch. (Which again reminds me to finally get around to the main branch migration.) In my development projects though I’m mostly working with Pull Requests. I find Gitea’s interface pretty convenient, and like seeing all the information and CI runs for a specific feature in one place.

So I’m not using too many actual features of Gitea, it’s mainly a convenient UI for my Git repos. But I must admit: I’m rather fond of that activity heat map. The only Gitlab feature I’m genuinely missing are the repository stats. If any of you know a good web app, either dynamic or statically generated, that can show stats on a Git repo, I’d be very interested.

Database setup and migration

I promise, this is the last time one of my migration articles will have a long-winded section on databases. 😉 But in this case, it’s warranted, because this is the first time I’m actually migrating a database, instead of setting up a new one.

I’m using CloudNativePG to manage the Postgres databases in my k8s cluster. More details can be found here. CNPG has a number of methods for seeding a new DB cluster with data. Generally, those approaches are split into two. The first way involves another online cluster and full replication or restoration of a backup. The second method is more suited to what I needed, namely a one-time import from another cluster using initdb to bootstrap the CNPG cluster. This method uses pg_dump/pg_restore from another running cluster. This method suited me somewhat well, because my Nomad Postgres setup is still up and running. The docs for this method can be found here.

There was just one problem: In Nomad, I’m using Consul Connect Service Mesh to connect services and only allow access between specific services instead of having open ports everywhere. This has been working pretty nicely in the past several years. Remember, I’m switching away from HashiCorp’s stuff not because their software is bad, but rather for ideological reasons.

But in this instance, I was stumped. For using pg_dump, CNPG needs access to the other cluster. But of course no k8s service is currently inside the Consul Mesh, so there’s no way to access the Postgres DB. I thought: Well, I can just open up a node port temporarily. And I failed. As in: I spend an entire evening trying to figure this out and had to give up. For reference, the network config for my Postgres Nomad job looks like this:

  group "postgres" {

    network {
      mode = "bridge"
    }

    service {
      name = "postgres"
      port = 5432

      connect {
        sidecar_service {}
      }

      check {
        type     = "script"
        command  = "/usr/bin/pg_isready"
        args     = ["-U", "postgres"]
        interval = "30s"
        timeout  = "2s"
        task     = "postgres"
      }
    }
[...]

With that config, Consul launches an Envoy container next to the Postgres container in the network namespace, by default on a random port. Inside the network namespace, Postgres’ 5432 port is connected to Envoy. Envoy then listens on a public port, but only lets through connections with the right mTLS cert. Other services can then be allowed to access Postgres via their own Envoy proxy. As best as I’ve been able to figure out, there’s no way for a service outside the mesh to get through the Envoy proxy to the Postgres port.

But opening another port also did not work. I’m reasonably sure that’s because trying to connect Postgres’ socket to two other sockets (the temporary public one, and Envoy’s) is just not something that can ever work. I still tried though. Pretty hard even.

But in the end I threw up my hands and had to admit that I was trying something that’s simply not possible. I could either have that port accessible on the node, or via the Consul Mesh, but not both.

I also couldn’t just temporarily switch off the Consul Mesh for Postgres, because that would have impacted other workloads on my Nomad cluster. Took me quite a while to come up with the solution: I remembered that, during my initial migration to Nomad from my Docker Compose setup, I had set up an Ingress Gateway to provide access to the already migrated services from the apps still running in Docker Compose. That Ingress Gateway does pretty much what it says on the tin: It allows services from outside the mesh access to services inside the mesh. It was of course not as fine-grained as the service mesh itself. If a service could reach the gateway, it could access all services inside the mesh that the gateway was allowed to access.

Luckily, by the time I originally set up the Ingress Gateway, I had already started to put my Homelab under version control, and I was still able to find the old Ingress Gateway definition. I pared it down to only Postgres, and the Nomad job ended up looking like this:

job "ingress-gateways" {
  datacenters = ["homenet"]

  group "internal" {
    network {
      mode = "bridge"

      port "postgres" {
        static = 5577
        to = 5577
      }
    }

    service {
      name = "ingress-internal"
      port = "8080"

      connect {
        gateway {
          ingress {
            listener {
              port = 5577
              protocol = "tcp"
              service {
                name = "postgres"
              }
            }
          }
        }
      }
    }
  }
}

This definition starts by setting up a bridged network namespace, meaning no outside access by default. Then it creates a listener for Postgres. With that, the Envoy proxy of the service would create a socket at port 5577 in the namespace, connected to the Postgres service’s Envoy proxy. The gateway would also open a static port on 5577 on the node it is running on, which would be connected to port 5577 inside the network namespace. And with that, any service connecting to port 5577 on the host running the Ingress Gateway would be connected to the Postgres database. Pretty neat and simple setup, but took me a while to remember.

I ran test connections with this command to confirm that I finally had external access to the cluster:

psql -U gogs -h ingress-internal.service.consul -p 5577 -d gitea

With that, I finally had access to the Postgres cluster from inside my k8s cluster.

The CNPG Cluster manifest then looks like this:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: gitea-pg-cluster
  labels:
    homelab/part-of: gitea
spec:
  instances: 2
  imageName: "ghcr.io/cloudnative-pg/postgresql:17.2"
  bootstrap:
    initdb:
      database: gitea
      owner: gitea
      import:
        type: microservice
        databases:
          - gitea
        source:
          externalCluster: nomad-pg
  resources:
    requests:
      memory: 200M
      cpu: 150m
  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: "50MB"
      effective_cache_size: "150MB"
      maintenance_work_mem: "12800kB"
      checkpoint_completion_target: "0.9"
      wal_buffers: "1536kB"
      default_statistics_target: "100"
      random_page_cost: "1.1"
      effective_io_concurrency: "300"
      work_mem: "128kB"
      huge_pages: "off"
      max_wal_size: "128MB"
      wal_keep_size: "512MB"
  storage:
    size: 1.5G
    storageClass: rbd-fast
  backup:
    barmanObjectStore:
      endpointURL: http://rook-ceph-rgw-rgw-bulk.rook-cluster.svc:80
      destinationPath: "s3://backup-cnpg/"
      s3Credentials:
        accessKeyId:
          name: rook-ceph-object-user-rgw-bulk-cnpg-backup-gitea
          key: AccessKey
        secretAccessKey:
          name: rook-ceph-object-user-rgw-bulk-cnpg-backup-gitea
          key: SecretKey
    retentionPolicy: "30d"
  externalClusters:
    - name: nomad-pg
      connectionParameters:
        host: ingress-internal.service.consul
        port: "5577"
        user: gogs
        dbname: gitea
      password:
        name: olddb-secret
        key: pw

I’ve omitted some standard things like the backup bucket setup here. The important parts for the migration are the spec.bootstrap.initdb.import and spec.externalClusters keys.

Let’s start with the externalClusters definition. It’s documented here and describes the connection to another cluster. This doesn’t need to be a CNPG cluster. One problem was that seemingly, no documentation exists of the externalClusters.connectionParameters.port option. I spend quite a while trying to figure out whether the port was supposed to go on the end of the host parameter, or whether it was a separate key. I was finally saved by the fact that CNPG is open source, and so I could look at the code - specifically a Yaml file from their test setup here. The password for the connection was coming from a Secret with the old database credentials. As you can see in the user parameter, the Gitea database was originally created during the Gogs phase of my Git hosting. 😁

The second part of the config is in spec.bootstrap.initdb.import, which tells CNPG what it should import from the external cluster. The first choice to make here is the type of the import. This describes the destination cluster, meaning the new CNPG cluster. The choices are microservice, meaning that the cluster serves only one app with one user, or monolith, meaning a cluster hosting the databases of multiple services. Besides that, I just needed to provide the name of the database in the source cluster and the name of said cluster in the externalClusters list.

This import, as configured above, worked immediately. I was very positively surprised. All data was imported properly, and CNPG automatically created the customary Secret with the connection details and credentials for accessing the cluster. After the initial import, I was able to remove the spec.bootstrap.initdb.import and externalClusters keys completely from the manifest without any error.

Helm Chart

For the Gitea deployment itself, I made use of the official Helm chart. It is one of the better ones I’ve encountered since starting the migration, providing the ability to set config options in the values.yaml instead of having to maintain a separate app.init file. What I value extremely highly is that they provide the ability to add environment variables via env.ValueFrom, so I can directly use the automatically created Secrets from CNPG for the DB and Rook Ceph for the S3 bucket. This safes me the roundabout setup I had to do for other charts, where I had to use external-secrets to template the auto Secrets into new Secrets with a different format to conform to the Chart’s expectations.

One downside at the time of writing is that the chart is not on the newest Gitea version 1.23.1. But I just changed the image tag and chart version 10.6.0 worked without issue. Going by this GitHub issue the delay is simply due to some internal refactoring of the chart they want to finish before the next release.

I will split the exploration of my values.yaml file into two parts, one with the Gitea config under the gitea key and one for everything else.

Everything besides the Gitea config

Let’s start with the “everything else” part:

replicaCount: 1
image:
  rootsless: true
  tag: "1.23.1"

strategy:
  type: Recreate

containerSecurityContext:
  capabilities:
    add:
      - SYS_CHROOT

service:
  ssh:
    type: LoadBalancer
    port: 2222
    externalTrafficPolicy: Local
    annotations:
      external-dns.alpha.kubernetes.io/hostname: git.example.com
    labels:
      homelab/public-service: "true"

ingress:
  enabled: true
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: very-safe-entrypoint
  hosts:
    - host: gitea.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - hosts:
      - gitea.example.com

resources:
  requests:
    cpu: 1
  limits:
    memory: 1500Mi

persistence:
  enabled: true
  create: false
  mount: true
  claimName: gitea-data-volume

signing:
  enabled: false

actions:
  enabled: false

redis-cluster:
  enabled: false

postgresql-ha:
  enabled: false

postgresql:
  enabled: false

As I noted above, I had to hardcode the Gitea tag for now, because the newest chart version is still on 1.22.3. I also opted for using the rootless image, which I did not use in the Nomad job. It just is a little bit nicer than having a root-capable image, although Gitea automatically drops root privileges at startup and even with the root image, Gitea doesn’t actually run as root.

I also had to hardcode the update strategy to Recreate. I’m not sure why it’s set to rolling updates by default. This doesn’t really work, because Gitea is launched in a Deployment and has a PVC mounted, so the newly started instance won’t actually be able to start because the RWO volume can’t be mounted in two Pods at the same time.

Then comes an important one, the SYS_CHROOT capability. This is documented as required when using cri-o as the container runtime in the Helm chart’s extensive README.md.

External access is split between two different subdomains, one for Gitea’s web frontend going through my Traefik ingress and one for git access with SSH. I like setting my LoadBalancer services, provided by Cilium, to the Local externalTrafficPolicy. This ensures that the client IP those services see are the actual client IPs, and not the IPs of the machine which received the request and forwarded it to the service it was intended for. The homelab/public-service label is simply a sign for Cilium that it should handle the service.

The ingress config has one important point, the tls key. I initially did not set that key, because I’ve never set it before - my Traefik automatically uses HTTPS and I’m using a wildcard cert. But Gitea needs to generate publicly addressable URLs for some things, e.g. when providing clone URLs for HTTPS or providing callback URLs in webhooks, e.g. for Woodpecker. The domain itself is fine, but the chart determines the protocol like this:

{{- define "gitea.public_protocol" -}}
{{- if and .Values.ingress.enabled (gt (len .Values.ingress.tls) 0) -}}
https
{{- else -}}
{{ .Values.gitea.config.server.PROTOCOL }}
{{- end -}}
{{- end -}}

In there, https is only set as the protocol if the ingress.tls list has at least one entry. And setting the server.PROTOCOL config comes with it’s own problems, so I decided to just add the tls.hosts setting, even if it means that I have to repeat my Gitea domain a number of times in the config.yaml.

For the persistence setting I had to go with a pre-created volume, because I had to make the migrated Gitea data available. One thing to note here is that you should delete the app.ini file your previous setup might have left on the disk. The init container which puts together the app.ini from the values and env variables and so on doesn’t handle it well when there’s an existing app.ini it didn’t create itself.

I also disabled a number of features I didn’t need, like signing or actions or the Redis and Postgres instances the Helm chart can deploy, because I’ve already got my own deployments.

The Gitea config

Here’s the full gitea: section of the config.yaml, just for reference. I will post the relevant subsections as I go over them:

gitea:
  admin:
    existingSecret: null
    username: null
    password: null
  metrics:
    enabled: false
  oauth:
    - name: "Keycloak"
      provider: "openidConnect"
      existingSecret: oidc-credentials
      autoDiscoverUrl: "https://key.example.com/realms/homelab/.well-known/openid-configuration"
  config:
    APP_NAME: "My Gitea"
    RUN_MODE: "prod"
    server:
      SSH_SERVER_HOST_KEYS: "ssh/gitea.ed25519"
      APP_DATA_PATH: "/data/gitea_data"
      SSH_DOMAIN: "git.example.com"
      SSH_PORT: 2222
    database:
      DB_TYPE: "postgres"
      LOG_SQL: false
    oauth2:
      ENABLED: true
    service:
      DISABLE_REGISTRATION: true
      REQUIRE_SIGNIN_VIEW: true
      DEFAULT_KEEP_EMAIL_PRIVATE: true
      DEFAULT_ALLOW_CREATE_ORGANIZATION: true
      DEFAULT_ORG_VISIBILITY: true
      DEFAULT_ORG_MEMBER_VISIBLE: false
      DEFAULT_ENABLE_TIMETRACKING: true
      SHOW_REGISTRATION_BUTTON: false
    repository:
      ROOT: "/data/git-repos"
      SCRIPT_TYPE: bash
      DEFAULT_PRIVATE: private
      DEFAULT_BRANCH: main
    ui:
      DEFAULT_THEME: gitea-auto
    queue:
      TYPE: redis
      CONN_STR: "addr=redis.redis.svc.cluster.local:6379"
      WORKERS: 1
      BOOST_WORKERS: 5
    admin:
      DEFAULT_EMAIL_NOTIFICATIONS: disabled
    openid:
      ENABLE_OPENID_SIGNIN: false
    webhook:
      ALLOWED_HOST_LIST: private
    mailer:
      ENABLED: true
      SUBJECT_PREFIX: "[Gitea]"
      SMTP_ADDR: mail.example.com
      SMTP_PORT: "465"
      FROM: "gitea@example.com"
      USER: "gitea@example.com"
    cache:
      ADAPTER: "redis"
      INTERVAL: 60
      HOST: "network=tcp,addr=redis.redis.svc.cluster.local:6379,db=0,pool_size=100,idle_timeout=180"
      ITEM_TTL: 7d
    session:
      PROVIDER: redis
      PROVIDER_CONFIG: network=tcp,addr=redis.redis.svc.cluster.local:6379,db=0,pool_size=100,idle_timeout=180
    time:
      DEFAULT_UI_LOCATION: "Europe/Berlin"
    cron:
      ENABLED: true
      RUN_AT_START: false
    cron.archive_cleanup:
      ENABLED: true
      RUN_AT_START: false
      SCHEDULE: "@every 24h"
    cron.update_mirrors:
      ENABLED: false
      RUN_AT_START: false
    cron.repo_health_check:
      ENABLED: true
      RUN_AT_START: false
      SCHEDULE: "0 30 5 * * *"
      TIMEOUT: "5m"
    cron.check_repo_stats:
      ENABLED: true
      RUN_AT_START: true
      SCHEDULE: "0 0 5 * * *"
    cron.update_migration_poster_id:
      ENABLED: true
      RUN_AT_START: true
      SCHEDULE: "@every 24h"
    cron.sync_external_users:
      ENABLED: true
      RUN_AT_START: false
      SCHEDULE: "@every 24h"
      UPDATE_EXISTING: true
    cron.deleted_branches_cleanup:
      ENABLED: true
      RUN_AT_START: true
      SCHEDULE: "@every 24h"
    migrations:
      ALLOW_LOCALNETWORKS: true
    packages:
      ENABLED: false
    storage:
      STORAGE_TYPE: minio
      MINIO_ENDPOINT: rook-ceph-rgw-rgw-bulk.rook-cluster.svc:80
      MINIO_LOCATION: ""
      MINIO_USE_SSL: false

  additionalConfigFromEnvs:
    - name: GITEA__DATABASE__HOST
      valueFrom:
        secretKeyRef:
          name: gitea-pg-cluster-app
          key: host
    - name: GITEA__DATABASE__NAME
      valueFrom:
        secretKeyRef:
          name: gitea-pg-cluster-app
          key: dbname
    - name: GITEA__DATABASE__USER
      valueFrom:
        secretKeyRef:
          name: gitea-pg-cluster-app
          key: user
    - name: GITEA__DATABASE__PASSWD
      valueFrom:
        secretKeyRef:
          name: gitea-pg-cluster-app
          key: password
    - name: GITEA__SECURITY__SECRET_KEY
      valueFrom:
        secretKeyRef:
          name: secret-key
          key: key
    - name: GITEA__OAUTH2__JWT_SECRET
      valueFrom:
        secretKeyRef:
          name: jwt-secret
          key: jwt
    - name: GITEA__MAILER__PASSWD
      valueFrom:
        secretKeyRef:
          name: mail-pw
          key: pw
    - name: GITEA__STORAGE__MINIO_BUCKET
      valueFrom:
        configMapKeyRef:
          name: gitea-bucket
          key: BUCKET_NAME
    - name: GITEA__STORAGE__MINIO_ACCESS_KEY_ID
      valueFrom:
        secretKeyRef:
          name: gitea-bucket
          key: AWS_ACCESS_KEY_ID
    - name: GITEA__STORAGE__MINIO_SECRET_ACCESS_KEY
      valueFrom:
        secretKeyRef:
          name: gitea-bucket
          key: AWS_SECRET_ACCESS_KEY

Let’s start with the gitea.admin config:

gitea:
  admin:
    existingSecret: null
    username: null
    password: null

I’ve already got an admin account, so I didn’t want the Helm chart to create a new one. I thought I could do that by just setting admin: {}, but that of course doesn’t work. So the Helm chart created an admin user with the chart’s default gitea.admin.password. But I then figured out that setting all values to null does work. It’s important to note that Gitea doesn’t then remove the newly created admin user again. It needs to be deleted manually via the UI.

The gitea.oauth config is also worth a paragraph. First, it’s important to note that this is the config for Gitea as an Oauth2 client. The config for Gitea as an identity provider has to be done in another place. I’m using Keycloak as my identity provider in the Homelab. For more details, see this post. The issue is that Gitea’s OAuth2 client config can only be done in the UI or via the CLI, not via the config file. And I had already taken my Nomad instance down at this point. I could get the client ID and secret from Keycloak, but not the name under which it was saved in the database for example. It was also pretty unclear what options should be set under the gitea.oauth key. I finally ended up looking into the init container script, which is a bash script using the Gitea CLI to create the OAuth2 entry:

    function configure_oauth() {
      {{- if .Values.gitea.oauth }}
      {{- range $idx, $value := .Values.gitea.oauth }}
      local OAUTH_NAME={{ (printf "%s" $value.name) | squote }}
      local full_auth_list=$(gitea admin auth list --vertical-bars)
      local actual_auth_table=''

      # We might have distorted output due to warning logs, so we have to detect the actual user table by its headline and trim output above that line
      local regex="(.*)(ID\s+\|Name\s+\|Type\s+\|Enabled.*)"
      if [[ "${full_auth_list}" =~ $regex ]]; then
        actual_auth_table=$(echo "${BASH_REMATCH[2]}" | tail -n+2) # tail'ing to drop the table headline
      else
      [...]
      fi

      local AUTH_ID=$(echo "${actual_auth_table}" | grep -E "\|${OAUTH_NAME}\s+\|" | grep -iE '\|OAuth2\s+\|' | awk -F " "  "{print \$1}")

      if [[ -z "${AUTH_ID}" ]]; then
        echo "No oauth configuration found with name '${OAUTH_NAME}'. Installing it now..."
        gitea admin auth add-oauth {{- include "gitea.oauth_settings" (list $idx $value) | indent 1 }}
        echo '...installed.'
      else
        echo "Existing oauth configuration with name '${OAUTH_NAME}': '${AUTH_ID}'. Running update to sync settings..."
        gitea admin auth update-oauth --id "${AUTH_ID}" {{- include "gitea.oauth_settings" (list $idx $value) | indent 1 }}
        echo '...sync settings done.'
      fi
      {{- end }}
      {{- else }}
        echo 'no oauth configuration... skipping.'
      {{- end }}
    }

    configure_oauth

I’ve removed some unimportant bits for the sake of brevity (heh, brevity 😂). What we can see here is that the entries in the gitea.oauth section are converted into CLI flags and their parameters 1:1. For finding the right options I used for my Keycloak setup, I ended up looking into the database:

\c gitea
SELECT * FROM login_source;

id | type |   name   | is_sync_enabled |                                                                                                                                                                                               cfg                                                                                                                                                                                               | created_unix | updated_unix | is_active
----+------+----------+-----------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------+--------------+-----------
  1 |    6 | Keycloak | f               | {"Provider":"openidConnect","ClientID":"bar","ClientSecret":"foo","OpenIDConnectAutoDiscoveryURL":"https://key.example.com/realms/homelab/.well-known/openid-configuration","CustomURLMapping":null,"IconURL":"","Scopes":null,"RequiredClaimName":"","RequiredClaimValue":"","GroupClaimName":"","AdminGroup":"","RestrictedGroup":"","SkipLocalTwoFA":true} |   1678573526 |   1678573526 | t
(1 row)

But this still left the question of how the gitea.oauth.existingSecret should be formatted. Which keys was the chart expecting the Secret to have? I wasn’t able to find any info, so I ended up looking first for the place where the gitea.oauth_settings from the init script above was defined, which lead me to the chart’s helpers again:

{{- define "gitea.oauth_settings" -}}
{{- $idx := index . 0 }}
{{- $values := index . 1 }}

{{- if not (hasKey $values "key") -}}
{{- $_ := set $values "key" (printf "${GITEA_OAUTH_KEY_%d}" $idx) -}}
{{- end -}}

{{- if not (hasKey $values "secret") -}}
{{- $_ := set $values "secret" (printf "${GITEA_OAUTH_SECRET_%d}" $idx) -}}
{{- end -}}

{{- range $key, $val := $values -}}
{{- if ne $key "existingSecret" -}}
{{- printf "--%s %s " ($key | kebabcase) ($val | quote) -}}
{{- end -}}
{{- end -}}
{{- end -}}

Here, the key and secret values, if not defined in the chart, are set to the GITEA_OAUH_KEY_$ID and GITEA_OUATH_SECRET_$ID env variables. Looking for those variables then lead me to the Deployment template:

- name: GITEA_OAUTH_KEY_{{ $idx }}
  valueFrom:
    secretKeyRef:
      key:  key
      name: {{ $value.existingSecret }}
- name: GITEA_OAUTH_SECRET_{{ $idx }}
  valueFrom:
    secretKeyRef:
      key:  secret
      name: {{ $value.existingSecret }}

And here I finally had my answer: The Secret should have a key key and a key secret for the two values. Armed with that info I could finally define the OAuth2 options:

gitea:
  oauth:
    - name: "Keycloak"
      provider: "openidConnect"
      existingSecret: oidc-credentials
      autoDiscoverUrl: "https://key.example.com/realms/homelab/.well-known/openid-configuration"

One thing which annoyed me is in Gitea’s S3 config:

gitea:
  config:
    storage:
      STORAGE_TYPE: minio
      MINIO_ENDPOINT: rook-ceph-rgw-rgw-bulk.rook-cluster.svc:80
      MINIO_LOCATION: ""
      MINIO_USE_SSL: false

The MINIO_ENDPOINT needs to have the host and port in one value. But the ConfigMap created by Rook for a new bucket contains them only in separate keys, meaning I had to hardcode the value in the values.yaml instead of taking it from the ConfigMap. But at least I could still use the Secret Rook creates to get the S3 credentials:

gitea:
  additionalConfigFromEnvs:
    - name: GITEA__STORAGE__MINIO_ACCESS_KEY_ID
      valueFrom:
        secretKeyRef:
          name: gitea-bucket
          key: AWS_ACCESS_KEY_ID
    - name: GITEA__STORAGE__MINIO_SECRET_ACCESS_KEY
      valueFrom:
        secretKeyRef:
          name: gitea-bucket
          key: AWS_SECRET_ACCESS_KEY

An option like this is what more Helm charts should have: The ability to use the valueFrom form of defining env variables. With this, I can easily use autogenerated Secrets and ConfigMaps without having to jump through hoops.

Next stumbling block was the redis config:

gitea:
  config:
    cache:
      ADAPTER: "redis"
      INTERVAL: 60
      HOST: "network=tcp,addr=redis.redis.svc.cluster.local:6379,db=0,pool_size=100,idle_timeout=180"
      ITEM_TTL: 7d
    session:
      PROVIDER: redis
      PROVIDER_CONFIG: network=tcp,addr=redis.redis.svc.cluster.local:6379,db=0,pool_size=100,idle_timeout=180
    queue:
      TYPE: redis
      CONN_STR: "addr=redis.redis.svc.cluster.local:6379"
      WORKERS: 1
      BOOST_WORKERS: 5

Here I wasn’t aware that the connection string has to have a certain format and isn’t just host:port. Took me a while to figure out why I wasn’t able to make a connection with Redis.

And finally, another word on YAML: Check your indentation! 😅 I had to make sure that the entire network could reach the SSH service so I could actually use it for git operations. So I added fromEntities:\n - world to the network policy:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "gitea-access"
spec:
  endpointSelector:
    matchExpressions:
      - key: "app.kubernetes.io/name"
        operator: In
        values:
          - "gitea"
  ingress:
    - fromEndpoints:
      - matchLabels:
          homelab/ingress: "true"
          io.kubernetes.pod.namespace: traefik-ingress
      - fromEntities:
          - world

And when I still could not connect, I checked with Cilium’s monitoring:

kubectl -n kube-system exec -ti cilium-vh5jj -- cilium monitor --type drop
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN
xx drop (Policy denied) flow 0x0 to endpoint 896, ifindex 5, file bpf_lxc.c:2067, , identity world->63410: 300.300.300.1:59774 -> 10.8.5.79:2222 tcp SYN

Fast forward through an hour of reading through Cilium’s network policy docs, and I took another look at the policy - and realized that I had screwed up the indentation. 🤦 It should of course look like this:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "gitea-access"
spec:
  endpointSelector:
    matchExpressions:
      - key: "app.kubernetes.io/name"
        operator: In
        values:
          - "gitea"
  ingress:
    - fromEndpoints:
      - matchLabels:
          homelab/ingress: "true"
          io.kubernetes.pod.namespace: traefik-ingress
    - fromEntities:
        - world

With the fromEntities entry in the ingress: list, not the fromEndpoints: list. And after that it was all up and running. Woodpecker, my CI, did not need any additional config to access Gitea, it worked out of the box. Likely because it uses HTTPS for Git access and goes through the standard Gitea URL. And I don’t think I can change that to have it use the internal service instead of going through the ingress. That’s because it also uses Gitea for auth, and I don’t think it will handle having two different URLs to access Gitea very well. But that still ended up on the rickety pile of Homelab tasks to look at at some point.

Overall, it was a good migration and allowed me to figure out my DB migration strategy with a service which I could do without for a couple of days. I also have to congratulate the Gitea community on their work on the Helm chart. It was definitely one of the better ones I’ve used.

And that’s it for today. I can’t say what’s going to be next on the migration list, as I haven’t decided yet. I first thought to migrate my IoT services, Mosquitto, zigbee2mqtt and friends, but I’d also like to tackle some of the bigger items, like Nextcloud. On the other hand, I’m really not looking forward to touching my Nextcloud deployment. It has been working so nicely.