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
Clusterresource - 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.