PostgreSQL From Scratch (Helm)
Run Postgres in Kubernetes with six Helm pieces plus an optional init container: _helpers.tpl, Secret, PVC, Deployment (pg_isready probes), Service, and a gated app Deployment that can wait for Postgres before starting. Build hosts from .Release.Namespace in templates — not hardcoded FQDNs in values.
Architecture & Order of Work
When an app pod connects to Postgres inside the cluster, four Kubernetes objects must agree on names, ports, credentials, and storage. Build them in this order:
- values.yaml — credentials, image, persistence, resources, and
enabledflags (no hardcoded cluster DNS here). - _helpers.tpl — compute
postgres.fullHostandpostgres.databaseUrlfrom.Release.Namespace. - postgres-secret.yaml — password referenced by Postgres and app Deployments.
- postgres-pvc.yaml — durable disk (gate on
persistence.enabled). - postgres-deployment.yaml — official image,
pg_isreadyprobes, resources, env, volumeMount. - postgres-service.yaml — stable ClusterIP DNS on port 5432.
- App deployment — helpers for
DATABASE_URL; optional init container waits for Postgres before the app starts.
Figure 1 — In-cluster Postgres: app connects via Service DNS; postgres pod mounts PVC for durable data.
-n my-namespace at helm upgrade --install. Templates should use {{ .Release.Namespace }} for DNS — not a hardcoded FQDN in values.yaml. A release.namespace value in values is documentation only unless you wire it into templates.Helm Files to Create
Under your chart's templates/ directory, add these files. All postgres templates should be gated with {{- if .Values.postgres.enabled }} so you can disable in-cluster Postgres and point apps at RDS or Cloud SQL later.
| File | Kind | Purpose |
|---|---|---|
templates/_helpers.tpl | Helpers | Build cluster DNS host and DATABASE_URL from .Release.Namespace. |
templates/postgres-secret.yaml | Secret | Password — referenced via secretKeyRef by Postgres and app. |
templates/postgres-pvc.yaml | PersistentVolumeClaim | Durable storage; gate on persistence.enabled too. |
templates/postgres-deployment.yaml | Deployment | Postgres container + POSTGRES_DB/USER/PASSWORD + volumeMount. |
templates/postgres-service.yaml | Service | ClusterIP on port 5432; selector app: postgres. |
templates/<app>-deployment.yaml | Deployment | App env: DATABASE_URL is what most Python/Node apps actually read. |
values.yaml | Config | Image, credentials, persistence, resources, enabled flags — not cluster DNS strings. |
Step 1 — values.yaml
Define postgres: with everything templates need except cluster DNS — that belongs in _helpers.tpl. Include resources if the Deployment references them. Keep postgres_user aligned with what apps expect in connection strings.
release:
name: my-app # suggested helm release name (documentation)
namespace: my-app # suggested install namespace; templates use .Release.Namespace
postgres:
enabled: true
image:
repository: postgres
tag: "15.3"
pullPolicy: IfNotPresent
service:
name: postgres # becomes DNS label in helper
port: 5432
containerport: 5432
postgres_database: mydb
postgres_user: myuser
postgres_password: change-me # dev default; override with --set in prod
persistence:
enabled: true
storageClass: standard
accessModes:
- ReadWriteOnce
size: 1Gi
replicas: 1
resources: # define before referencing in Deployment
requests:
cpu: 100m
memory: 128Mi
app:
enabled: true # gate Deployment + Service
Override postgres_password at install time (--set postgres.postgres_password='...') or use External Secrets in production. Do not put Helm template syntax ({{ ... }}) inside values.yaml — values are data, not templates.
Step 2 — _helpers.tpl
Create helper templates once. Every app that talks to Postgres includes these instead of duplicating FQDNs or connection strings in values.
{{- define "postgres.fullHost" -}}
{{ .Values.postgres.service.name }}.{{ .Release.Namespace }}.svc.cluster.local
{{- end }}
{{- define "postgres.databaseUrl" -}}
postgresql+asyncpg://{{ .Values.postgres.postgres_user }}:{{ .Values.postgres.postgres_password }}@{{ include "postgres.fullHost" . }}:{{ .Values.postgres.service.port }}/{{ .Values.postgres.postgres_database }}
{{- end }}
At install with -n staging, include "postgres.fullHost" renders postgres.staging.svc.cluster.local automatically. Build DATABASE_URL here so user, host, port, and database stay in sync.
Step 3 — postgres-secret.yaml
The Secret holds the password. Use stringData (plaintext input — Kubernetes base64-encodes it). Put type: Opaque at the root of the manifest, not under metadata. Pick one key name (e.g. postgres-password) and use it everywhere — Secret, Postgres Deployment, and app Deployment must match exactly.
{{- if .Values.postgres.enabled }}
apiVersion: v1
kind: Secret
type: Opaque # root level — not under metadata
metadata:
name: postgres-secret
stringData: # lowercase "s" — StringData is invalid and ignored by Kubernetes
postgres-password: {{ .Values.postgres.postgres_password | quote }}
{{- end }}
stringData at the manifest root; keep type: Opaque outside metadata; use one key name (e.g. postgres-password) consistently in Secret, postgres Deployment, and app Deployment.Step 4 — postgres-pvc.yaml
Without a PVC, Postgres writes to the container filesystem. Pod restarts wipe the database. Request a PersistentVolumeClaim and mount it at /var/lib/postgresql/data (the official image's data directory). Gate on both postgres.enabled and persistence.enabled.
{{- if and .Values.postgres.enabled .Values.postgres.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc # must match claimName in Deployment volumes block
spec:
accessModes:
{{- range .Values.postgres.persistence.accessModes }}
- {{ . }} # loop — accessModes in values is a list, not a scalar
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.persistence.size }}
storageClassName: {{ .Values.postgres.persistence.storageClass }}
{{- end }}
ReadWriteOnce volumes attach to one node at a time. That is why replicas: 1 on the Postgres Deployment is correct for this pattern. For HA Postgres, use an operator (CloudNativePG, Zalando) or managed RDS — not a second replica on the same RWO claim.
Step 5 — postgres-deployment.yaml
The official postgres image bootstraps from uppercase env vars: POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD. Place them under the container's env: block. Add pg_isready exec probes — Postgres speaks the PostgreSQL protocol on 5432, not HTTP. Set resources as a sibling of probes and env, not nested inside a probe.
{{- if .Values.postgres.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
labels:
app: postgres
spec:
replicas: {{ .Values.postgres.replicas }}
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: {{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}
imagePullPolicy: {{ .Values.postgres.image.pullPolicy }}
ports:
- containerPort: {{ .Values.postgres.containerport }}
readinessProbe: # exec — Postgres is not HTTP
exec:
command:
- pg_isready
- -U
- {{ .Values.postgres.postgres_user }}
- -d
- {{ .Values.postgres.postgres_database }}
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command:
- pg_isready
- -U
- {{ .Values.postgres.postgres_user }}
initialDelaySeconds: 30
periodSeconds: 10
resources: # sibling of probes — not inside livenessProbe
{{ toYaml .Values.postgres.resources | nindent 12 }}
env:
- name: POSTGRES_DB
value: {{ .Values.postgres.postgres_database }}
- name: POSTGRES_USER
value: {{ .Values.postgres.postgres_user }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: postgres-password
{{- if .Values.postgres.persistence.enabled }}
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
{{- end }}
{{- if .Values.postgres.persistence.enabled }}
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
{{- end }}
{{- end }}
Bootstrap env vars only apply on first initialization when the data directory is empty. Changing POSTGRES_USER in values after data exists will not alter the live database — you would need a migration or a fresh PVC.
Step 6 — postgres-service.yaml
Deployments get random pod IPs. Apps need a Service for stable DNS. Use ClusterIP (default) for in-cluster access only. The Service name becomes the DNS label: <service.name>.<namespace>.svc.cluster.local.
{{- if .Values.postgres.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.postgres.service.name }}
spec:
selector:
app: postgres # routes to pods with this label
ports:
- port: {{ .Values.postgres.service.port }} # port clients connect to
targetPort: {{ .Values.postgres.containerport }} # port on the postgres container
protocol: TCP
{{- end }}
Missing this Service is a common reason apps fail with "connection refused" or DNS NXDOMAIN even when the Postgres pod is running.
Step 7 — Wire the Application Deployment
Gate the app Deployment with {{- if and .Values.app.enabled .Values.postgres.enabled }} when the app uses bundled Postgres. Inject host and DATABASE_URL via helpers. Use httpGet probes on the app's HTTP health endpoint — that pattern is correct for FastAPI/Node apps, unlike Postgres.
{{- if and .Values.app.enabled .Values.postgres.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-service
# ...
spec:
containers:
- name: app-service
env:
- name: POSTGRES_HOST
value: {{ include "postgres.fullHost" . | quote }}
- name: POSTGRES_PORT
value: {{ .Values.postgres.service.port | quote }}
- name: POSTGRES_DATABASE
value: {{ .Values.postgres.postgres_database }}
- name: POSTGRES_USER
value: {{ .Values.postgres.postgres_user }}
- name: DATABASE_URL
value: {{ include "postgres.databaseUrl" . | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: postgres-password
readinessProbe:
httpGet:
path: /health
port: {{ .Values.app.containerport }}
livenessProbe:
httpGet:
path: /health
port: {{ .Values.app.containerport }}
resources:
{{ toYaml .Values.app.resources | nindent 12 }}
{{- end }}
Ensure your Services template also checks app.enabled so disabling the app removes both Deployment and Service. Most apps read DATABASE_URL only — building it in _helpers.tpl keeps credentials and host aligned.
Wait for Postgres — init container
A flag like app.enabled and postgres.enabled means “deploy this app only when bundled Postgres is enabled.” It does not control startup order inside one helm install — Kubernetes still creates Postgres and app pods in parallel.
An init container runs in the same pod before the main app container starts. Use it to block until Postgres accepts connections. Init containers run to completion (exit 0) before the app container is started.
pg_isready probes + app connection retry. Init containers reduce crash loops; they do not replace DB-aware readiness checks if you need “ready” to mean “can query the database.”Add initContainers at the pod spec level — a sibling of containers, not nested inside the app container. Use the same Postgres image (includes pg_isready) and the in-cluster Service name as host.
spec:
initContainers:
- name: wait-for-postgres
image: {{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}
command:
- sh
- -c
- |
until pg_isready \
-h {{ .Values.postgres.service.name }} \
-p {{ .Values.postgres.service.port }} \
-U {{ .Values.postgres.postgres_user }}; do
echo "waiting for postgres..."
sleep 2
done
containers:
- name: app-service
image: {{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}
# ... env, probes, resources as in Step 7
Inside the same namespace, -h {{ .Values.postgres.service.name }} (e.g. postgres) resolves via cluster DNS — you do not need the full FQDN for pg_isready. The init container exits once Postgres is accepting connections; then the app container starts and runs migrations or schema init.
Verify the rendered pod spec includes both initContainers and containers:
helm template my-release ./helm --namespace staging \
--show-only templates/app-deployment.yaml | grep -E 'initContainers|wait-for-postgres'
Step 8 — Feature Flags & Gating
Wrap each optional resource file. Place {{- end }} at the last line of the file — not indented inside the container spec.
| Flag | Templates | When false |
|---|---|---|
postgres.enabled | secret, pvc, deployment, service | No in-cluster Postgres; point app at external DB. |
postgres.persistence.enabled | pvc, deployment volumes | Ephemeral Postgres for throwaway dev. |
app.enabled + postgres.enabled | app deployment, app service | Skip app when bundled Postgres is off. |
{{- if .Values.postgres.enabled }}
# ... entire postgres manifest ...
{{- end }}
Step 9 — Verify Before Apply
Always render locally first. This catches indentation bugs, missing secrets keys, and gating mistakes without touching the cluster.
# Render with the namespace you will install into
helm template my-release ./helm --namespace staging
# Confirm DNS uses that namespace
helm template my-release ./helm --namespace staging \
--show-only templates/app-deployment.yaml | grep POSTGRES_HOST
# Confirm init container wait-for-postgres is present
helm template my-release ./helm \
--show-only templates/app-deployment.yaml | grep wait-for-postgres
# Confirm pg_isready probes on postgres (not httpGet)
helm template my-release ./helm \
--show-only templates/postgres-deployment.yaml | grep -A2 readinessProbe
# Confirm gating
helm template my-release ./helm --set postgres.enabled=false | grep -i postgres
helm template my-release ./helm --set app.enabled=false | grep app-service
# Lint
helm lint ./helm
Rendered postgres Deployment should show exactly one container with pg_isready under both probes and a populated resources block when defined in values.
Step 10 — Install to the Cluster
Install into a dedicated namespace. Wait for Postgres to become ready before app pods that depend on it start migrations.
kubectl create namespace my-namespace --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install my-release ./helm \
--namespace my-namespace \
--wait --atomic \
--timeout 10m
# Confirm postgres is up
kubectl get pods,svc,pvc -n my-namespace -l app=postgres
kubectl describe pod -n my-namespace -l app=postgres # check volumeMount + env
# Test DNS from a debug pod
kubectl run -it --rm psql-test --image=postgres:15.3 --restart=Never -n my-namespace -- \
psql "postgresql://myuser:change-me@postgres:5432/mydb" -c '\conninfo'
Diagnostics After Deploy
If something fails, check these in order:
| Symptom | Check | Action |
|---|---|---|
App CreateContainerConfigError | kubectl describe pod events | Confirm secretKeyRef.key matches Secret stringData key. |
| App starts before Postgres ready | Pod events / app logs | Add init container with pg_isready wait loop. |
| Postgres never Ready | kubectl describe pod -l app=postgres | Confirm probes use pg_isready exec, not httpGet. |
password authentication failed | Rendered DATABASE_URL user | User in URL must match postgres_user / initialized DB. |
| DNS / connection refused | POSTGRES_HOST in rendered auth env | Host should use install namespace via helper. |
| Data lost on restart | kubectl get pvc + pod volumeMounts | Enable persistence; mount /var/lib/postgresql/data. |
PVC Pending | kubectl get sc | Set a valid storageClass in values. |
resources: null in render | values.yaml postgres block | Define postgres.resources before referencing it. |
Full Chart Layout
Minimal tree after completing this guide:
helm/
├── Chart.yaml
├── values.yaml
└── templates/
├── _helpers.tpl # postgres.fullHost, postgres.databaseUrl
├── postgres-secret.yaml
├── postgres-pvc.yaml
├── postgres-deployment.yaml
├── postgres-service.yaml
└── app-deployment.yaml # env + optional initContainers