KubeBlocks
BlogsEnterprise
⌘K
​
Blogs
Back
  1. What is KubeBlocks?
  2. Prerequisites
  3. Architecture Overview
  4. Step 1: Create the Database Namespace
  5. Step 2: Deploy PostgreSQL HA
  6. Step 3: Create the GitLab Database and User
  7. Step 4: Deploy Redis HA with Sentinel
  8. Step 5: Create Kubernetes Secrets for GitLab
  9. Step 6: Deploy GitLab
  10. Step 7: Verify the Setup
    1. Access GitLab via Port-Forward
  11. Understanding the Connection Details
    1. PostgreSQL
    2. Redis
  12. Failover Testing
    1. Test PostgreSQL Failover
    2. Test Redis Failover
  13. Scaling and Upgrades
    1. Scale PostgreSQL Read Replicas
    2. Upgrade PostgreSQL Version
    3. Expand Storage
  14. Backup and Recovery
  15. Cleanup
  16. Summary
  17. Related Resources

GitLab on Kubernetes: Production-Ready PostgreSQL and Redis HA with KubeBlocks

Running GitLab on Kubernetes is remarkably straightforward if you stick to the official Helm chart—until you hit the database tier. The bundled PostgreSQL and Redis instances are perfectly fine for taking GitLab for a spin, but they are single points of failure. The moment one of those pods restarts, your entire CI/CD pipeline grinds to a halt.

This guide walks you through replacing those fragile bundled databases with true, production-grade Highly Available (HA) clusters managed by KubeBlocks. We will set up PostgreSQL with Patroni (for streaming replication and automatic primary election) and Redis with Sentinel (1 primary, 1 replica, and 3 sentinels).

Every command below has been tested on EKS using KubeBlocks 1.0.2.

What is KubeBlocks?

KubeBlocks is an open-source Kubernetes operator for running and managing databases and stateful middleware. It supports 30+ database engines—including PostgreSQL, Redis, MySQL, MongoDB, and Kafka—through a unified API surface. Instead of writing StatefulSets and sidecar logic by hand, you describe what you want (topology, version, resources) and KubeBlocks handles provisioning, HA configuration, credential management, and day-2 operations.


Prerequisites

Before diving in, make sure you have the following:

  • A Kubernetes cluster (1.22 or newer) with kubectl configured
  • Helm 3.x installed
  • KubeBlocks 1.0.2 or higher installed in your cluster

If you haven't installed KubeBlocks yet, you can add it quickly:

helm repo add kubeblocks https://apecloud.github.io/helm-charts helm repo update helm install kubeblocks kubeblocks/kubeblocks \ --namespace kb-system \ --create-namespace \ --version 1.0.2

Wait for the operator to become fully ready:

kubectl wait --for=condition=ready pod \ -l app.kubernetes.io/name=kubeblocks \ -n kb-system \ --timeout=120s

Architecture Overview

To keep things clean, we will deploy both database clusters into a dedicated gitlab-data namespace. This strictly isolates the stateful data layer from GitLab's stateless application pods.

GitLab on Kubernetes Architecture


Step 1: Create the Database Namespace

First, let's create the isolated namespace for our data layer.

kubectl create namespace gitlab-data

Step 2: Deploy PostgreSQL HA

KubeBlocks ships with a PostgreSQL addon built on Patroni—a battle-tested HA solution that handles leader election, streaming replication, and automatic failover seamlessly. See the PostgreSQL quickstart for standalone usage.

Deploy the cluster using Helm:

helm install gitlab-pg kubeblocks/postgresql-cluster \ --namespace gitlab-data \ --set version=16.4.0 \ --set mode=replication \ --set replicas=2 \ --set cpu=2 \ --set memory=4 \ --set storage=50 \ --set terminationPolicy=Delete

Here is exactly what these parameters do:

  • mode=replication — Enables Patroni streaming replication (1 primary + N-1 replicas).
  • replicas=2 — Deploys 1 primary and 1 standby. (This is sufficient for this guide, but see the production note below).
  • terminationPolicy=Delete — Allows helm delete to remove the cluster.

Production Note: We use replicas=2 here to keep tutorial costs low. For actual production environments, you should use replicas=3. With only 2 nodes and no external Distributed Configuration Store (like etcd or Consul), Patroni cannot establish a quorum during a network partition. In that scenario, it will pause the primary to prevent split-brain, rather than failing over. Three replicas give Patroni the majority vote it needs to fail over safely without external dependencies.

