Deploying GitLab on RKE2 with Longhorn: A Post-Bitnami Reality Check

Deploying GitLab on RKE2 with Longhorn: A Post-Bitnami Reality Check

I run a homelab RKE2 cluster backed by Longhorn for storage, and I wanted GitLab as the latest addition — Postgres, Redis, and object storage all included, deployed with the official Helm chart. What I expected to be a single helm install turned into a tour of everything that's changed in the GitLab Helm chart over the last year. This is the write-up: what broke, why, and the working configuration I landed on.

If you're running GitLab chart 19.x+ on a self-managed cluster, especially a single-node homelab box, hopefully this saves you the same afternoon of INSTALLATION FAILED messages.

The premise that didn't survive contact

The original plan was simple: helm install gitlab gitlab/gitlab with postgresql.install=true and redis.install=true, let the chart bring up bundled Bitnami Postgres and Redis alongside GitLab itself, point storage at Longhorn, and be done in one command.

That's no longer possible. As of GitLab Helm chart 19.0 (chart version 10.0), the bundled PostgreSQL, Redis, and MinIO subcharts have been removed entirely, with no replacement. This isn't a deprecation warning — the postgresql.install and redis.install values are now silent no-ops. The reason: Bitnami discontinued free-tier access to its container images in September 2025, and rather than keep chasing a moving target, GitLab dropped the wrapper charts.

So "GitLab plus dependencies in one Helm command" became "GitLab plus a small stack of separately-managed dependencies, wired together by hand." Here's what that stack looked like.

Architecture

  • Kubernetes: RKE2, single node (homelab constraint — more on that later)
  • Storage: Longhorn (existing StorageClass, no changes needed there)
  • Ingress: RKE2's bundled nginx-ingress controller (not a second one)
  • PostgreSQL: CloudNativePG operator, one Cluster resource
  • Redis-compatible cache/queue: official Valkey Helm chart (valkey-io, not Bitnami)
  • Object storage: existing SeaweedFS S3 gateway, one bucket
  • GitLab chart version: 19.3 (release notes reference "19-3" in the install survey link)

Step 1: PostgreSQL via CloudNativePG

GitLab now requires PostgreSQL 17+ and expects you to bring your own instance. CloudNativePG is GitLab's own documented recommendation for a Kubernetes-native Postgres, replacing the old Bitnami subchart.

Install the operator:

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm upgrade --install cnpg cnpg/cloudnative-pg \
  --namespace cnpg-system --create-namespace

Then define a Cluster (this is a CRD, not a Helm release — kubectl apply, not helm install):

# gitlab-postgres.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: gitlab-postgres
  namespace: gitlab
spec:
  instances: 1
  storage:
    size: 20Gi
    storageClass: longhorn
  bootstrap:
    initdb:
      database: gitlabhq_production
      owner: gitlab
kubectl create namespace gitlab
kubectl apply -f gitlab-postgres.yaml

CNPG auto-generates an <cluster-name>-app secret with connection details. Check it with kubectl get secret gitlab-postgres-app -n gitlab -o yaml — the key you want is password (basic-auth type secret).

Gotcha: kubectl get cluster gitlab-postgres may return NotFound even though it exists — if you also run Kasten K10 in your cluster, it registers its own Cluster CRD (clusters.dist.kio.kasten.io), which shadows CNPG's Cluster CRD (clusters.postgresql.cnpg.io) under the bare cluster shorthand. Use the fully qualified name:

kubectl get cluster.postgresql.cnpg.io gitlab-postgres -n gitlab

Step 2: Redis-compatible cache via Valkey

Skip Bitnami's Redis/Valkey charts entirely — same free-image discontinuation problem applies. Use the official, community-maintained chart from valkey.io instead:

helm repo add valkey https://valkey.io/valkey-helm/
helm repo update

This chart uses ACL-based auth rather than a flat password, and it requires a default user to be defined if auth is enabled — no default user means no auth at all, which the chart rightly refuses to do silently:

# valkey-values.yaml
auth:
  enabled: true
  aclUsers:
    default:
      permissions: "~* &* +@all"
      password: "<strong-password>"
persistence:
  size: 8Gi
  storageClass: longhorn
helm install gitlab-valkey valkey/valkey \
  --namespace gitlab \
  -f valkey-values.yaml

