This repository is a canonical sample for a Bazel-built web/API application deployed to Kubernetes with FluxCD. Prefer clear, repeatable patterns over one-off deployment shortcuts.
The expected deployment environment is:
- Kubernetes.
- FluxCD reconciling OCI-hosted deployment bundles.
- CloudNativePG managing Postgres clusters and generated database credentials.
- Gateway API routing traffic to the gateway.
Bazel builds application images and Flux OCI bundles. Flux then reconciles the rendered Kubernetes resources from those bundles into the target namespace. The checked-in manifests intentionally omit namespace and hostname details so the same bundles can be used in different tenant environments.
This matters for Jobs: Flux can detect a changed Job manifest, but Kubernetes
does not allow most Job.spec.template fields to be updated in place. A bundle
that contains Jobs must either use forced apply semantics or create new Job
names for changed Jobs.
- Bazel is the only build tool used for project artifacts.
- Deployable application artifacts are pushed as OCI images and Flux OCI
bundles through
adiom-data/bazel-rules. - Deploy is split into three ordered bundles:
infra: long-lived infrastructure, currently the CloudNativePG cluster.preview_infra: disposable preview infrastructure, currently a plain Postgres Deployment backed byemptyDirto avoid per-preview PVCs.migration: rerunnable Kubernetes Jobs for database setup and schema migrations.app: runtime workloads, currently the API and gateway.
- Only the
migrationbundle usesforce = True. This is intentional because Kubernetes Job pod templates are immutable and Jobs need to be recreated when their scripts or images change. - Do not put long-lived resources like the Postgres
Clusterin a forced bundle. - Preview publish targets may swap the long-lived infra bundle for preview-specific disposable infrastructure, but should keep the same service and secret names consumed by migration and app bundles.
- User-facing Deployments should run at least two replicas with
maxUnavailable: 0,maxSurge: 1, and readiness probes so normal rolling updates keep an old ready pod serving until a new pod is ready.
- Observability collection and backend storage are provider-owned. Application bundles should not install SigNoz, VictoriaMetrics, VictoriaLogs, or other observability backends.
- Each tenant namespace is expected to have a provider-managed OTel Collector
service named
otel-collectoraccepting OTLP/HTTP on port4318, matching the framework telemetry default. - Applications emit traces and metrics via standard OpenTelemetry OTLP to the namespace collector. The provider collector or gateway assigns trusted tenant identity before forwarding data to shared backends.
- Applications log structured JSON to stdout/stderr. They should not use an OTel log exporter. The provider's Kubernetes log pipeline collects, enriches, and forwards pod logs.
- Application-controlled resource attributes are useful for service identity, version, and environment, but must not be trusted as tenant identity.
Every Job in the forced migration bundle must be idempotent.
- Setup Jobs must check existing state before creating or changing resources.
- Goose migration Jobs are idempotent because Goose records applied migrations
in
goose_db_version. - A Job rerun should either make the missing change or exit successfully when the desired state already exists.
- CloudNativePG bootstraps a dummy
bootstrapdatabase owned by the app role. This gives us a generatedsample-postgres-appsecret without making the app database itself a CNPG bootstrap artifact. - CNPG superuser access is enabled so the operator generates
sample-postgres-superuser. - Preview infra may replace CNPG with disposable Postgres, but the deployment environment must still provide DB credential secrets compatible with the shared migration and app bundles.
- Database creation Jobs connect with the generated superuser secret.
- Application workloads and schema migration Jobs connect with the generated app secret.
- The app role should not be a superuser and should not have
CREATEDB. - Application databases are created by setup Jobs and owned by the app role.
- Service-owned database migrations live with the owning service under
services/<service>/migrations. - Service-owned database access helpers should live with service
implementation code, such as
internal/<service>/db. - Each database should have its own migration Job in
deploy/migrations. - Each migration Job should connect to exactly one database.
- Migration images should layer service SQL migrations onto the shared
ghcr.io/adiom-data/components/goosemigratecomponent image. The component runs migrations packaged at the well-known/app/migrationsdirectory and receives only database connection details from the environment. - Migration Jobs should be deployed in an order where required databases and roles already exist. The migration runner should fail normally when the database is unavailable rather than hiding ordering problems with long in-process waits.
- Go HTTP/Connect services should use
framework/httpappfor server assembly. cmd/<name>packages should remain small entrypoints that call service code.cmd/<name>packages should own process environment parsing and pass typed config into internal packages. Internal service packages should not read env directly except for narrowly scoped helper binaries that are themselves drivers.- Long-running HTTP services should initialize framework runtime with
httpapp.Initand construct servers withruntime.NewServiceso telemetry providers and Connect instrumentation use the same process-level wiring. - Let
httpapp.Initown signal handling and useruntime.Context()for service lifetime and any setup that should stop with the process. - Construct outbound generated Connect clients with
runtime.ConnectHTTPClient()andruntime.ConnectClientOptions()so client RPC telemetry and trace propagation use the initialized framework providers. - Construct raw outbound HTTP clients with
runtime.HTTPClient()when request telemetry and propagation are desired. - Service implementation should live outside
cmd/, under the appropriate service or internal package. - Register generated Connect services through
httpapp.ConnectHandler. - Rely on framework-provided HTTP, Connect, logging, trace, and metric instrumentation instead of adding local middleware for the same signals.
- Register framework readiness checks for startup/configuration conditions that should remove a pod from service. Do not use readiness for transient dependencies the service can handle and recover from at request time.
- Kubernetes probes should use the framework gRPC health services exposed by
httpapp.
- The gateway validates tokens and forwards verified bearer credentials to the BFF/API.
- Gateway configuration lives with the gateway service wrapper under
services/gateway. - Gateway container images should be built from the pinned
ghcr.io/adiom-data/components/gatewaybase image. - The gateway should stay a validation and routing layer. Business logic, auth mediation, and BFF behavior belong behind the gateway in application services.
- Gateway routes should explicitly mark public paths, such as browser auth routes.
- Authenticated backend routes should forward the verified bearer credential to the API instead of minting a separate gateway-internal token.
- APIs should still verify forwarded bearer credentials before deriving user identity. The gateway is the routing enforcement point, but services should not treat unsigned JWT payloads as trusted input if they are reachable inside the cluster. Prefer verifier paths that use the auth issuer's metadata and JWKS rather than sharing private signing-key state with API handlers.
- Browser auth is owned by the auth service behind the gateway. In this sample the auth service runs in the same binary as the API service, but it lives in its own package because it has a separate service boundary.
- SPA auth flows should use the BFF browser auth endpoints under
/auth. Generic SPA OIDC clients are only appropriate if the app intentionally moves to browser-owned OIDC tokens. - SPA calls to application APIs should use generated Connect clients.
- SPAs should keep app bearer tokens in memory only. A small token manager may
cache the app token, parse its expiration, deduplicate
/auth/tokencalls, and clear local state on logout, but provider refresh tokens remain server-side in the DB-backed browser auth session. - Keep reusable SPA auth glue separate from app-specific API clients. Import
browser auth helpers from
@adiom-data/framework-web/authinstead of copying token-management code into the app. - The gateway validates app-issued tokens using the API auth issuer metadata.
- Browser OIDC is configured through Kubernetes secrets consumed by the auth service.
- Native/mobile clients should use provider-native auth, then call
adiom.auth.v1.AuthService/ExchangeCredentialwith a provider ID token. The service verifies the provider token against the configured allowed audiences and returns a short-lived app token. - Token signing keys must be stable across API pod restarts and replicas in any
shared environment.
AUTH_PRIVATE_KEY_BASE64is required and should be provided by a Kubernetes secret. - Browser OAuth state cookies are signed and encrypted. Their codec keys must
also be stable across API pod restarts and replicas, otherwise login and
callback requests can hit different pods and fail with
browserauth: invalid state. Configure these with frameworkbrowserauth.CookieStateKeysFromSeedBase64usingAUTH_STATE_KEY_BASE64; do not derive them from process-local random defaults in a replicated deployment. - Browser OAuth callbacks with missing, stale, or already-used state should use
the framework invalid-state handler to restart login instead of surfacing a
raw
browserauth: invalid stateerror page. AUTH_ISSUERis required and must be the issuer URL reachable by components that verify app tokens, such as the gateway.- The process should fail startup when required auth configuration is missing or invalid. It should not silently disable browser auth or generate ephemeral signing keys in shared deploys.
- Browser OAuth should pass
PROXY_REDIRECT_URLto the framework in preview environments so the provider uses a stable proxy callback while the app callback remains request-scoped state. Otherwise usePUBLIC_BASE_URLwhen configured. - If neither explicit redirect base is configured, browser auth should use the framework request-scoped redirect resolver to derive redirect URLs from forwarded host/proto headers. Auth requests should fail rather than use a fake fallback URL when those headers are unavailable. Production-like environments should prefer an explicit public base URL when one is available.
- Auth/session tables are service-owned schema. In this sample they live with the app database migrations because the API and auth services share one deployable binary and database.
- Logs and user-facing responses should not include secret values or raw dependency errors that may contain connection details. Prefer redacted operator logs and generic client-facing error messages.
- The API and gateway use framework-provided gRPC health services for Kubernetes probes.
- API readiness should not depend on transient Postgres availability. Database outages should be handled by request paths and recover when the database becomes available again.
- Additional health surfaces should be added only for integrations that cannot use the framework health services.
- Kubernetes resources intentionally omit
metadata.namespace. - HTTPRoutes intentionally omit
hostnames. - Environment-specific namespace and hostname binding should happen outside this sample.