Wait for the cluster to reach the Running state:

kubectl wait --for=jsonpath='{.status.phase}'=Running \ cluster/gitlab-pg \ -n gitlab-data \ --timeout=300s

Let's verify that replication is actively working:

PG_USER=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.username}' | base64 -d) PG_PASS=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.password}' | base64 -d) # Check pod-0 kubectl exec -n gitlab-data gitlab-pg-postgresql-0 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-postgresql -c "SELECT pg_is_in_recovery();" # Check pod-1 kubectl exec -n gitlab-data gitlab-pg-postgresql-1 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-headless -c "SELECT pg_is_in_recovery();"

You should see output similar to this:

 pg_is_in_recovery
-------------------
 f                    ← pod-0 is the primary

 pg_is_in_recovery
-------------------
 t                    ← pod-1 is the replica

Step 3: Create the GitLab Database and User

Next, we need to provision the specific database and user that GitLab expects.

Rather than identifying the primary pod manually, connect through the gitlab-pg-postgresql-postgresql Service — KubeBlocks keeps this ClusterIP service pointed at the current Patroni primary at all times. First, retrieve the postgres superuser credentials from the KubeBlocks-generated secret:

PG_USER=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.username}' | base64 -d) PG_PASS=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.password}' | base64 -d)

Generate a password for the GitLab application user, then run all DDL through the service:

GITLAB_PG_PASS=$(openssl rand -base64 18 | tr -d '/+=') echo "Save this password: $GITLAB_PG_PASS" kubectl exec -n gitlab-data gitlab-pg-postgresql-0 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-postgresql \ -c "CREATE USER gitlab WITH PASSWORD '$GITLAB_PG_PASS';" kubectl exec -n gitlab-data gitlab-pg-postgresql-0 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-postgresql \ -c "CREATE DATABASE gitlabhq_production OWNER gitlab;" kubectl exec -n gitlab-data gitlab-pg-postgresql-0 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-postgresql \ -c "GRANT ALL PRIVILEGES ON DATABASE gitlabhq_production TO gitlab;"

Note: Connecting via -h gitlab-pg-postgresql-postgresql (the service) ensures DDL always lands on the primary, regardless of which pod you exec into. Direct local connections to a replica pod would fail with ERROR: cannot execute ... in a read-only transaction.


Step 4: Deploy Redis HA with Sentinel

GitLab leans heavily on Redis for caching, managing Sidekiq job queues, and maintaining session state.

KubeBlocks' Redis addon replication topology deploys a complete HA stack. See the Redis quickstart for standalone usage.

  • Redis data nodes (1 primary + N-1 replicas)
  • 3 Sentinel nodes dedicated to monitoring and automatic failover

To avoid Helm type-coercion issues with decimal values, it's best to create a redis-values.yaml file:

version: "7.2.10" mode: replication replicas: 2 cpu: 1 memory: 2 storage: 10 terminationPolicy: Delete sentinel: replicas: 3 cpu: 0.5 memory: 0.5 storage: 1

Deploy the Redis cluster:

helm install gitlab-redis kubeblocks/redis-cluster \ --namespace gitlab-data \ --values redis-values.yaml

Here is a breakdown of the key parameters:

  • mode=replication — Automatically deploys both the Redis and Sentinel components.
  • replicas=2 — Provisions 1 primary and 1 replica for the Redis data nodes.
  • sentinel.replicas=3 — Provisions 3 Sentinel nodes, establishing a quorum of 2.

Wait for the cluster to become Running:

kubectl wait --for=jsonpath='{.status.phase}'=Running \ cluster/gitlab-redis \ -n gitlab-data \ --timeout=300s

Now, let's verify that Sentinel is actively monitoring the master node:

SENTINEL_PASS=$(kubectl get secret gitlab-redis-redis-sentinel-account-default \ -n gitlab-data \ -o jsonpath='{.data.password}' | base64 -d) kubectl exec -n gitlab-data gitlab-redis-redis-sentinel-0 -c redis-sentinel -- \ redis-cli -p 26379 -a "$SENTINEL_PASS" sentinel masters

You should see output containing these specific lines:

name
gitlab-redis-redis
ip
gitlab-redis-redis-0.gitlab-redis-redis-headless.gitlab-data.svc.cluster.local
port
6379
flags
master
num-slaves
1
num-other-sentinels
2
quorum
2