Gotcha: the generated secret is named <release>-auth (e.g. gitlab-valkey-auth), with the password stored under a key named <username>-password — i.e. default-password, not default. Confirm before wiring it into GitLab:

kubectl get secret gitlab-valkey-auth -n gitlab -o yaml

Gotcha #2: the Kubernetes Service this chart creates is just <release-name> (gitlab-valkey), with no -master suffix. If you're used to Bitnami's naming convention, don't carry it over — verify with kubectl get svc -n gitlab | grep valkey. Getting this wrong doesn't fail at install time; it fails later at runtime with a DNS resolution error inside the migrations job, which is a much more confusing place to debug it.

Step 3: Object storage

GitLab 19.x also requires external S3-compatible object storage — MinIO is gone the same way Postgres/Redis are. I pointed it at an existing SeaweedFS S3 gateway.

Create the bucket(s) and a connection secret:

kubectl create secret generic gitlab-rails-storage -n gitlab \
  --from-literal=connection='{"provider":"AWS","region":"us-east-1","aws_access_key_id":"<key>","aws_secret_access_key":"<secret>","endpoint":"http://<seaweedfs-host>:<port>","path_style":true}'

GitLab's docs recommend a separate bucket per object type (artifacts, LFS, uploads, packages, etc.) to avoid key collisions. For a homelab instance I consolidated everything into one bucket — a deliberate trade-off, not the supported default, but acceptable for low-stakes use:

global:
  appConfig:
    object_store:
      enabled: true
      proxy_download: true
      connection:
        secret: gitlab-rails-storage
        key: connection
      objects:
        artifacts: { bucket: gitlab }
        lfs: { bucket: gitlab }
        uploads: { bucket: gitlab }
        packages: { bucket: gitlab }
        external_diffs: { bucket: gitlab }
        terraform_state: { bucket: gitlab }
        dependency_proxy: { bucket: gitlab }
        pages: { bucket: gitlab }

Gotcha — the big one: the container registry has its own, separate storage secret format, and it does not share the consolidated object_store.connection used above. It expects a secret containing a YAML blob shaped like the classic Omnibus config:

# registry-storage.yaml
s3:
  bucket: gitlab
  accesskey: <key>
  secretkey: <secret>
  region: us-east-1
  regionendpoint: http://<seaweedfs-host>:<port>
  v4auth: true
kubectl create secret generic gitlab-registry-storage-secret -n gitlab \
  --from-file=connection=registry-storage.yaml
registry:
  storage:
    secret: gitlab-registry-storage-secret
    key: connection

Naming trap: GitLab auto-generates a secret named <release>-registry-secret (e.g. gitlab-registry-secret) containing the JWT cert/key pair (registry-auth.key / registry-auth.crt) used for auth between webservice and the registry. If you name your own storage secret the same thing — which felt like the natural name to pick — you silently overwrite the auto-generated one. The failure mode is ugly: webservice, sidekiq, and toolbox pods all get stuck at Init, and kubectl describe pod eventually reveals:

MountVolume.SetUp failed for volume "init-webservice-secrets" : references non-existent secret key: registry-auth.key

Fix: rename your own secret to something else, delete the corrupted gitlab-registry-secret, and let the chart's shared-secrets job regenerate it on the next helm upgrade.

Step 4: Ingress — fighting the chart's real default

This is the step that cost the most time, because the actual failure mode obscured the actual cause twice in a row.

First failure — a straightforward IngressClass collision:

IngressClass "nginx" in namespace "" exists and cannot be imported into the current release: invalid ownership metadata

RKE2 ships its own nginx ingress controller (rke2-ingress-nginx), which already owns the nginx IngressClass. The GitLab chart's bundled nginx-ingress subchart was trying to deploy a second one and claim the same class name. Fix: disable the bundled one and let RKE2's existing controller pick up GitLab's Ingress objects.

nginx-ingress:
  enabled: false

Second, hidden failure: even with that fixed, the dry-run output showed something unexpected — no Ingress resources anywhere, only HTTPRoute, Gateway, and GatewayClass. As of chart 19.0, Gateway API with a bundled Envoy Gateway is the new default networking mode, replacing NGINX Ingress outright. My global.ingress.class: nginx setting was correctly configured — for a mechanism the chart wasn't even using.

To force classic Ingress (the simpler option for an existing nginx-based setup):

