ADR-005: Modular Deployment Profiles
On this page
Context
Canopy is designed to eventually serve multiple jurisdictions beyond Georgia DHS. A state may wish to deploy only SNAP, or only SNAP and TANF, without standing up Medicaid/CHIP, CAPS, or WIC infrastructure. A tribal nation operating a tribal TANF program may need only TANF and child care. An agency undergoing phased modernization may need to introduce programs incrementally alongside a legacy system.
The existing service topology includes 19 independent services plus 2 BFF services. All can be deployed on a single machine or distributed across a cluster. However, there is currently no formal declaration of:
-
Which services are required for a given program to function
-
Which services are optional and what happens when they are absent
-
How Docker Compose or Kubernetes manifests should be constructed for partial deployments
-
How dependent services should behave when an optional peer is unreachable
This creates accidental coupling: a jurisdiction deploying SNAP should not need to explain why it has no canopy-medicaid container.
The OpenStack project was identified by the project owner as the appropriate architectural model. OpenStack’s "big tent" governance — independent service projects, a shared identity layer, and optional composition — is the reference point for this decision.
Decision
1. Deployment profiles
Every program has a defined minimum required service set: the smallest set of services needed for that program to accept applications, determine eligibility, issue benefits, and meet federal certification requirements.
| Deployment profile | Required services | Notes |
|---|---|---|
|
canopy-auth (Keycloak), canopy-persons, canopy-applications, canopy-rules, canopy-eligibility, canopy-snap, canopy-verification, canopy-notices, canopy-appeals, canopy-security, canopy-enrollment, canopy-renewals, canopy-reporting, canopy-web |
canopy-portal (applicant-facing) is optional for UAT; add for go-live |
|
Same as |
TANF has no EBT issuance — canopy-enrollment is lighter |
|
Union of snap-only and tanf-only; canopy-enrollment handles both |
Categorical eligibility cross-reference requires both services reachable |
|
canopy-auth, canopy-persons, canopy-applications, canopy-rules, canopy-eligibility, canopy-medicaid, canopy-verification, canopy-notices, canopy-appeals, canopy-security, canopy-renewals, canopy-reporting, canopy-exchange, canopy-web |
canopy-exchange required for FFE account transfers (ACA §1413) |
|
canopy-auth, canopy-persons, canopy-applications, canopy-rules, canopy-caps, canopy-eligibility, canopy-notices, canopy-appeals, canopy-security, canopy-enrollment, canopy-renewals, canopy-reporting, canopy-web |
No IEVS or FTI; simpler compliance posture |
|
canopy-auth, canopy-persons, canopy-applications, canopy-rules, canopy-wic, canopy-eligibility, canopy-notices, canopy-appeals, canopy-security, canopy-enrollment, canopy-reporting, canopy-web |
Adjunctive eligibility from SNAP/Medicaid is optional (can fall back to income test) |
|
All 21 services |
Georgia DHS production target |
2. Docker Compose profiles
Docker Compose v2 profiles are used to implement deployment selections.
Every service has a profiles: key in docker-compose.yml.
The profiles correspond to the table above.
# SNAP-only deployment
COMPOSE_PROFILES=snap-only docker compose up -d
# Full deployment (default in devstack)
COMPOSE_PROFILES=full docker compose up -d
# SNAP + TANF
COMPOSE_PROFILES=snap-only,tanf-only docker compose up -d
Services that are in every profile (universal dependencies) declare all profiles:
services:
keycloak:
profiles: [snap-only, tanf-only, snap-tanf, medicaid-chip, caps-only, wic-only, full]
canopy-persons:
profiles: [snap-only, tanf-only, snap-tanf, medicaid-chip, caps-only, wic-only, full]
Program-specific services declare only their relevant profiles:
canopy-snap:
profiles: [snap-only, snap-tanf, full]
canopy-medicaid:
profiles: [medicaid-chip, full]
3. Graceful degradation for optional services
Infrastructure services that are optional in some profiles (canopy-exchange, canopy-portal) must not crash other services when absent. Required services must not have hard startup dependencies on optional services.
Rules for inter-service calls:
-
Required-to-required calls (e.g., canopy-eligibility → canopy-snap): Circuit breaker with retry. If the required peer is unreachable, fail fast with a 503 and log an alert. This is a misconfigured deployment.
-
Required-to-optional calls (e.g., canopy-enrollment → canopy-exchange for FFE notification): The calling service uses a capability flag. If the optional service is not configured (empty
CANOPY_EXCHANGE_URLenv var), the call is silently skipped and atracing::debug!()message is emitted. No error is returned. -
Optional-to-required calls (e.g., canopy-portal calling canopy-persons): The optional service simply is not deployed; no graceful degradation needed.
Capability flags are set via environment variables:
# In snap-only deployments, these are unset or empty:
CANOPY_EXCHANGE_URL=
CANOPY_PORTAL_URL=
CANOPY_TANF_URL=
CANOPY_MEDICAID_URL=
canopy-api’s `bootstrap() function reads these flags and excludes unconfigured service clients from the AppState.
Program service router slots that require an unconfigured peer return 501 Not Implemented with a Problem Details body explaining the missing service.
4. Health and metrics reflect profile
The /healthz endpoint of each service reports only the dependencies that are configured for the active profile.
A SNAP-only canopy-eligibility instance does not report canopy-medicaid as a health dependency.
5. Kubernetes and production deployments
Docker Compose profiles are for development and single-node deployments. For Kubernetes, each deployment profile corresponds to a Helm values file:
helm install canopy ./charts/canopy \
--values charts/canopy/profiles/snap-only.yaml
Helm chart design is out of scope for the current phase but must be compatible with this profile model. Profile names are the authoritative mapping between Docker Compose and Kubernetes deployments.
Rationale
Why Docker Compose profiles instead of separate compose files?
A single docker-compose.yml with profiles is easier to maintain than multiple compose files that diverge over time.
Profile definitions are co-located with service definitions, so adding a new service to a profile is one line.
Separate compose files would require constant synchronization.
Why environment-variable capability flags instead of build-time features?
Rust feature flags would require different binary builds per profile — expensive and error-prone. Environment variables allow the same binary to adapt at runtime. This matches the 12-factor app model already used throughout Canopy.
Why define minimum required sets at the ADR level?
Without a formal declaration, every team member makes a different judgment about what is "really required." This leads to integration tests that pass in one profile but fail in another, and UAT environments that differ from production. Formalizing the minimum required sets here makes profile testing a first-class concern.
Consequences
Benefits
-
Any jurisdiction can deploy any program subset on day one
-
SNAP UAT does not require standing up Medicaid or TANF infrastructure
-
Federal auditors reviewing a SNAP-only deployment see only SNAP-relevant services
-
Incremental adoption path: agencies start with one program, add others without rebuilding
-
Smaller attack surface in single-program deployments
Costs and risks
-
Graceful degradation code paths must be tested — they are easy to introduce and hard to notice when broken
-
docker-compose.ymlbecomes larger (all services + all profile tags) -
Services must be careful not to assume the presence of optional peers — this requires discipline in pull requests
-
Capability flag logic adds conditional paths to
AppStateand bootstrap that must be kept current