The num-slaves: 1 and num-other-sentinels: 2 values confirm that you have a healthy 1+1 Redis cluster with all 3 Sentinels online and watching.


Step 5: Create Kubernetes Secrets for GitLab

GitLab reads its database credentials directly from Kubernetes Secrets. We need to create these secrets in the gitlab namespace (or whichever namespace you plan to deploy the GitLab application into).

kubectl create namespace gitlab # Create the PostgreSQL password secret # $GITLAB_PG_PASS was set in Step 3 kubectl create secret generic gitlab-postgresql-password \ --namespace gitlab \ --from-literal=main-gitlab-password="$GITLAB_PG_PASS" # Extract the Redis password from the KubeBlocks-generated secret REDIS_PASS=$(kubectl get secret gitlab-redis-redis-account-default \ -n gitlab-data \ -o jsonpath='{.data.password}' | base64 -d) # Create the Redis password secret for GitLab kubectl create secret generic gitlab-redis-secret \ --namespace gitlab \ --from-literal=redis-password="$REDIS_PASS" # Extract the Sentinel auth password (this is separate from the Redis data password) SENTINEL_PASS=$(kubectl get secret gitlab-redis-redis-sentinel-account-default \ -n gitlab-data \ -o jsonpath='{.data.password}' | base64 -d) # Create the Sentinel password secret for GitLab kubectl create secret generic gitlab-redis-sentinel-secret \ --namespace gitlab \ --from-literal=sentinel-password="$SENTINEL_PASS"

Why do we need two Redis secrets? KubeBlocks manages the Redis data nodes and the Sentinel nodes using entirely separate credentials for enhanced security. The sentinel password is used specifically for AUTH on the sentinel port (26379), while the standard Redis password is used for AUTH on the actual data port (6379).


Step 6: Deploy GitLab

Now we are ready for the main event. Add the GitLab Helm repository and prepare the values file to wire up our external, highly available databases.

helm repo add gitlab https://charts.gitlab.io/ helm repo update

Create a gitlab-values.yaml file:

global: hosts: domain: gitlab.example.com # Replace this with your actual domain https: true ## External PostgreSQL (KubeBlocks Patroni cluster) psql: host: gitlab-pg-postgresql-postgresql.gitlab-data.svc.cluster.local port: 5432 username: gitlab database: gitlabhq_production password: useSecret: true secret: gitlab-postgresql-password key: main-gitlab-password ## External Redis (KubeBlocks Sentinel cluster) redis: # IMPORTANT: The 'host' here must be the Sentinel MASTER NAME, not the sentinel service hostname. # KubeBlocks assigns the master name based on the cluster: <release>-<component> host: gitlab-redis-redis port: 6379 auth: enabled: true secret: gitlab-redis-secret key: redis-password sentinels: - host: gitlab-redis-redis-sentinel-redis-sentinel.gitlab-data.svc.cluster.local port: 26379 sentinelAuth: enabled: true secret: gitlab-redis-sentinel-secret key: sentinel-password # Explicitly disable the bundled PostgreSQL and Redis instances postgresql: install: false redis: install: false # Disable the bundled cert-manager (Note: GitLab chart 9.x uses 'installCertmanager', not 'certmanager.install') installCertmanager: false certmanager-issuer: email: your@email.com

Deploy GitLab:

helm install gitlab gitlab/gitlab \ --namespace gitlab \ --values gitlab-values.yaml \ --timeout 600s

Note: GitLab's initial deployment is heavy. It pulls several large images and executes extensive database migrations. Give the gitlab-migrations job a solid 5–10 minutes to complete before expecting the rest of the application pods to become ready.


Step 7: Verify the Setup

Check the status of the GitLab pods (remember to give it a few minutes for migrations to finish):

kubectl get pods -n gitlab

You are looking for an output where all application pods are Running with zero restarts, and the migrations pod has cleanly finished:

NAME                                  READY   STATUS      RESTARTS
gitlab-gitaly-0                       1/1     Running     0
gitlab-gitlab-exporter-xxx            1/1     Running     0
gitlab-gitlab-shell-xxx               1/1     Running     0
gitlab-migrations-xxx                 0/1     Completed   0
gitlab-sidekiq-all-in-1-xxx           1/1     Running     0
gitlab-webservice-default-xxx         2/2     Running     0
...

The fact that gitlab-migrations-xxx is Completed (rather than running or crashing) is your confirmation that the database schema was applied successfully to the external PostgreSQL cluster.