global:
  gatewayApi:
    enabled: false
  ingress:
    enabled: true      # Ingress rendering is off by default; must be explicitly re-enabled
    class: nginx
    configureCertmanager: false

Re-running the dry run after this showed kind: Ingress for webservice, registry, and KAS, each already correctly host-named from global.hosts.domain (I set domain: lab.home, and the chart automatically produced gitlab.lab.home for the main Ingress — no extra host configuration needed there).

Step 5: cert-manager plumbing

I run everything over plain HTTP internally, so cert-manager wasn't needed — but disabling it cleanly took two attempts.

certmanager.install: false fails schema validation on this chart version — it's been renamed to a top-level key:

installCertmanager: false

Even with that set, the bundled certmanager-issuer subchart still renders unconditionally and demands an email address regardless of whether cert-manager itself is installed:

certmanager-issuer:
  email: you@example.com

With both set, the chart still generates a self-signed wildcard cert and TLS secret by default (harmless — RKE2's nginx serves plain HTTP fine alongside it — but worth knowing so an untrusted-cert browser warning doesn't come as a surprise later).

Step 6: Redis config key rename

Not fatal, but worth fixing to avoid a Helm warning on every future upgrade: global.redis.password has been renamed to global.redis.auth. Same sub-fields (enabled, secret, key), new parent key:

global:
  redis:
    host: gitlab-valkey.gitlab.svc.cluster.local
    auth:
      enabled: true
      secret: gitlab-valkey-auth
      key: default-password

Step 7: the toolbox's phantom S3 config

After everything else came up, the toolbox pod (used for backup-utility, Rake tasks, etc.) crash-looped with:

cp: cannot stat '/etc/gitlab/.s3cfg': No such file or directory

This is leftover plumbing from the MinIO removal — the toolbox startup script unconditionally tries to stage an .s3cfg file for s3cmd, previously supplied automatically by the bundled MinIO instance. With MinIO gone, nothing provides it unless you configure it explicitly, even if you have no intention of using GitLab's backup feature immediately.

[default]
access_key = <key>
secret_key = <secret>
host_base = <seaweedfs-host>:<port>
host_bucket = <seaweedfs-host>:<port>/%(bucket)
use_https = False
signature_v2 = False
kubectl create secret generic gitlab-toolbox-s3cfg -n gitlab --from-file=config=s3cfg
global:
  appConfig:
    backups:
      bucket: gitlab
      tmpBucket: gitlab

gitlab:
  toolbox:
    backups:
      objectStorage:
        config:
          secret: gitlab-toolbox-s3cfg
          key: config

Step 8: the node ran out of memory

Cloud Native GitLab's default resource requests assume a cluster with real headroom — multiple replicas of webservice, sidekiq, KAS, registry, Gitaly, and a bundled Prometheus, each reserving their own slice of memory whether or not they're under load. On a single-node 15GB RKE2 VM already running other homelab workloads (Ghost, Kasten, etc.), the scheduler flatly refused to place several pods:

0/1 nodes are available: 1 Insufficient memory

kubectl describe nodes confirmed it: 99% of memory already requested before GitLab's defaults even applied. free -h showed the same story at the OS level — 14 of 15GB in active use.

There's no clever values-file fix for a node that's genuinely out of RAM. The real fix was doubling the VM's memory allocation to 32GB via vCenter (no memory hot-add configured, so this meant a full shutdown → resize → power-on cycle). Alongside that, I trimmed replica counts and resource requests for the heavier components to something proportionate to a homelab box rather than the chart's production-oriented defaults:

gitlab:
  webservice:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 300m, memory: 1200Mi }
      limits: { memory: 2Gi }
  sidekiq:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 200m, memory: 1Gi }
      limits: { memory: 2Gi }
  kas:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 50m, memory: 100Mi }
  gitlab-shell:
    minReplicas: 1
    maxReplicas: 1
  gitaly:
    resources:
      requests: { cpu: 200m, memory: 500Mi }

registry:
  hpa:
    minReplicas: 1
    maxReplicas: 1

The final gitlab-values.yaml

