- Docs
- Operate
- Deploy to Kubernetes
Deploy to Kubernetes
Run the generated server and worker on Kubernetes with probes, graceful shutdown, config and secrets, a PodDisruptionBudget, and an HorizontalPodAutoscaler.
This guide turns the deployment checklist into Kubernetes manifests for a project made with zever new: one image per entrypoint, config in a ConfigMap, secrets in a Secret, health probes against /healthz and /readyz, and a shutdown sequence that drains traffic before the process exits. The manifests are a starting point to copy and adapt; they are not generated by the CLI.
Images
Section titled “Images”zever new writes a multi-stage Dockerfile that builds ./cmd/server into a distroless, non-root image. Build the worker the same way by pointing the builder at ./cmd/worker (the Dockerfile ships a commented example for it), and tag the two images separately so web and worker scale independently.
docker build -t registry.example.com/myapp-server:1.0.0 \ --build-arg VERSION=1.0.0 --build-arg REVISION="$(git rev-parse HEAD)" .Config and secrets
Section titled “Config and secrets”Keep structure in a ConfigMap and anything sensitive in a Secret. The layering is defaults < .env < .env.local < process env, so the Secret only needs the values that differ per environment, and secrets never go in the ConfigMap or the image. The .env file is committed (example) or injected via CI.
apiVersion: v1kind: ConfigMapmetadata: name: myapp-configdata: periodSeconds: 2 failureThreshold: 30 livenessProbe: httpGet: {path: /healthz, port: http} periodSeconds: 10 readinessProbe: httpGet: {path: /readyz, port: http} periodSeconds: 5 timeoutSeconds: 4 lifecycle: preStop: sleep: {seconds: 5} resources: requests: {cpu: 100m, memory: 128Mi} limits: {memory: 256Mi} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: {drop: ["ALL"]} volumes: - name: config configMap: {name: myapp-config}Why these settings:
- Probes.
/healthzalways returns 200 with no dependency checks, so it is the liveness and startup probe: a down database must not restart healthy pods./readyzpings the database (3 second timeout) and the gRPC health state and returns 503 on failure, so it gates traffic. ThereadinessProbetimeout is above that 3 seconds. - Graceful shutdown. On SIGTERM the generated server flips gRPC health to
NOT_SERVING, callsGracefulStop(), shuts the HTTP server down, and closes the container, all within a 10 second grace. KeepterminationGracePeriodSecondsabove that plus anypreStopdelay, so the kubelet does not SIGKILL a server that is still draining. - Endpoint removal races. Kubernetes removes a pod from Service endpoints at the same time it starts termination, and some proxies keep sending for a moment. A short
preStopdelay lets the endpoint removal propagate before SIGTERM. The reference image is distroless and has no shell, so apreStopexecofsleepis not available; the example uses the nativesleeplifecycle handler (Kubernetes 1.30 and later). On older clusters, add a tiny static sleep binary to the image. Count the delay inside the 30 seconds. - Read-only root filesystem. The server needs no writable path unless you use the
localstorage adapter or a sqlite file; mount anemptyDiror a volume there. - Config reload. Log level and the rate limiter are swapped on SIGHUP; everything else needs a rollout. A rolling restart (
kubectl rollout restart deployment/myapp-server) is the supported way to change anything else.
Service, PodDisruptionBudget, and autoscaling
Section titled “Service, PodDisruptionBudget, and autoscaling”apiVersion: v1kind: Servicemetadata: name: myapp-serverspec: selector: {app: myapp, component: server} ports: - {name: http, port: 80, targetPort: http} - {name: grpc, port: 9090, targetPort: grpc}---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: myapp-serverspec: minAvailable: 2 selector: matchLabels: {app: myapp, component: server}---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: myapp-serverspec: scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: myapp-server} minReplicas: 3 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: {type: Utilization, averageUtilization: 70}The PodDisruptionBudget keeps two pods serving through node drains; size minAvailable below minReplicas. CPU is a reasonable first HPA signal for the server.
The worker Deployment
Section titled “The worker Deployment”The worker runs the job worker and the scheduler in one process over one shared queue, and also drains the outbox relay. Give it its own Deployment and image, and no Service or HTTP probes.
apiVersion: apps/v1kind: Deploymentmetadata: name: myapp-workerspec: replicas: 2 selector: matchLabels: {app: myapp, component: worker} template: metadata: labels: {app: myapp, component: worker} spec: terminationGracePeriodSeconds: 30 securityContext: runAsNonRoot: true containers: - name: worker image: registry.example.com/myapp-worker:1.0.0 envFrom: - secretRef: {name: myapp-secrets} volumeMounts: resources: requests: {cpu: 100m, memory: 128Mi} limits: {memory: 256Mi} volumes: - name: config configMap: {name: myapp-config}Run more than one worker only with a shared queue adapter (redis or db). With more than one scheduler replica, wire a locker so a cron slot fires once; otherwise run the scheduler in a single replica. Scale workers on queue depth (queue.Length) with an autoscaler such as KEDA rather than on CPU. See Scheduler and jobs and the background jobs overview.
Migrations and one-off tasks
Section titled “Migrations and one-off tasks”Run zever db migrate as a Kubernetes Job (or an init step in your pipeline) before rolling out a schema change, not inside every pod. Use a distinct Job per release so a failed migration blocks the rollout instead of racing replicas.
Profiling and debugging
Section titled “Profiling and debugging”The generated server can expose pprof on a loopback-only listener (--pprof-addr). In a cluster, set PPROF_ADDR=127.0.0.1:6060 on the pod and reach it with a port-forward rather than a Service (the forward connects inside the pod’s network namespace, so the loopback-only bind is reachable):
kubectl port-forward deploy/myapp-server 6060:6060go tool pprof http://127.0.0.1:6060/debug/pprof/heapSee Profiling with pprof. Never run zever tinker against production.