Production Guides
Multi-Environment Deployment
Summary
GoFr selects per-environment configuration through the APP_ENV env var, which picks configs/.<APP_ENV>.env at startup; in Kubernetes you ship one image and override every value through environment-specific ConfigMaps, Secrets, and Helm values files. Keep namespaces or clusters isolated, point each environment at its own database and tracing endpoint, and promote by tag — never by rebuilding for the target.
When to use
Any time you have more than one running copy of a service — even if it's just dev and prod — you need a deployment story that prevents config drift. GoFr's twelve-factor config makes the what easy; this guide covers the how on Kubernetes.
One image, many environments
The build artifact never changes between environments. The same image digest that ran in staging for a day promotes to production. Everything that varies — connection strings, log levels, feature flags, replica count, resource limits — comes from the cluster.
git push tag v1.4.2 ──▶ CI builds image, signs, pushes
──▶ deploy to staging (APP_ENV=staging)
──▶ smoke + soak
──▶ deploy to prod (APP_ENV=prod)
GoFr reads APP_ENV to decide which override file to overlay on configs/.env. In Kubernetes, the override file is largely vestigial — every value comes from a ConfigMap or Secret injected with envFrom (see Twelve-Factor Config). Ship the same image to every environment and differentiate behavior through env / ConfigMap / Helm values rather than by branching on APP_ENV inside main.go — the moment two environments execute different code paths, the artifact you tested in staging stops being the artifact running in production.
Namespace per env vs cluster per env
Namespace per env (staging, prod in the same cluster) is cheaper and simpler, but shares a control plane and nodes — a runaway prod workload can starve staging, and compliance frameworks often reject it for regulated data. Cluster per env isolates everything but doubles operational overhead. Most teams start with namespaces and graduate to separate prod clusters once compliance or noisy-neighbor pressure forces the move. Whichever you pick, never share the same database, broker, or tracing backend across envs.
Helm values per environment
Keep one chart, one values.yaml for defaults, and one overrides file per env. Per-env files override only what's different — replica count, log level, datasource hosts.
# values.yaml
replicaCount: 2
image: { repository: ghcr.io/example/orders-api }
config:
HTTP_PORT: "8000"
METRICS_PORT: "2121"
LOG_LEVEL: INFO
TRACE_EXPORTER: otlp
SHUTDOWN_GRACE_PERIOD: 30s
# values-staging.yaml
image: { tag: 1.4.2 }
config:
APP_ENV: staging
LOG_LEVEL: DEBUG
DB_HOST: postgres.staging.svc.cluster.local
# GoFr's OTLP exporter speaks gRPC; use bare host:port (no http://) and the
# OTLP gRPC port 4317 (4318 is OTLP HTTP, which GoFr does NOT use).
TRACER_URL: otel-collector.observability.svc.cluster.local:4317
# values-prod.yaml
replicaCount: 10
image: { tag: 1.4.2 }
config:
APP_ENV: prod
DB_HOST: postgres-primary.prod.svc.cluster.local
TRACER_URL: otel-collector.observability.svc.cluster.local:4317
DB_MAX_OPEN_CONNECTION: "20"
Apply with helm upgrade --install orders-api ./chart -n prod -f values.yaml -f values-prod.yaml. Same chart, same image tag, different values → different environment.
Promotion flow
CI tags an image (1.4.2). helm upgrade deploys it to staging; after integration tests and a soak window, the same 1.4.2 tag promotes to prod. If a problem surfaces, helm rollback orders-api -n prod reverts. Never docker build again between envs — that invalidates the artifact you tested.
Datasource separation
Each environment must point at its own datasources. Sharing a database across staging and prod is a data-corruption incident waiting to happen — staging migrations can drop columns prod still reads.
- Separate
DB_HOST/DB_NAMEper env. - Separate Pub/Sub topics or namespaces (Kafka cluster + topic prefix, NATS account, MQTT broker).
- Separate Redis instances or at least separate
REDIS_DBnumbers. - Separate object storage buckets.
For databases under heavy migration churn, give staging its own writable replica with a nightly snapshot from prod — close enough to be representative, isolated enough to be safe. See Handling Data Migrations for the migration story itself.
Telemetry segregation
Tag every signal with the environment so dashboards and alerts can filter. Set a different TRACER_URL per env, or share a collector with an env resource attribute; use TRACER_RATIO (default 1) to drop prod sampling if volume is too high. Use LOG_LEVEL=DEBUG in staging, INFO in prod, and toggle without redeploying via REMOTE_LOG_URL (see Remote Log Level Change). Add an env Prometheus label via your scrape config so the same alert rule can fire per-environment with different thresholds. Staging alerts should page a chat channel; prod alerts page on-call.
Verification
kubectl exec -n prod deploy/orders-api -- env | grep -E '^(APP_ENV|DB_HOST|TRACER_URL|LOG_LEVEL)='
curl https://orders-api.prod.example.com/.well-known/health
The first command verifies the running container actually has the env you expect; the second confirms the service is reachable.