global:
  hosts:
    domain: lab.home
    https: false

  gatewayApi:
    enabled: false

  ingress:
    enabled: true
    configureCertmanager: false
    class: nginx

  edition: ce

  psql:
    host: gitlab-postgres-rw.gitlab.svc.cluster.local
    port: 5432
    database: gitlabhq_production
    username: gitlab
    password:
      secret: gitlab-postgres-app
      key: password

  redis:
    host: gitlab-valkey.gitlab.svc.cluster.local
    auth:
      enabled: true
      secret: gitlab-valkey-auth
      key: default-password

  appConfig:
    object_store:
      enabled: true
      proxy_download: true
      connection:
        secret: gitlab-rails-storage
        key: connection
      objects:
        artifacts: { bucket: gitlab }
        lfs: { bucket: gitlab }
        uploads: { bucket: gitlab }
        packages: { bucket: gitlab }
        external_diffs: { bucket: gitlab }
        terraform_state: { bucket: gitlab }
        dependency_proxy: { bucket: gitlab }
        pages: { bucket: gitlab }
    backups:
      bucket: gitlab
      tmpBucket: gitlab

installCertmanager: false

certmanager-issuer:
  email: you@lab.home

nginx-ingress:
  enabled: false

gitlab:
  gitaly:
    persistence:
      storageClass: longhorn
      size: 50Gi
    resources:
      requests: { cpu: 200m, memory: 500Mi }

  webservice:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 300m, memory: 1200Mi }
      limits: { memory: 2Gi }

  sidekiq:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 200m, memory: 1Gi }
      limits: { memory: 2Gi }

  kas:
    minReplicas: 1
    maxReplicas: 1
    resources:
      requests: { cpu: 50m, memory: 100Mi }

  gitlab-shell:
    minReplicas: 1
    maxReplicas: 1

  toolbox:
    backups:
      objectStorage:
        config:
          secret: gitlab-toolbox-s3cfg
          key: config

registry:
  storage:
    secret: gitlab-registry-storage-secret
    key: connection
  hpa:
    minReplicas: 1
    maxReplicas: 1

gitlab-runner:
  install: false

Supporting manifests and secrets

For reference, everything created outside the helm install/helm upgrade cycle itself:

# CloudNativePG operator + cluster
helm upgrade --install cnpg cnpg/cloudnative-pg -n cnpg-system --create-namespace
kubectl apply -f gitlab-postgres.yaml   # Cluster CRD, see Step 1

# Valkey
helm install gitlab-valkey valkey/valkey -n gitlab -f valkey-values.yaml   # see Step 2

# Object storage secrets
kubectl create secret generic gitlab-rails-storage -n gitlab --from-literal=connection='...'
kubectl create secret generic gitlab-registry-storage-secret -n gitlab --from-file=connection=registry-storage.yaml
kubectl create secret generic gitlab-toolbox-s3cfg -n gitlab --from-file=config=s3cfg

Sanity checks along the way

A few commands that earned their keep during debugging, worth keeping handy for next time:

# Confirm actual secret contents rather than assuming a naming convention
kubectl get secret <name> -n gitlab -o yaml

# Confirm actual service names — don't assume Bitnami-style suffixes on other charts
kubectl get svc -n gitlab

# Check init-container blockers directly — this is where the registry-secret
# collision and the resource-pressure Pending states both surfaced
kubectl describe pod <pod> -n gitlab | tail -30

# Confirm what's actually requested vs. allocatable on the node
kubectl describe nodes | grep -A 15 "Allocated resources"

# CNPG Cluster resource, disambiguated from any other CRD also named "Cluster"
# (Kasten K10 registers its own — check for collisions before assuming NotFound
# means the resource doesn't exist)
kubectl get cluster.postgresql.cnpg.io gitlab-postgres -n gitlab

Result

After all of the above: PostgreSQL 18.4 via CNPG, Valkey via the official chart, SeaweedFS-backed object storage, classic Ingress through RKE2's existing nginx controller, all storage on Longhorn, and GitLab itself running with resource requests actually sized for a homelab node. Initial root credentials come from the auto-generated gitlab-gitlab-initial-root-password secret:

kubectl get secret gitlab-gitlab-initial-root-password -n gitlab -o jsonpath='{.data.password}' | base64 -d

Total elapsed time from first helm install to a working instance was considerably longer than a "single Helm command" — but every piece of friction above was a real, documented breaking change in the chart's recent history, not a one-off homelab quirk. If you're deploying GitLab fresh on chart 19.x+, budget time for all of it up front rather than discovering it one CrashLoopBackOff at a time.