To test database connectivity directly from the application layer, run this inside a webservice pod:

WEBSERVICE_POD=$(kubectl get pods -n gitlab -l app=webservice -o name | head -1) kubectl exec -n gitlab $WEBSERVICE_POD -c webservice -- \ /srv/gitlab/bin/rails runner "puts Gitlab::Database.main.version" 2>&1

Access GitLab via Port-Forward

If you don't have direct access to the LoadBalancer (for instance, if you are testing locally or have strict security groups on your ELB), you can use kubectl port-forward to access the GitLab UI directly from your workstation:

# Retrieve the initial root password kubectl get secret gitlab-gitlab-initial-root-password \ -n gitlab \ -o jsonpath='{.data.password}' | base64 -d && echo # Forward the nginx-ingress controller to your localhost kubectl port-forward -n gitlab svc/gitlab-nginx-ingress-controller 8888:80

Open your browser to http://localhost:8888/users/sign_in and log in using:

  • Username: root
  • Password: (the output from the command above)

Note: After logging in, GitLab might try to redirect you to http://localhost/users/sign_in (dropping the port). Simply navigate back to http://localhost:8888/users/sign_in manually to stay on the port-forwarded connection.


Understanding the Connection Details

How does GitLab actually maintain connections to these HA clusters without getting confused during a failover event?

PostgreSQL

PropertyValue
Servicegitlab-pg-postgresql-postgresql.gitlab-data.svc.cluster.local
Port5432
Usernamegitlab
Databasegitlabhq_production
HA MechanismPatroni (automatic primary election)

The -postgresql service is a standard ClusterIP service (non-headless) that KubeBlocks dynamically manages. It is guaranteed to always point to the current Patroni primary. When a failover occurs, Patroni promotes the standby and updates the service selector under the hood. From GitLab's perspective, no reconfiguration is needed; the connection simply drops for a moment and immediately reconnects to the newly promoted primary.

Redis

PropertyValue
Sentinel Servicegitlab-redis-redis-sentinel-redis-sentinel.gitlab-data.svc.cluster.local
Sentinel Port26379
Master Namegitlab-redis-redis
HA MechanismSentinel (automatic master election)

GitLab's underlying Rails stack supports Redis Sentinel natively via the sentinels: configuration block. When a Redis master fails, the Sentinel nodes elect a new master within seconds and proactively notify all connected clients (like Sidekiq) to redirect their traffic to the new master IP.

Important: In the GitLab Helm chart, when sentinels is configured, the global.redis.host field actually serves as the Sentinel master name, not the service hostname. KubeBlocks systematically names the master after the cluster itself: <helm-release>-<component>. So, for a release named gitlab-redis with the component redis, the master name is exactly gitlab-redis-redis. You can always verify this manually by running redis-cli -p 26379 sentinel masters.


Failover Testing

Don't just trust the theory. Let's intentionally break things and watch the system recover.

Test PostgreSQL Failover

First, identify the current primary, delete its pod, and watch Patroni promote the replica in real-time:

PG_USER=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.username}' | base64 -d) PG_PASS=$(kubectl get secret -n gitlab-data gitlab-pg-postgresql-account-postgres \ -o jsonpath='{.data.password}' | base64 -d) # Kill pod-0 (assuming it is currently primary) kubectl delete pod gitlab-pg-postgresql-0 -n gitlab-data # Watch the service-connected query — once pg_is_in_recovery returns 'f', # Patroni has promoted a new primary and the service is already pointing to it watch kubectl exec -n gitlab-data gitlab-pg-postgresql-1 -c postgresql -- \ env PGUSER="$PG_USER" PGPASSWORD="$PG_PASS" \ psql -h gitlab-pg-postgresql-postgresql -c "SELECT pg_is_in_recovery();"

The moment pg_is_in_recovery returns f via the service query, failover is complete. Because we're querying through the ClusterIP service, a f result confirms the service has already redirected to the new primary — GitLab's connection pool will recover automatically.

Test Redis Failover

REDIS_PASS=$(kubectl get secret gitlab-redis-redis-account-default \ -n gitlab-data -o jsonpath='{.data.password}' | base64 -d) # Confirm the current master kubectl exec -n gitlab-data gitlab-redis-redis-0 -c redis -- \ redis-cli -a "$REDIS_PASS" role # Kill the master pod kubectl delete pod gitlab-redis-redis-0 -n gitlab-data # Sentinel detects the failure and promotes the replica within ~20 seconds. # Let's check the new master via Sentinel: SENTINEL_PASS=$(kubectl get secret gitlab-redis-redis-sentinel-account-default \ -n gitlab-data -o jsonpath='{.data.password}' | base64 -d) kubectl exec -n gitlab-data gitlab-redis-redis-sentinel-0 -c redis-sentinel -- \ redis-cli -p 26379 -a "$SENTINEL_PASS" sentinel masters | grep -A1 "^ip"

