Kubernetes Deployment
Pennant runs well on Kubernetes. The server exposes standard HTTP health probes and emits Prometheus metrics. Here is a complete single-replica deployment with a PostgreSQL backend.
Namespace and secret
apiVersion: v1
kind: Namespace
metadata:
name: pennant
---
apiVersion: v1
kind: Secret
metadata:
name: pennant-secrets
namespace: pennant
type: Opaque
stringData:
jwt-secret: "your-long-random-secret-at-least-32-chars"
database-url: "postgres://pennant:password@postgres-svc:5432/pennant?sslmode=disable"
Do not commit secrets
Use a secrets management solution (External Secrets Operator, Sealed Secrets, AWS Secrets Manager, HashiCorp Vault) rather than storing plaintext secrets in manifests.
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: pennant
namespace: pennant
labels:
app: pennant
spec:
replicas: 2
selector:
matchLabels:
app: pennant
template:
metadata:
labels:
app: pennant
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
terminationGracePeriodSeconds: 30
containers:
- name: pennant
image: ghcr.io/sanskarpan/pennant:v1.0.0
ports:
- containerPort: 8080
name: http
env:
- name: PENNANT_JWT_SECRET
valueFrom:
secretKeyRef:
name: pennant-secrets
key: jwt-secret
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: pennant-secrets
key: database-url
- name: PENNANT_CORS_ORIGINS
value: "https://admin.yourapp.com"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "500m"
memory: "256Mi"
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
Liveness vs readiness
| Probe | Endpoint | Purpose |
|---|---|---|
| Liveness | GET /health |
Restarts the pod if the store connection is lost and not recovering |
| Readiness | GET /ready |
Removes the pod from service endpoints during startup or store outage |
Both return 200 OK when healthy and 503 Service Unavailable when not. The difference is in Kubernetes behavior: a failed liveness probe triggers a pod restart; a failed readiness probe removes the pod from the Service's endpoint slice without restarting it.
Service
apiVersion: v1
kind: Service
metadata:
name: pennant-svc
namespace: pennant
spec:
selector:
app: pennant
ports:
- port: 80
targetPort: 8080
name: http
type: ClusterIP
Ingress (with TLS)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pennant-ingress
namespace: pennant
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- flags.yourapp.com
secretName: pennant-tls
rules:
- host: flags.yourapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pennant-svc
port:
name: http
SSE timeout configuration
SSE connections are long-lived HTTP connections. The proxy-read-timeout and proxy-send-timeout annotations increase NGINX's default 60-second timeout to 1 hour. Without this, NGINX will close SSE connections every 60 seconds, triggering constant client reconnects. Other ingress controllers (Traefik, Envoy/Istio) have equivalent settings.
Multi-replica notes
Multiple Pennant replicas are safe when using PostgreSQL. Each replica: - Maintains its own in-memory SSE hub - Persists flag changes to the shared PostgreSQL database - Reads changes from the database on each write
Important: A flag update made to replica A is persisted to Postgres and immediately streamed to SDK clients connected to replica A. SDK clients connected to replica B will receive the update on their next SSE heartbeat or reconnect. The maximum propagation delay is one heartbeat interval (default 25 seconds).
For zero-lag cross-replica propagation, you could add a Postgres LISTEN/NOTIFY channel — this is not currently built-in but is a natural extension point in internal/store/postgres.go.
PodDisruptionBudget
Keep at least one replica available during rolling updates:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: pennant-pdb
namespace: pennant
spec:
minAvailable: 1
selector:
matchLabels:
app: pennant
Horizontal Pod Autoscaler
Pennant scales horizontally (with PostgreSQL). Scale on CPU or RPS:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: pennant-hpa
namespace: pennant
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pennant
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Resource sizing guidelines
| Scale | Replicas | CPU request | Memory request |
|---|---|---|---|
| < 100 flags, < 1K SDK clients | 1 | 50m | 32Mi |
| < 1K flags, < 10K SDK clients | 2 | 100m | 64Mi |
| < 10K flags, < 100K SDK clients | 3–5 | 250m | 128Mi |
| > 10K flags or > 100K SDK clients | Profile first | 500m+ | 256Mi+ |
These are starting points. The SSE hub's memory usage scales with the number of concurrent connected clients. Profile with your actual load using make loadgen.