Author: [Your Name]
Published: [Date]
Tags: K3s, Kubernetes, Immich, PostgreSQL, CNPG, VectorChord, Valkey, Homelab
1. Introduction
Immich is an open-source, self-hosted photo and video management solution. It requires three main backend components: a PostgreSQL database (with vector search support for ML-powered features), a Redis-compatible cache, and a machine learning service.
In this post, I’ll walk through deploying Immich on a 3-node K3s homelab cluster with:
- Cloud Native PostgreSQL (CNPG) operator for a highly-available PostgreSQL cluster
- VectorChord (vchord) for vector similarity search instead of standard pgvector
- Valkey as a Redis-compatible cache (built into the Immich Helm chart)
- MetalLB for LoadBalancer IP allocation
- Traefik ingress with Let’s Encrypt TLS
Everything runs on bare metal with no managed cloud services.
2. Prerequisites & Cluster Overview
2.1 K3s Cluster
My cluster consists of 3 nodes, all running as control-plane/etcd members:
- 3 nodes, openSUSE Leap
- K3s v1.34.6+k3s1
- containerd 2.2.2
- CNPG operator pre-installed
- MetalLB pre-configured with IP pools
- Traefik as the default ingress controller
- cert-manager with Let’s Encrypt ClusterIssuer
2.2 Storage Classes
Three storage classes are available:
| local-path | Local SSD storage on each node — used for PostgreSQL data |
| truenas-nfs-ssd | NFS-backed SSD storage from TrueNAS — used for ML cache and Valkey |
| nfs-immich-class | NFS-backed HDD storage — used for the Immich media library |
3. Setting Up the PostgreSQL Cluster with VectorChord
3.1 Why VectorChord?
Immich uses vector embeddings for ML-powered features like smart search, face recognition, and similar image clustering. The Immich documentation recommends using the ghcr.io/tensorchord/cloudnative-vectorchord image, which bundles PostgreSQL 18.4 with the VectorChord extension pre-installed.
VectorChord is a modern vector search engine for PostgreSQL that offers better performance and indexing capabilities compared to the standard pgvector extension.
3.2 Creating the CNPG Cluster
First, I created a dedicated namespace and a secret for the database credentials:
kubectl create namespace immich
kubectl create secret generic immich-postgres-secret \
--namespace immich \
--from-literal=username=immich \
--from-literal=password=your-secure-password
Then I defined the CNPG Cluster manifest. Key details:
- 3 instances for high availability (one per node)
- VectorChord image:
ghcr.io/tensorchord/cloudnative-vectorchord:18.4 - local-path storage (20Gi each) for database performance
- Pod anti-affinity to ensure each instance runs on a different node
- postInitApplicationSQL to automatically create required extensions in the Immich database
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: immich-database
namespace: immich
spec:
instances: 3
imageName: ghcr.io/tensorchord/cloudnative-vectorchord:18.4
storage:
size: 20Gi
storageClass: local-path
bootstrap:
initdb:
database: immich
owner: immich
secret:
name: immich-postgres-secret
postInitApplicationSQL:
- CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
- CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE;
postgresql:
shared_preload_libraries:
- "vchord.so"
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
primaryUpdateStrategy: unsupervised
kubectl apply -f immich-cnpg-cluster.yaml
3.3 Monitoring Cluster Bootstrap
CNPG will create 3 pods, one on each node. The first pod initializes the database, creates the extensions, and then the other two join as replicas:
kubectl get pods -n immich -w
NAME READY STATUS RESTARTS AGE
immich-database-1 1/1 Running 0 2m
immich-database-2 1/1 Running 0 1m
immich-database-3 1/1 Running 0 1m
3.4 Verifying the Extensions
Once all pods are ready, verify that VectorChord and earthdistance are installed:
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -d immich -c "\dx"
List of installed extensions
Name | Version | Schema | Description
---------------+---------+------------+------------------------------
cube | 1.5 | public | data type for multidimensional cubes
earthdistance | 1.2 | public | calculate great-circle distances
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
vchord | 1.1.1 | public | vchord vector search extension
vector | 0.8.3 | public | vector data type and ivfflat and hnsw access methods
(5 rows)
Note: VectorChord bundles the vector extension (0.8.3) and provides its own vchord extension (1.1.1) on top. The cube extension is a dependency of earthdistance.
4. Deploying Immich via Helm
4.1 The Immich Helm Chart
Immich provides an official Helm chart hosted at oci://ghcr.io/immich-app/immich-charts/immich. The chart uses the bjw-s common library (v5.0.1) as a dependency and supports deploying all Immich components (server, machine learning, and optionally Valkey for caching).
4.2 Configuration Values
Here’s the values file I used. It configures:
- Database connection to the CNPG cluster via the
immich-database-rwservice - Valkey enabled (built into the chart)
- NFS PVC for the media library
- MetalLB LoadBalancer for the server
- Traefik ingress with Let’s Encrypt TLS
- Resource limits for all components
# Shared settings applied to all components
controllers:
main:
containers:
main:
image:
tag: v3.0.3
env:
DB_HOSTNAME: immich-database-rw
DB_USERNAME:
valueFrom:
secretKeyRef:
name: immich-postgres-secret
key: username
DB_PASSWORD:
valueFrom:
secretKeyRef:
name: immich-postgres-secret
key: password
DB_DATABASE_NAME: immich
REDIS_HOSTNAME: immich-valkey
IMMICH_MACHINE_LEARNING_URL: 'http://immich-machine-learning:3003'
immich:
metrics:
enabled: false
persistence:
library:
existingClaim: immich-nfs-pvc
configurationKind: ConfigMap
# --- Server component ---
server:
enabled: true
controllers:
main:
replicas: 3
containers:
main:
image:
repository: ghcr.io/immich-app/immich-server
pullPolicy: IfNotPresent
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
service:
main:
type: LoadBalancer
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.9.11
ports:
http:
port: 80
targetPort: 2283
https:
port: 443
targetPort: 2283
ingress:
main:
enabled: true
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: "letsencrypt"
hosts:
- host: immich.example.com
paths:
- path: "/"
pathType: Prefix
service:
identifier: main
tls:
- hosts:
- immich.example.com
# --- Machine learning component ---
machine-learning:
enabled: true
controllers:
main:
replicas: 3
containers:
main:
image:
repository: ghcr.io/immich-app/immich-machine-learning
pullPolicy: IfNotPresent
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
env:
TRANSFORMERS_CACHE: /cache
HF_XET_CACHE: /cache/huggingface-xet
MPLCONFIGDIR: /cache/matplotlib-config
persistence:
cache:
enabled: true
type: persistentVolumeClaim
size: 10Gi
accessMode: ReadWriteMany
storageClass: truenas-nfs-ssd
# --- Valkey (Redis alternative) ---
valkey:
enabled: true
controllers:
main:
containers:
main:
image:
repository: docker.io/valkey/valkey
tag: 9.1-alpine
pullPolicy: IfNotPresent
persistence:
data:
enabled: true
type: persistentVolumeClaim
size: 5Gi
accessMode: ReadWriteOnce
storageClass: truenas-nfs-ssd
4.3 Deploying
helm upgrade --install immich \
oci://ghcr.io/immich-app/immich-charts/immich \
--namespace immich \
-f values-immich.yaml
4.4 Verifying the Deployment
After a few minutes, all pods should be running:
kubectl get pods -n immich
NAME READY STATUS RESTARTS AGE
immich-database-1 1/1 Running 0 5h
immich-database-2 1/1 Running 0 5h
immich-database-3 1/1 Running 0 5h
immich-machine-learning-57cbb9b648-h297d 1/1 Running 0 2m
immich-machine-learning-57cbb9b648-xvrx8 1/1 Running 0 2m
immich-machine-learning-57cbb9b648-xw2mv 1/1 Running 0 2m
immich-server-dd4966886-6hv8d 1/1 Running 0 17m
immich-server-dd4966886-8j4ml 1/1 Running 0 17m
immich-server-dd4966886-h59d8 1/1 Running 0 21m
immich-valkey-645c7d4899-vzw7b 1/1 Running 0 5h
Check that the server is responding:
curl -s http://10.0.9.11/api/server/ping
# {"res":"pong"}
curl -s http://10.0.9.11/api/server/version
# {"major":3,"minor":0,"patch":3}
5. Scaling to 3 Replicas
5.1 Why Scale?
Immich server and machine-learning components are stateless (the server reads from the shared NFS library, and ML workers share a cache). Scaling them to 3 replicas provides:
- High availability — if a node goes down, the other replicas continue serving
- Load distribution — requests are spread across all 3 nodes
- Zero-downtime updates — rolling updates with no service interruption
5.2 Configuring Replicas
In the values file, I set replicas: 3 under both server.controllers.main and machine-learning.controllers.main. The ML cache PVC must use ReadWriteMany access mode since it’s shared across pods on different nodes.
5.3 Applying the Change
helm upgrade immich \
oci://ghcr.io/immich-app/immich-charts/immich \
--namespace immich \
-f values-immich.yaml
6. Networking & Load Balancing
6.1 MetalLB IP Pools
I created a dedicated MetalLB IP pool for the DMZ/public VLAN:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: dmz-public
namespace: metallb-system
spec:
addresses:
- 10.0.9.11-10.0.9.200
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: layer2-advert-dmz
namespace: metallb-system
spec:
ipAddressPools:
- dmz-public
interfaces:
- enp0s20f0u1.9
The Immich server gets a static IP from this pool (10.0.9.11) via the annotation metallb.universe.tf/loadBalancerIPs: 10.0.9.11.
6.2 Ingress with Traefik & Let’s Encrypt
The Traefik ingress controller handles TLS termination with certificates from Let’s Encrypt (via cert-manager). The Immich web UI is accessible at https://immich.example.com.
7. Database Migration & Permission Fixes
7.1 The Migration Process
Immich runs database migrations automatically on startup. With 85+ migration files, the process completes in a few seconds on a fresh cluster.
7.2 Handling a Partial Restore
If restoring from a backup, you may encounter permission issues. The restore process creates tables owned by postgres instead of immich, causing the Immich server to crash with:
PostgresError: permission denied for table system_metadata
To fix this, change ownership of all tables to the immich user and grant proper permissions:
# Change ownership of all tables to immich
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -d immich -c "
ALTER SCHEMA public OWNER TO immich;
ALTER TABLE public.system_metadata OWNER TO immich;
ALTER TABLE public.migrations OWNER TO immich;
-- ... repeat for all tables ...
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO immich;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO immich;
"
You can generate the ALTER TABLE statements dynamically:
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -d immich -t -c \
"SELECT 'ALTER TABLE public.' || tablename || ' OWNER TO immich;' \
FROM pg_tables WHERE schemaname = 'public';" \
| kubectl exec -n immich pod/immich-database-1 -- psql -U postgres -d immich
7.3 Handling Missing Roles
If your backup references roles that don’t exist in the new cluster, create them:
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -c "CREATE ROLE immiches WITH LOGIN SUPERUSER;"
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -c "GRANT immiches TO postgres;"
8. Final Architecture
| Component | Replicas | Storage | Notes |
| immich-database (CNPG) | 3 | 20Gi local-path each | VectorChord 18.4, HA across nodes |
| immich-server | 3 | NFS (library via immich-nfs-pvc) | MetalLB @ 10.0.9.11 |
| immich-machine-learning | 3 | 10Gi truenas-nfs-ssd (RWX) | Shared cache across replicas |
| immich-valkey | 1 | 5Gi truenas-nfs-ssd | Redis-compatible cache |
Extensions installed in PostgreSQL:
- vchord 1.1.1 — vector search engine
- vector 0.8.3 — vector data type (bundled with VectorChord)
- earthdistance 1.2 — geospatial distance calculations
- cube 1.5 — multidimensional data type (dependency of earthdistance)
9. Key Takeaways
- Use VectorChord for Immich — the official Immich docs recommend it over standard pgvector for better performance
- Dedicated CNPG cluster per app — gives each application its own PostgreSQL HA setup, avoiding the “shared database” single point of failure
- postInitApplicationSQL vs postInitSQL — use
postInitApplicationSQLto run SQL against the application database (not the defaultpostgresdatabase) - RWX for ML cache — when scaling ML replicas, the cache PVC must be ReadWriteMany
- Table ownership matters — after a DB restore, always verify table ownership matches the application user
- Helm chart with bjw-s — the Immich chart uses the bjw-s common library, which provides a consistent structure for controllers, services, persistence, and ingress
10. Useful Commands (Quick Reference)
Check cluster health
kubectl get clusters.postgresql.cnpg.io -n immich
Find the primary pod
kubectl get pods -n immich -l cnpg.io/instanceRole=primary
Check replication status
PRIMARY=$(kubectl get pods -n immich -l cnpg.io/instanceRole=primary -o name)
kubectl exec -n immich $PRIMARY -- \
psql -U postgres -c "SELECT client_addr, state, sync_state FROM pg_stat_replication;"
Check installed extensions
kubectl exec -n immich pod/immich-database-1 -- \
psql -U postgres -d immich -c "\dx"
Verify Immich server
curl -s http://<loadbalancer-ip>/api/server/ping
curl -s http://<loadbalancer-ip>/api/server/version
Check pod distribution across nodes
kubectl get pods -n immich -o \
custom-columns="NAME:.metadata.name,NODE:.spec.nodeName"
Connect to the database
kubectl exec -n immich -it pod/immich-database-1 -- \
psql -U postgres -d immich
Upgrade Immich to a new version
# Update the tag in values-immich.yaml, then:
helm upgrade immich \
oci://ghcr.io/immich-app/immich-charts/immich \
--namespace immich \
-f values-immich.yaml
Have questions or feedback? Leave a comment below!
3.2 The Result
After scaling down, the old primary remained as the only instance. The WAL archive gap went undetected. When I tried to scale back up, new replicas could not catch up.
3.3 The Lesson
Never scale down a CNPG cluster without:
- Verifying replication health first
- Taking a fresh backup
- Confirming all replicas are fully in-sync
- Using a proper backup/restore approach for scaling down
4. Enabling pgvector
4.1 What is pgvector?
pgvector is an open-source extension that adds vector data types and similarity search to PostgreSQL. It’s essential for AI-powered applications, semantic search, and machine learning features like:
- Similarity search (cosine distance, L2 distance)
- Vector embeddings from AI models (OpenAI, etc.)
- Similarity indexing (HNSW, IVFFlat)
Version installed: 0.8.1
4.2 Prerequisites: Finding Your Pod Name
Most commands require connecting to a PostgreSQL pod. First, list your pods:
# List all PostgreSQL pods in the postgres namespace
kubectl -n postgres get pods
# Expected output (example):
# NAME READY STATUS RESTARTS AGE
# pg-cluster-ha-3 1/1 Running 0 32s
# pg-cluster-ha-4 1/1 Running 0 5m
# pg-cluster-ha-5 1/1 Running 0 4m
Any pod works for read queries. The primary (newest) is usually the one with the most recent age. In this example, pg-cluster-ha-3 is the primary.
4.3 How to Enable pgvector on CNPG
The pre-built CNPG images based on Debian (like ghcr.io/cloudnative-pg/postgresql:18.1-system-trixie) already include pgvector — it just needs to be enabled.
Step 1: Apply shared_preload_libraries
Unlike most PostgreSQL settings, shared_preload_libraries requires a full server restart. CNPG 1.28 treats this as a top-level field (not under parameters):
kubectl -n postgres patch clusters.postgresql.cnpg.io pg-cluster-ha \
--patch '{"spec":{"postgresql":{"shared_preload_libraries":["vector"]}}}' \
--type=merge
This triggers an automatic restart of the database — expect a brief outage (a few seconds for a single instance).
Step 2: Verify it loaded
# Replace pg-cluster-ha-3 with your actual primary pod name
kubectl -n postgres exec pg-cluster-ha-3 -- \
psql -U postgres -c "SHOW shared_preload_libraries;"
shared_preload_libraries
--------------------------
vector
(1 row)
Step 3: Create the extension in each database
# Create the vector extension in the "app" database
kubectl -n postgres exec pg-cluster-ha-3 -- \
psql -U postgres -d app -c "CREATE EXTENSION IF NOT EXISTS vector;"
Step 4: Test it works
# Test vector distance calculation
kubectl -n postgres exec pg-cluster-ha-3 -- \
psql -U postgres -d app -c \
"SELECT '[0.1,0.2,0.3]'::vector '<=>' '[0.4,0.5,0.6]'::vector AS distance;"
distance
--------------------
0.5196152525944904
(1 row)
Step 5: Update the Cluster Config
After patching, update your cluster YAML to persist the change:
spec:
postgresql:
shared_preload_libraries:
- vector
4.4 Alternative: Connect Without Knowing Pod Names
If you prefer not to look up pod names, you can launch a temporary psql client pod:
# Launch a temporary psql client connected to the primary service
kubectl run psql-client --rm -it --image=postgres:18 --namespace=postgres -- \
psql -h pg-cluster-ha-rw.postgres.svc.cluster.local -U postgres -d app
This connects directly to the read-write ClusterIP service, which always routes to the primary. Type \q to exit and the temporary pod will be cleaned up automatically.
5. Scaling Back to 3 Instances
5.1 Update the Config File
Changed instances back from 1 to 3, and re-enabled pod anti-affinity:
spec:
instances: 3
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
5.2 Apply the Changes
kubectl -n postgres patch clusters.postgresql.cnpg.io pg-cluster-ha \
--patch '{"spec":{"instances":3,"affinity":{"enablePodAntiAffinity":true,"topologyKey":"kubernetes.io/hostname"}}}' \
--type=merge
5.3 Monitor the Process
# Watch pods come up (press Ctrl+C to stop)
kubectl -n postgres get pods -w | grep pg-cluster
CNPG will:
- Create a new pod (pg-cluster-ha-4) and start streaming from primary
- Create a second new pod (pg-cluster-ha-5) and start streaming
- The old primary (pg-cluster-ha-3) gets restarted with the new config
5.4 Handle Stuck Pods
If a pod gets stuck in “Terminating&,nbsp;force delete it:
kubectl -n postgres delete pod pg-cluster-ha-3 --force --grace-period=0
Then wait for CNPG to provision a fresh replacement.
5.5 Verify Everything
# Check pod status
kubectl -n postgres get pods
NAME READY STATUS RESTARTS AGE
pg-cluster-ha-3 1/1 Running 0 32s
pg-cluster-ha-4 1/1 Running 0 5m
pg-cluster-ha-5 1/1 Running 0 4m
# Check cluster status
kubectl -n postgres get clusters.postgresql.cnpg.io pg-cluster-ha
NAME INSTANCES READY STATUS PRIMARY
pg-cluster-ha 3 3 Cluster in healthy state pg-cluster-ha-4
5.6 Verify Node Distribution
# Check which node each pod runs on
kubectl -n postgres get pods -o custom-columns="NAME:.metadata.name,NODE:.spec.nodeName"
NAME NODE
pg-cluster-ha-3 hpe233.ntitta.local
pg-cluster-ha-4 hpe232.ntitta.local
pg-cluster-ha-5 hpe234.ntitta.local
Each pod is on a separate node — exactly what anti-affinity provides.
5.7 Verify Replication Health
# First, identify the primary pod automatically
PRIMARY_POD=$(kubectl -n postgres get pods -o jsonpath='{.items[].metadata.name}' -l cnpg.io/instanceRole=primary -n postgres)
echo "Primary pod: $PRIMARY_POD"
# Check replication from the primary
kubectl -n postgres exec $PRIMARY_POD -- \
psql -U postgres -c \
"SELECT client_addr, state, sync_state, sent_lsn, flush_lsn, replay_lsn FROM pg_stat_replication;"
client_addr | state | sync_state | sent_lsn | flush_lsn | replay_lsn
-------------+-----------+------------+-------------+-------------+-------------
10.42.2.225 | streaming | async | 65/CB059138 | 65/CB059138 | 65/CB059138
10.42.1.125 | streaming | async | 65/CB059138 | 65/CB059138 | 65/CB059138
(2 rows)
Both replicas are streaming and fully in-sync with the primary.
5.8 Verify pgvector on the New Primary
# Verify pgvector extension on primary
PRIMARY_POD=$(kubectl -n postgres get pods -o jsonpath='{.items[].metadata.name}' -l cnpg.io/instanceRole=primary -n postgres)
kubectl -n postgres exec $PRIMARY_POD -- \
psql -U postgres -d app -c \
"SELECT '[0.1,0.2,0.3]'::vector '<=>' '[0.4,0.5,0.6]'::vector AS distance;"
distance
--------------------
0.5196152525944904
(1 row)
6. Final Cluster State
| Cluster: | pg-cluster-ha |
| Status: | Healthy (3/3 instances) |
| Primary: | pg-cluster-ha-4 (on Node 2) |
| Replicas: | pg-cluster-ha-3 (Node 3), pg-cluster-ha-5 (Node 4) |
| pgvector: | 0.8.1 (enabled via shared_preload_libraries) |
| Storage: | 20Gi local-path per instance |
| HA: | True (pod anti-affinity ensures 1 pod per node) |
7. Key Takeaways
- CNPG’s
shared_preload_librariesrequires a full restart — plan for downtime - Never scale down CNPG without verifying replication health first
- WAL gaps are silent — monitor replication lag continuously
- When scaling up, expect the old primary to restart (not a switchover)
- Stuck pods sometimes need force deletion — use with caution
- pgvector is included in the CNPG system-trixie image — just enable it
- Always keep config files in sync with live cluster state
8. Reference: Full Cluster YAML (Anonymized)
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-cluster-ha
namespace: postgres
spec:
instances: 3
primaryUpdateStrategy: unsupervised
enableSuperuserAccess: true
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
postgresql:
shared_preload_libraries:
- vector
storage:
size: 20Gi
storageClass: local-path
managed:
services:
additional:
- selectorType: rw
serviceTemplate:
metadata:
name: pg-cluster-ha-lb
annotations:
metallb.universe.tf/loadBalancerIPs: 172.16.24.199
spec:
type: LoadBalancer
9. Useful Commands (Quick Reference)
Check cluster health
kubectl -n postgres get clusters.postgresql.cnpg.io
Find the primary pod
kubectl -n postgres get pods -l cnpg.io/instanceRole=primary
Check replication status
# Get primary pod name automatically
PRIMARY_POD=$(kubectl -n postgres get pods -o jsonpath='{.items[].metadata.name}' -l cnpg.io/instanceRole=primary -n postgres)
# Query replication
kubectl -n postgres exec $PRIMARY_POD -- \
psql -U postgres -c "SELECT * FROM pg_stat_replication;"
Check pgvector version
# Get primary pod name
kubectl -n postgres get pods -l cnpg.io/instanceRole=primary
# Check vector extension version (replace with actual name)
kubectl -n postgres exec -- \
psql -U postgres -c "SELECT extversion FROM pg_extension WHERE extname = 'vector';"
Scale cluster
kubectl -n postgres patch clusters.postgresql.cnpg.io pg-cluster-ha \
--patch '{"spec":{"instances":N}}' --type=merge
Check node distribution
kubectl -n postgres get pods -o \
custom-columns="NAME:.metadata.name,NODE:.spec.nodeName"
Connect to PostgreSQL directly
# Option A: Temporary psql client (auto-cleanup on exit)
kubectl run psql-client --rm -it --image=postgres:18 \
--namespace=postgres -- \
psql -h pg-cluster-ha-rw.postgres.svc.cluster.local \
-U postgres -d app
# Option B: exec into a running pod
kubectl -n postgres exec -it -- psql -U postgres -d app