Skip to content

Kubernetes

pfCentral runs well on Kubernetes. Pick a shape first:

ShapeNATSReplicasState volumeStrategy
SingleEmbedded1PersistentVolumeClaimRecreate
High availabilityExternal2 or moreemptyDirRollingUpdate

With the embedded broker the pod owns the keys and the JetStream data on its volume, so only one pod may run at a time. With an external broker, every replica is stateless: keys live in Secrets and durable data lives in PostgreSQL and on the broker.

Keep secrets out of the manifest:

Terminal window
kubectl create secret generic pf-central \
--from-literal=PFCENTRAL_DATABASE_URL='postgres://pf_central:…@postgres:5432/pf_central?sslmode=require' \
--from-literal=PFCENTRAL_TOKEN_PEPPER_CURRENT="$(openssl rand -hex 32)" \
--from-literal=PFCENTRAL_TOKEN_PEPPER_VERSIONS="<same value>"
SecretVariableShape
Database DSNPFCENTRAL_DATABASE_URLBoth
Token pepperPFCENTRAL_TOKEN_PEPPER_CURRENT, _VERSIONSBoth
First owner (optional)PFCENTRAL_SUPERUSER_EMAIL, _PASSWORDBoth
Email key and provider credentialsPFCENTRAL_EMAIL_*Both, if email is on
NATS app, callout and sentinel credsfiles at PFCENTRAL_NATS_*_CREDS_FILEHA
Delegated signing keyfile at PFCENTRAL_NATS_SIGNING_KEY_PATHHA
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: pf-central-state }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 2Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: pf-central }
spec:
replicas: 1
strategy: { type: Recreate } # two pods must never share the state volume
selector: { matchLabels: { app: pf-central } }
template:
metadata: { labels: { app: pf-central } }
spec:
securityContext: { runAsNonRoot: true, runAsUser: 65532, fsGroup: 65532 }
containers:
- name: pf-central
image: ghcr.io/danielmichaels/pf-central:0.0.12
envFrom: [{ secretRef: { name: pf-central } }]
env:
- { name: PFCENTRAL_SERVER_BASE_URL, value: https://pf.example.com }
- { name: PFCENTRAL_TRUSTED_ORIGINS, value: https://pf.example.com }
- { name: PFCENTRAL_NATS_TLS_ENABLED, value: "true" }
- { name: PFCENTRAL_NATS_TLS_HOSTS, value: pf.example.com }
- { name: PFCENTRAL_LOG_LEVEL, value: info }
ports:
- { name: http, containerPort: 2808 }
- { name: nats, containerPort: 24222 }
startupProbe: { tcpSocket: { port: http }, periodSeconds: 5, failureThreshold: 30 }
livenessProbe: { tcpSocket: { port: http } }
readinessProbe: { tcpSocket: { port: http } }
volumeMounts: [{ name: state, mountPath: /data }]
volumes:
- { name: state, persistentVolumeClaim: { claimName: pf-central-state } }
---
apiVersion: v1
kind: Service
metadata: { name: pf-central-nats }
spec:
type: LoadBalancer # agents connect here; TCP passthrough, no HTTP proxy
selector: { app: pf-central }
ports: [{ name: nats, port: 24222, targetPort: nats }]
---
apiVersion: v1
kind: Service
metadata: { name: pf-central-http }
spec:
selector: { app: pf-central }
ports: [{ name: http, port: 80, targetPort: http }]

Put your usual Ingress or Gateway in front of the HTTP Service for HTTPS, and set its read timeout to at least an hour so the UI’s live updates are not cut off. See HTTPS for the web UI.

pfCentral has no health endpoint yet. Use TCP probes on the HTTP port, as above. / answers with a redirect to /login, which also counts as healthy for an HTTP probe. The startupProbe gives the first start time to run database migrations.

  • Migrations and first-run bootstrap take a PostgreSQL advisory lock, so two pods starting at once cannot both run them.
  • Sessions live in PostgreSQL, so any replica serves any user.
  • The authorization callout runs on every replica in a NATS queue group; one replica answers each login.
  • Telemetry and presence: every replica reads the same JetStream stream and buckets, so every replica shows the full fleet.