TL;DR

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:

  1. values.yaml — credentials, image, persistence, resources, and enabled flags (no hardcoded cluster DNS here).
  2. _helpers.tpl — compute postgres.fullHost and postgres.databaseUrl from .Release.Namespace.
  3. postgres-secret.yaml — password referenced by Postgres and app Deployments.
  4. postgres-pvc.yaml — durable disk (gate on persistence.enabled).
  5. postgres-deployment.yaml — official image, pg_isready probes, resources, env, volumeMount.
  6. postgres-service.yaml — stable ClusterIP DNS on port 5432.
  7. App deployment — helpers for DATABASE_URL; optional init container waits for Postgres before the app starts.
app-service DATABASE_URL httpGet /health Service postgres:5432 ClusterIP DNS postgres pod POSTGRES_* env pg_isready probes PVC postgres-pvc /var/lib/postgresql/data DNS built in templates: postgres.<Release.Namespace>.svc.cluster.local Secret password → postgres Deployment + app Deployment · Gate postgres + app templates with enabled flags

Figure 1 — In-cluster Postgres: app connects via Service DNS; postgres pod mounts PVC for durable data.

💡
Install namespace — Pass -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.

FileKindPurpose
templates/_helpers.tplHelpersBuild cluster DNS host and DATABASE_URL from .Release.Namespace.
templates/postgres-secret.yamlSecretPassword — referenced via secretKeyRef by Postgres and app.
templates/postgres-pvc.yamlPersistentVolumeClaimDurable storage; gate on persistence.enabled too.
templates/postgres-deployment.yamlDeploymentPostgres container + POSTGRES_DB/USER/PASSWORD + volumeMount.
templates/postgres-service.yamlServiceClusterIP on port 5432; selector app: postgres.
templates/<app>-deployment.yamlDeploymentApp env: DATABASE_URL is what most Python/Node apps actually read.
values.yamlConfigImage, 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.

yamlvalues.yaml
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.

yamltemplates/_helpers.tpl
{{- 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.

yamltemplates/postgres-secret.yaml
{{- 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 }}
💡
Secret checklist — Use lowercase 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.

yamltemplates/postgres-pvc.yaml
{{- 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.

yamltemplates/postgres-deployment.yaml
{{- 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.

yamltemplates/postgres-service.yaml
{{- 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.

yamltemplates/app-deployment.yaml
{{- 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.

💡
Production pattern — Combine init container wait + Postgres 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.

yamltemplates/app-deployment.yaml (initContainers excerpt)
    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:

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

FlagTemplatesWhen false
postgres.enabledsecret, pvc, deployment, serviceNo in-cluster Postgres; point app at external DB.
postgres.persistence.enabledpvc, deployment volumesEphemeral Postgres for throwaway dev.
app.enabled + postgres.enabledapp deployment, app serviceSkip app when bundled Postgres is off.
yaml
{{- 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.

bashrender-and-check.sh
# 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.

bashinstall.sh
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:

SymptomCheckAction
App CreateContainerConfigErrorkubectl describe pod eventsConfirm secretKeyRef.key matches Secret stringData key.
App starts before Postgres readyPod events / app logsAdd init container with pg_isready wait loop.
Postgres never Readykubectl describe pod -l app=postgresConfirm probes use pg_isready exec, not httpGet.
password authentication failedRendered DATABASE_URL userUser in URL must match postgres_user / initialized DB.
DNS / connection refusedPOSTGRES_HOST in rendered auth envHost should use install namespace via helper.
Data lost on restartkubectl get pvc + pod volumeMountsEnable persistence; mount /var/lib/postgresql/data.
PVC Pendingkubectl get scSet a valid storageClass in values.
resources: null in rendervalues.yaml postgres blockDefine postgres.resources before referencing it.

Full Chart Layout

Minimal tree after completing this guide:

text
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

See also