Scaling and Upgrades

Scale PostgreSQL Read Replicas

Need more read capacity? You can scale the cluster dynamically:

# Scale the cluster to 1 primary + 2 replicas kubectl patch cluster gitlab-pg -n gitlab-data \ --type='json' \ -p='[{"op":"replace","path":"/spec/componentSpecs/0/replicas","value":3}]'

Upgrade PostgreSQL Version

KubeBlocks supports in-place minor version upgrades with zero downtime. It performs a rolling update of the replicas first, followed by a controlled primary failover:

kubectl apply -f - <<EOF apiVersion: operations.kubeblocks.io/v1alpha1 kind: OpsRequest metadata: name: pg-upgrade namespace: gitlab-data spec: clusterName: gitlab-pg type: Upgrade upgrade: components: - componentName: postgresql serviceVersion: "16.9.0" EOF

Expand Storage

Running out of disk space? Expand the volume seamlessly:

kubectl apply -f - <<EOF apiVersion: operations.kubeblocks.io/v1alpha1 kind: OpsRequest metadata: name: pg-volume-expand namespace: gitlab-data spec: clusterName: gitlab-pg type: VolumeExpansion volumeExpansion: - componentName: postgresql volumeClaimTemplates: - name: data storage: "100Gi" EOF

Backup and Recovery

Backup is not optional in production. For a GitLab deployment, the two stateful components that absolutely must be backed up are:

  • PostgreSQL — This holds all of your critical GitLab application data: projects, issues, merge requests, user accounts, and CI pipeline configurations.
  • Redis — While primarily acting as a cache and job queue (making it slightly less critical for strict point-in-time recovery), preserving the Sentinel state across hard restarts is still best practice.

KubeBlocks provides a unified, Kubernetes-native backup API for both databases. The general workflow looks like this:

  1. Configure a BackupRepo (pointing to S3, GCS, or any S3-compatible object store).
  2. Create a BackupSchedule to automate snapshots on a cron schedule.
  3. Use an OpsRequest (with type Restore) or standard Backup/Restore Custom Resources to recover when disaster strikes.

For detailed, step-by-step instructions, refer to the official documentation:

  • PostgreSQL Backup & Restore with KubeBlocks
  • Redis Backup & Restore with KubeBlocks

Tip: Always configure your BackupRepo and BackupSchedule before going live. If you suffer a catastrophic double-node failure on a replicas=2 cluster without a backup, recovery is impossible.


Cleanup

If you are just testing and want to tear everything down:

helm delete gitlab -n gitlab helm delete gitlab-pg -n gitlab-data helm delete gitlab-redis -n gitlab-data # Delete the PersistentVolumeClaims if you want to permanently wipe the data kubectl delete pvc -n gitlab-data --all # Finally, remove the namespaces kubectl delete namespace gitlab gitlab-data

Summary

ComponentBeforeAfter
PostgreSQLBundled single-instancePatroni HA: 1 primary + 1 replica, auto-failover
RedisBundled standaloneSentinel HA: 1 primary + 1 replica + 3 sentinels
FailoverManual / noneAutomatic within 10–30 seconds
ScalingManual pod managementNative kubectl patch or OpsRequest
Version UpgradeDowntime requiredRolling in-place via KubeBlocks OpsRequest

KubeBlocks entirely abstracts the operational complexity of running stateful databases on Kubernetes. It handles provisioning, HA configuration, credential management, and day-2 operations through a single, unified API. For your GitLab deployment, this means the database tier is genuinely production-ready from day one—all without you having to write a single line of StatefulSet YAML.


Related Resources

  • KubeBlocks Official Website
  • KubeBlocks for PostgreSQL — Landing page, quickstart, architecture, and operations
  • KubeBlocks for Redis — Landing page, quickstart, topologies, and operations
  • PostgreSQL Backup & Restore with KubeBlocks
  • Redis Backup & Restore with KubeBlocks
  • GitLab Helm Chart Documentation

© 2026 KUBEBLOCKS INC