ADR-0002: Platform Architecture¶
Status: Accepted Date: 2026-05-23 Ticket: BD-3.0
Context¶
Migration from AWS ECS to DigitalOcean Kubernetes (DOKS) is complete. All 3 tenants (oep-stg, mansety-prd, us-prd) are running on oep-prd-cluster with the full platform layer: Argo CD GitOps, ESO secret sync, Cilium Gateway API ingress, kube-prometheus-stack observability, and namespace network isolation. This ADR captures the architectural decisions made during BD-3 that shaped the current platform.
Decision¶
All locked decisions captured from the BD-3 grill rounds:
GitOps - Argo CD¶
- App-of-Apps pattern: one root
Applicationper Argo CD install, pointing atbootstrap/platform-apps/. Each platform concern is a separate childApplication.ApplicationSet(List generator) reserved for tenant workloads (BD-6.2). - Argo CD install: TF
helm_releaseinoep-infra/argocd.tf. Pinned chart version. TF is source of truth for chart version + values. Nobootstrap.sh. server.insecure=true: argocd-server listens on plain HTTP; Gateway terminates TLS once at the front. Avoids TLS-in-TLS chain.- Dex disabled: auth handled by Cloudflare Access OIDC (BD-3.14). No Dex, no GitHub OAuth for Argo.
- Argo CD RBAC:
policy.default = role:adminfor now (small team). Future tightening tracked as follow-up once team grows. - Argo CD exposure: ClusterIP + port-forward initially; HTTPRoute on shared Gateway after BD-3.7; CF Access SSO in BD-3.14.
Auth - Cloudflare Access¶
- ArgoCD: CF Access self-hosted app at
argocd.unipuka.app. Policy:email_domain = unipuka.comvia existing Google Workspace IdP (configured in BD-2.3). Argo trusts CF Access JWT viaoidc.configpointing at CF Access JWKS endpoint. - Grafana: same CF Access edge gate at
grafana.unipuka.app. Grafana consumesCf-Access-Authenticated-User-Emailheader via auth-proxy; auto-creates users withAdminrole. - Break-glass: built-in admin credentials retained in Doppler
basefor both services, accessible only via port-forward (bypasses CF Access). - DNS:
proxied=true(orange-cloud) forargocd.unipuka.appandgrafana.unipuka.appso CF Access intercepts at the edge. Same pattern asdocs.unipuka.app.
Sync Waves¶
Explicit argocd.argoproj.io/sync-wave annotations:
| Wave | Applications |
|---|---|
| 0 | ESO, ClusterSecretStores |
| 1 | TLS Secrets, ResourceQuota, LimitRange, NetworkPolicy, kube-prometheus-stack |
| 2 | Gateway, HTTPRoutes |
| 3 | Loki, OpenCost, Argo HTTPRoute |
Argo Application syncOptions (CRD-bearing charts)¶
Every Application whose chart ships CRDs (external-secrets, kube-prometheus-stack,
gateway-api, loki) carries:
Avoids metadata.annotations: Too long errors common with kube-prom CRDs.
Secret Sync¶
- Doppler is source of truth. ESO syncs into native K8s Secrets; pods consume via
envFrom. - One
ClusterSecretStoreper Doppler config (oep-stg,mansety-prd,us-prd). - No SOPS, no AGE keys, no encrypted blobs in Git.
- ESO chart:
external-secrets 2.5.0deployed via Argo Application directly from upstream chart repo (https://charts.external-secrets.io). ClusterSecretStores in separatecluster-secret-storesApplication (wave 1, ensures CRDs exist first). - See ADR-0001 for full secret management rationale.
- See external-secrets.md for usage + debugging.
TLS¶
- 3 wildcard Cloudflare Origin CA certs (one per zone:
*.unipuka.app,*.mansety.com,*.us-academy.net). 15-year validity. ECDSA/RSA-2048. - Stored in Doppler
baseasCF_ORIGIN_CERT_*/CF_ORIGIN_KEY_*. - ESO syncs into 3 K8s TLS Secrets in
gatewayns viatls-secretsApplication (wave 1). Uses tenant ClusterSecretStores (oep-stg/mansety-prd/us-prd) which inherit base keys. - Gateway listeners terminate TLS with these secrets. CF edge re-encrypts to origin.
- Rotation: reissue in CF dashboard -> update Doppler -> ESO syncs within 1h (or force).
- See tls-and-certs.md.
Gateway¶
- Shared
Gatewayingatewayns (Cilium Gateway API). One resource, multiple listeners: :80HTTP redirect to HTTPS:443per-tenant api hostname (x3) +argocd.unipuka.app+grafana.unipuka.app:6001per-tenant ws hostname (x3)allowedRoutes.namespaces.selectorwith labelgateway.unipuka.app/tenant: <tenant>enforces cross-tenant hostname isolation.- Cilium Gateway API: GatewayClass
cilium(verifiedAccepted=True). CRDs v1.2.1 pre-installed on cluster (6 CRDs confirmed at BD-3.0). - DO LB provisioned automatically by Cilium.
NetworkPolicy¶
- Default-deny ingress + egress per tenant ns.
- Named allow rules: intra-ns, DNS (kube-dns selector), SaaS egress 443/tcp, managed DB
ipBlock (MySQL + Redis private IPs), ingress from
gatewayns. - DNS allow uses
namespaceSelector+podSelector(kube-dns label) on UDP/TCP 53 - survives Pod IP churn. - Broad 443/tcp SaaS egress now; Cilium L7 FQDN allow deferred to later phase.
Quotas¶
- Default per tenant ns: CPU req=4, mem req=8Gi, pods=30.
- LimitRange container defaults: CPU 100m req / 500m limit, mem 128Mi req / 512Mi limit.
- Per-tenant override via Helm values
.Values.tenants.<name>.quota.
Observability¶
- Monitoring node pool: dedicated
monitoring-pool(s-2vcpu-4gb, min/max=1), taintdedicated=monitoring:NoSchedule. All monitoring Pods scheduled here. - kube-prometheus-stack: Prometheus + Grafana + kube-state-metrics + node-exporter.
Prometheus PVC reclaim policy =
Retain. - Loki: simple-scalable mode, DO Spaces backend (3 per-env buckets in
ams3). Retention: 7d staging, 30d prod. Promtail/Alloy DaemonSet ships logs withX-Scope-OrgIDtenant header. - OpenCost: port-forward only by default; wired to in-cluster Prometheus.
- Grafana PV reclaim policy =
Retain. Loki PV same.
Repo Layout¶
unipuka-infra-ops/
bootstrap/
root-application.yaml
appprojects.yaml
platform-apps/ # one Application YAML per concern
platform/
charts/
external-secrets/
tls-secrets/
resource-quotas/
network-policy/
kube-prometheus-stack/
gateway/
loki/
opencost/
unipuka-infra-do/oep-infra/
argocd.tf
dns_platform.tf
cf_access_apps.tf
monitoring_pool.tf
spaces_logs.tf
Consequences¶
Positive:
- Full GitOps: all platform state driven by Git commits, Argo auto-syncs.
- Tenant isolation: namespace-scoped NetworkPolicy + Gateway listener selectors.
- Zero-trust access: CF Access gates all operator UIs; no VPN required.
- Observability: metrics (Prom), logs (Loki), cost (OpenCost) from day one.
- Break-glass preserved for every auth-gated service.
Negative / Trade-offs:
- CF Access adds browser-to-origin round-trip via Cloudflare edge for every UI request.
policy.default: role:adminis coarse - tightening deferred.- Loki Spaces keys pre-dated bucket TF management; keys scoped to buckets created separately from TF on first bootstrap.
server.insecure=truemeans argocd-server is HTTP internally; Gateway holds the only TLS boundary.
Alternatives Considered¶
- Dex for Argo auth: rejected - adds operational complexity; CF Access reuses existing
Google Workspace IdP already configured for
docs.unipuka.app. - Per-tenant Gateway: rejected - one shared Gateway is simpler; listener selectors provide isolation without multiplying LBs.
- Loki monolithic mode: rejected - simple-scalable gives independent read/write path; lower memory footprint vs distributed.
- Sealed Secrets / SOPS: rejected per ADR-0001 (Doppler + ESO).
BD-4 reuse note¶
BD-4 (laravel-app Helm chart) reuses the BD-3 platform without modification. No new Argo CD Applications, namespaces, ClusterSecretStores, or Gateway listeners are added in BD-4. The chart is deployed into existing namespaces (oep-stg, mansety-prd, us-prd) by BD-6.2 ApplicationSet.
DOCR Basic tier: Registry upgraded from Starter to Basic to support image storage and tag retention requirements. See registry.tf and promote-image.md for operational details.