Skip to content
Zever
Home

Deployment

Ship Zever to production with one checklist for build, config, secrets, shutdown, health, scaling, and hardening.

4 min readEdit on GitHub

The production checklist for a tested checkout. Build once, configure per environment, run one container per process, and close it on SIGTERM. There is no build-time codegen: the .zen compiler runs explicitly (zever compile, zever generate) during development, never at container start.

Terminal window
make build # go build ./..., CI check
go build -o myapp ./cmd/myapp # one artifact per service
GOOS=linux GOARCH=amd64 go build -o myapp ./cmd/myapp # cross-compile
Target Shape Pick it for
Systemd ExecStart=/usr/local/bin/myapp with EnvironmentFile VMs and bare metal.
Kubernetes Image plus env: secrets and .env ConfigMap. Orchestrated rollouts.
Container image docker build -f examples/showcase/Dockerfile . Multi-stage: golang:1.27-trixie builder (CGO_ENABLED=0, -trimpath, -ldflags="-s -w"), final gcr.io/distroless/static-debian13:nonroot with an absolute ENTRYPOINT ["/server"].
Compose DB_PASSWORD=... docker compose up --build Scaffolded compose.yaml when zever new picks db=postgres or *=redis. Health-gated startup.

Static builds work because sqlite is pure Go (no cgo) and the distroless static base carries CA certificates for Stripe and cloud TLS. Build from the repo root; the builder runs with GOWORK=off. The worker follows the same pattern with ./cmd/worker. Build from a clean tree at a tag and keep the SBOM. Tagged releases build static binaries and SBOMs with SLSA provenance; verify before deploy:

Terminal window
gh attestation verify dist/zever-linux-amd64 --repo zenta-dev/zever
gh attestation verify sbom/cmd_zever.json --repo zenta-dev/zever

Ship an .env file for structure and set secrets and per-environment deltas through env. Layering is defaults < .env < .env.local < process env, and config.Default() never reaches production because its crypto key is deterministic and public. See Configuration.

Terminal window
DB_ADAPTER=postgres
DB_DSN=postgres://app:secret@db:5432/app
DB_MAX_CONNS=20
DB_MIN_CONNS=2
CACHE_ADAPTER=redis
CACHE_ADDR=redis:6379
LOG_ADAPTER=slog
OBSERVABILITY_ADAPTER=otlp
OBSERVABILITY_ENDPOINT=otel:4317
OBSERVABILITY_SERVICE_NAME=myapp
Terminal window
DB_DSN='postgres://app:${DB_PASSWORD}@db:5432/app'
AUTH_JWT_SECRET='prod-secret-at-least-32-bytes-long'

Resolve with an explicit env (config.FromEnv()) so a wrong working directory cannot fall back to sqlite. Keep secrets in env or mounted files, and prefer Secrets with Vault for payment credentials (SOC2 and PCI expect a real secrets manager). The env adapter cannot rotate at runtime: rotate by restarting with new values. Print only cfg.RedactedServices() at boot.

main.go
env, err := config.FromEnv()
if err != nil {
log.Fatal(err)
}
cfg, err := config.Resolve(env)
if err != nil {
log.Fatal(err)
}
c := container.New(cfg, app.Providers()...)
srv := &http.Server{Addr: ":8080", Handler: routes(c)}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-sigCh // SIGINT or SIGTERM
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
if err := c.Close(ctx); err != nil {
log.Fatal(err)
}

Build one container per process, resolve services in handlers, and close on SIGINT or SIGTERM:

main.go
env, err := config.FromEnv()
if err != nil {
log.Fatal(err)
}
cfg, err := config.Resolve(env)
if err != nil {
log.Fatal(err)
}
c := container.New(cfg, app.Providers()...)
srv := &http.Server{Addr: ":8080", Handler: routes(c)}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-sigCh // SIGINT or SIGTERM
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
if err := c.Close(ctx); err != nil {
log.Fatal(err)
}

Wire /healthz and /readyz:

r.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if err := c.DB().Ping(r.Context()); err != nil {
http.Error(w, "db", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}

Configure liveness on /healthz (no deps) and readiness on /readyz (with 3s db timeout). In Kubernetes, the readinessProbe timeout must exceed the db timeout so a slow query doesn’t flip the probe.

On SIGTERM the generated server flips gRPC health to NOT_SERVING, calls GracefulStop(), shuts the HTTP server down, and closes the container, all within a 10 second grace. Keep terminationGracePeriodSeconds above that plus any preStop delay.

A short preStop delay lets endpoint removal propagate before SIGTERM. The reference image is distroless and has no shell; the example uses the native sleep lifecycle handler (Kubernetes 1.30+). On older clusters, add a tiny static sleep binary.

Log level and the rate limiter swap on SIGHUP; everything else needs a rollout. A rolling restart (kubectl rollout restart deployment/myapp-server) is the supported way to change anything else.

Run more than one replica only with shared-state adapters (redis, db) for cache, queue, session, lock, rate-limit. 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.

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.

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:

Terminal window
kubectl port-forward deploy/myapp-server 6060:6060
go tool pprof http://127.0.0.1:6060/debug/pprof/heap

See Profiling with pprof. Never run zever tinker against production.

  • Distroless, non-root, read-only rootfs, drop all capabilities
  • Secrets in Secret (not ConfigMap), rotate by restart
  • Readiness probe with db timeout
  • Liveness on /healthz (no deps)
  • preStop delay + terminationGracePeriodSeconds
  • Read-only rootfs, drop ALL capabilities
  • NetworkPolicy: deny all egress except db, redis, otel, allowed APIs
  • PodDisruptionBudget with minAvailable
  • Rolling restart for config changes (SIGHUP only reloads log/ratelimit)
  • Migrations as separate Job before rollout