Enterprise Kubernetes for a critical payments platform on AWS
A multi-region, active-active design on Amazon Elastic Kubernetes Service for payment authorization, settlement, account-to-account transfer rails, and real-time messaging, built for zero data loss on the core ledger and continuous availability across two Canadian regions.
Contents
Part I, Reference architecture
- Executive summary
- Context and scope
- Functional requirements
- Non-functional requirements
- Requirements traceability
- Architecture principles
- Reference architecture overview
- Architecture decision records
- Failure modes and runbooks
- Capacity and cost optimization
- Compliance mapping
- Risks and open items
- References
Part II, Detailed design
- Purpose and conventions
- Organization and account baseline
- Network configuration
- EKS cluster configuration
- Node strategy and Karpenter
- Kubernetes platform configuration
- Tier-zero ledger configuration
- Tier-one data services
- Identity, access, and keys
- Security services
- CI/CD configuration
- Observability stack
- Resilience testing
Part I, Reference architecture
1. Executive summary
This document describes a production-grade architecture for a critical payments platform running on Amazon Elastic Kubernetes Service across two Canadian AWS regions, Canada Central in Montreal and Canada West in Calgary. The platform carries a full product portfolio of payment authorization, settlement, account-to-account transfer rails, and real-time messaging. The defining constraint is that the core payment and settlement ledger must lose no committed data under any single failure, which is a recovery point objective (RPO) of zero, while every product remains continuously available, which is a recovery time objective (RTO) of zero at the product level. The target availability is 99.999 percent.
The hardest problem in the design is achieving a literal zero recovery point objective for the ledger across two regions while keeping all data inside Canada. The analysis below shows that the managed service which would normally solve this, Amazon Aurora DSQL in a multi-region configuration, cannot be used here because its Canadian regions are offered only as single-region clusters and are not part of any multi-region set, and because its multi-region quorum requires a third region that would sit outside Canada. Amazon Aurora Global Database, the usual cross-region option, replicates asynchronously and therefore cannot offer a literal zero recovery point objective. With only two Canadian regions there is also no third failure domain for an automatic quorum to survive a full region loss.
The resolution is a cell-based, regionally owned ledger with synchronous cross-region commit. The ledger keyspace is divided into cells, each cell is owned by one region, and every commit is made durable in the partner region before it is acknowledged. This delivers a true zero recovery point objective across a complete region loss. The unavoidable cost, which the design contains and makes explicit, is that the inter-region round trip is added to commit latency on mutating transactions only, and that during a cross-region network partition one side must stop writing to preserve consistency and durability. A witness placed in an on-premises Canadian data center adjudicates that partition so the surviving region continues without data loss and without split-brain.
Around this core, the platform uses a private-only network posture, full account-level isolation per environment and per region, a defense-in-depth security model that minimizes the cardholder data environment, image signing with admission-time verification, GitOps delivery with region-by-region promotion, and a 99.999 percent service level objective governed by an error budget. The remainder of the document records the requirements, the principles, the reference architecture, and the decisions that produced it, each with the alternatives that were considered and rejected.
2. Context and scope
The platform is a regulated financial system. It processes high transaction volumes, integrates with partner banks and a card switch, and exchanges settlement and reconciliation files with existing systems of record in an on-premises data center. Access is private only. There is no public internet ingress to any workload. Inbound traffic arrives over AWS Direct Connect private virtual interfaces and the AWS backbone.
In scope
- The compute platform on Amazon Elastic Kubernetes Service, including cluster topology, node strategy, and tenancy.
- The data tier for the ledger and for the supporting products, including the cross-region resilience model.
- Network connectivity, segmentation, and the private ingress and controlled egress model.
- Security and Payment Card Industry Data Security Standard (PCI DSS) alignment, secrets, and key management.
- Continuous integration and continuous delivery, GitOps, and progressive multi-region rollout.
- Observability, service level objectives, and the failure and recovery runbooks.
Out of scope
- The internal business logic of each product beyond what is needed to size resilience and latency.
- The on-premises core banking systems themselves, which are treated as integration endpoints.
- Card scheme certification activities, which depend on the specific scheme and acquirer.
A note on the regions
Canada has exactly two AWS regions, Canada Central and Canada West. Several resilience patterns that are routine in the United States or Europe, where three or more regions form a set, are not available here. This single fact shapes the data tier more than any other and is treated explicitly throughout.
3. Functional requirements
The platform delivers four product families. Each is summarized below with the behavior that the architecture must support.
3.1 Payment authorization
- Accept authorization requests from partner channels and the card switch over private connectivity, and return an approve or decline decision within a tight latency budget.
- Apply risk and fraud scoring using cached features and rules before the ledger is touched.
- Tokenize the primary account number (PAN) at the boundary so that clear card data does not flow into the application or the ledger.
- Record an authorization hold against the account ledger with strong consistency, so that no two requests can double-spend the same available balance.
3.2 Settlement
- Process clearing and settlement against the same ledger as authorization, so that balances reflect a single source of truth.
- Produce settlement and reconciliation outputs for exchange with the on-premises systems of record.
- Guarantee that a settled transaction is never lost, which ties settlement to the zero recovery point objective tier.
3.3 Account-to-account transfer rails
- Support real-time account-to-account transfers in the style of domestic transfer rails, including request to pay and immediate credit.
- Maintain idempotency so that a retried transfer is never applied twice.
- Tolerate a short recovery point objective of one to five minutes, which allows an asynchronous cross-region data strategy for this product family.
3.4 Real-time messaging
- Provide a low-latency messaging backbone for events such as authorization outcomes, settlement notifications, and status updates.
- Deliver events at least once with consumer-side idempotency, and preserve ordering where a product requires it.
- Replicate the event stream across regions so that consumers continue after a region loss within the one to five minute objective.
4. Non-functional requirements
The non-functional requirements are the binding constraints. They are stated as targets and are traced to the design in the next section.
| ID | Category | Requirement | Target |
|---|---|---|---|
| N1 | Durability, core | No loss of committed payment and settlement data under any single failure, including a full region loss | Recovery point objective of zero |
| N2 | Durability, other | Bounded data loss for transfer rails and messaging under a region loss | Recovery point objective of 1 to 5 minutes |
| N3 | Availability | Continuous service at the product level, including during a region loss | Recovery time objective of zero, always on |
| N4 | Service level | Platform availability over the rolling year | 99.999 percent |
| N5 | Consistency | Strong consistency for all business transactions on the ledger | Snapshot isolation or stronger, no double spend |
| N6 | Latency | Authorization decision within budget at high volume | Tight per-decision budget, cross-region cost paid once at commit |
| N7 | Data residency | All data, including quorum metadata, remains in Canada | Strict, enforced by policy |
| N8 | Security | Payment Card Industry Data Security Standard alignment, best-practice posture | PCI DSS, defense in depth, minimized scope |
| N9 | Network posture | No public ingress, controlled egress | Private only, allow-list egress |
| N10 | Separation | Environment and regional isolation | Full account separation, production, non-production, performance |
| N11 | Scalability | Absorb peak volumes and bursts without manual intervention | Elastic, cost aware, no graphics processing units |
| N12 | Recoverability | Region loss is served from the other region by definition | Tested failover, automated where safe |
| N13 | Observability | Full signal coverage, audit, and service level governance | Metrics, traces, logs, error budget |
5. Requirements traceability
Each non-functional requirement maps to one or more decision records, which are detailed in section 8. The mapping makes it possible to see why each requirement is met and where to look if a requirement changes.
| Req | Met by | How it is satisfied |
|---|---|---|
| N1 | ADR-01, ADR-02, ADR-05 | Cell-based ownership with synchronous cross-region commit and a Canadian witness |
| N2 | ADR-03 | Aurora Global Database, Amazon MSK replication, Redis global datastore |
| N3 | ADR-01, ADR-04, ADR-05 | Active-active cells, health-based routing, fast cell adoption |
| N4 | ADR-04, ADR-14 | Active-active topology and a 99.999 percent objective with an error budget |
| N5 | ADR-02 | Single-writer-per-cell ledger with synchronous durability |
| N6 | ADR-02, ADR-04 | Local execution with the cross-region cost confined to commit |
| N7 | ADR-05, ADR-06 | Canadian witness and a service control policy that denies other regions |
| N8 | ADR-11, ADR-12 | Defense in depth, scope minimization, signed images, managed keys |
| N9 | ADR-09 | Private-only ingress and allow-list egress through a network firewall |
| N10 | ADR-06, ADR-07 | Account per environment and region, namespace isolation within clusters |
| N11 | ADR-08 | Managed and self-managed node scaling on Graviton with Spot for burst |
| N12 | ADR-05, ADR-13 | Tested failover runbooks and progressive multi-region delivery |
| N13 | ADR-14 | Metrics, traces, logs, central security information and event management |
6. Architecture principles
Correctness before convenience
On the core ledger, never trade away consistency or durability for latency or operational ease. Make the cost of correctness explicit and contain it.
Keep data in Canada
Every byte of business data and every piece of quorum metadata stays in Canada. Residency is enforced by policy, not by convention.
Private by default
No public ingress. Egress is denied unless explicitly allowed. Identity is established before any routing decision is made.
Minimize the blast radius
Isolate by account, by region, by cell, and by namespace. A failure or a compromise is contained to the smallest possible scope.
Everything is reconciled from Git
Cluster and application state is declared in Git and reconciled continuously. Drift is detected and corrected. Promotion is auditable.
Test resilience rather than assume it
Failover and partition behavior are exercised regularly with fault injection. A failover path that is never exercised should be treated as broken until proven otherwise.
7. Reference architecture overview
Both regions are active. Amazon Route 53 directs each caller to the nearest healthy region using latency-based routing with health checks. Within each region a private Amazon Elastic Kubernetes Service cluster runs the product workloads behind an internal load balancer, with no public load balancers anywhere. The data tier sits outside the cluster as managed services in private subnets, reached over virtual private cloud (VPC) interface endpoints. The two regions are joined by AWS Cloud WAN, and AWS Direct Connect links both regions to the on-premises data center, which also hosts the ledger witness.
The remainder of the document works inward from this picture. Section 8 records the decisions in order, starting with the resilience model and the data tier, which are the decisions that everything else depends on.
8. Architecture decision records
Each record states the context, the decision, the alternatives that were considered and rejected, and the consequences. The records are ordered so that foundational decisions appear before the decisions that depend on them.
Tiered resilience model
AcceptedContext
The products do not all share the same durability need. Payment authorization and settlement must lose nothing, while transfer rails and messaging can tolerate a small bounded loss. Treating every product as if it needed a literal zero recovery point objective would impose cross-region commit latency on workloads that do not require it, and would inflate cost and complexity.
Decision
Split the platform into two durability tiers. Tier zero covers payment authorization and the settlement ledger and carries a recovery point objective of zero. Tier one covers transfer rails and real-time messaging and carries a recovery point objective of one to five minutes. Both tiers carry a product-level recovery time objective of zero, meaning the service remains available through a region loss. The tier boundary is a first-class design seam that determines which data strategy each workload uses.
Alternatives considered
Consequences
The data tier decisions divide along the tier boundary. Tier zero uses synchronous cross-region durability, recorded in ADR-02. Tier one uses asynchronous managed replication, recorded in ADR-03. Workload placement and routing must respect the tier of the data they touch.
Tier-zero data tier, cell ownership with synchronous cross-region commit
AcceptedContext
A literal zero recovery point objective across a full region loss means that a committed transaction must be durable in a second region before it is acknowledged. With only two Canadian regions and strict residency, the usual managed answers do not apply, as the rejected alternatives below show. The design must therefore provide synchronous cross-region durability while keeping the ledger writable in both regions for availability and throughput.
Decision
Partition the ledger keyspace by a consistent hash of the account key into cells, and assign each cell a single home region. Canada Central owns one half of the cells and Canada West owns the other half, so both regions are active and no two regions ever write the same key at the same time. Each cell runs on Amazon Aurora PostgreSQL with synchronous replication, where the primary does not acknowledge a commit until a synchronous standby in the partner region has persisted it. In PostgreSQL terms this is synchronous_commit = remote_apply with a synchronous standby set that includes the partner region. A local Multi-AZ replica provides zero data loss within the region, and the partner-region standby provides zero data loss across a region loss. On loss of a region, the surviving region promotes the partner-region standby for the failed region cells and adopts them, serving the full keyspace. Because the standby already holds every committed transaction, the recovery point objective is zero and the recovery time objective is the adoption time, which is driven to seconds.
Alternatives considered
Consequences
The platform accepts that mutating transactions pay the inter-region round trip at commit. Reads and the entire execution phase stay local, so the cost is spent once per write rather than per statement. The single-writer-per-cell rule removes cross-region write conflicts by construction. The partition behavior and the witness are handled in ADR-05. Operating synchronous replication for a tier-zero ledger is a deliberate operational commitment that the runbooks in section 9 support.
Tier-one data tier, asynchronous managed replication
AcceptedContext
Transfer rails and messaging tolerate a recovery point objective of one to five minutes. They need cross-region continuity without the latency cost of synchronous commit.
Decision
Use Amazon Aurora Global Database for relational state in the tier-one products, with its asynchronous replication and managed fast failover. Use Amazon Managed Streaming for Apache Kafka (Amazon MSK) with cross-region replication for the event backbone. Use Amazon ElastiCache for Redis with a global datastore for cross-region cache. Each of these comfortably meets the one to five minute objective and avoids any synchronous cross-region path.
Alternatives considered
Consequences
Consumers must be idempotent and tolerate at-least-once delivery, which is already required by the messaging product. A region loss can drop at most the replication-lag window, which is held within the objective and is acceptable for these products.
Active-active cell topology and global traffic management
AcceptedContext
Both regions must serve traffic to meet the always-on objective and to use capacity efficiently, while honoring the single-writer-per-cell rule from ADR-02.
Decision
Run both regions active. Amazon Route 53 performs latency-based routing with health checks so that callers reach the nearest healthy region. A smart client and a cell router resolve the home region for each account key. A request that lands in a region which does not own the target cell is steered to the owning region over the core network. On a health-check failure, Route 53 withdraws the unhealthy region and all traffic flows to the survivor, which by then has adopted the failed cells.
Alternatives considered
Consequences
Routing must be cell-aware, and the cell map is itself a small piece of strongly consistent state that the router consults. The end-to-end request path, including where the cross-region cost appears, is shown below.
Partition and failover policy
AcceptedContext
Strong consistency, a zero recovery point objective, and a network partition cannot all hold at once for the same data. During a cross-region partition the platform must choose, per cell, between continuing to write and preserving zero loss. A naive choice risks either data loss or a split-brain in which both regions write the same cells and diverge.
Decision
Place a lightweight witness and arbiter in the on-premises data center, which is a third failure domain inside Canada reached over AWS Direct Connect. The witness holds journal metadata only and never holds cardholder data. The synchronous standby set is configured so that a local Availability Zone replica and the partner-region standby together form the durability quorum, which means a local zone fault does not block writes. On a true cross-region partition, the witness adjudicates which side retains ownership of each contested cell. The side that loses the adjudication stops writing those cells until the partition heals, which preserves zero loss and prevents split-brain. The side that wins continues to serve.
Alternatives considered
Consequences
A cross-region partition reduces write availability for the cells that lose adjudication, which is the correct trade for a system that must never lose committed money. The witness is a critical dependency and is itself made redundant within the data center. Partition handling is exercised by fault injection on a schedule.
Cluster separation and multi-account landing zone
AcceptedContext
The platform requires full separation across environments and regions, strong blast-radius control, and residency enforcement. The separation model was selected after weighing namespace-only, cluster-per-environment, and account-per-environment isolation.
Decision
Adopt a multi-account landing zone under AWS Organizations. Each environment and each region runs in its own dedicated account, so production in Canada Central, production in Canada West, the performance environment, and the non-production environments are separate accounts. Foundational accounts carry security tooling and log archive, network and shared connectivity, and shared platform services such as the container registry and the signing keys. Organization-wide service control policies enforce guardrails, including a policy that denies any region outside Canada Central and Canada West so that residency is structural rather than procedural.
Alternatives considered
Consequences
Account sprawl is managed with automation and a consistent baseline applied to every account. Cross-account access is brokered with least privilege and short-lived sessions. The account topology is shown below.
Tenancy and namespace isolation within clusters
AcceptedContext
Within a production cluster, several product domains run side by side. They must be isolated from one another, and the cardholder data environment must be isolated most strictly of all.
Decision
Isolate product domains by namespace, with a default-deny network policy, per-namespace resource quotas and limit ranges, and pod security standards enforced at admission. The cardholder data environment runs on dedicated nodes, separated from non-cardholder workloads, so that the most sensitive components do not share a host with anything out of scope. Service-to-service authorization is enforced in the mesh in addition to network policy, so that identity and not only network reachability governs access.
Alternatives considered
Consequences
Scheduling is constrained by node pools and taints, which is acceptable and is handled by the compute strategy in ADR-08.
Compute strategy
AcceptedContext
The platform needs fast, cost-aware scaling for bursty payment traffic, hardened nodes for a regulated environment, and the ability to run host-level security agents. No graphics processing units are required.
Decision
Use a mixed model. A managed baseline carries the steady-state tier-zero workloads on reserved capacity for predictable cost and performance, on AWS Graviton instances for price and energy efficiency. Self-managed Karpenter provisions elastic capacity for tier-one and bursty workloads, mixing on-demand and Spot capacity, again favoring Graviton. Self-managed Karpenter is chosen over fully managed Auto Mode for the regulated fleet because it allows custom machine images and host-level agents that the security baseline requires, while Auto Mode remains a candidate for lower-sensitivity clusters. Nodes run a hardened, minimal operating system from signed images. Short-lived batch jobs run on AWS Fargate for additional isolation. Karpenter brings new nodes online in roughly forty-five to sixty seconds, which keeps headroom small without starving bursts.
Alternatives considered
Consequences
The platform runs two provisioning models and must keep their machine images and policies consistent. The single-region cluster detail is shown below.
Networking and connectivity
AcceptedContext
The platform is private only, spans two regions, and integrates with an on-premises data center. It must route between regions and to on-premises without any public exposure, and it must control egress tightly.
Decision
Join the regions with AWS Cloud WAN as the core network, segmented so that production and non-production never share a segment. Connect both regions to the on-premises data center with redundant AWS Direct Connect links from separate locations. Expose services through internal load balancers and the Kubernetes Gateway interface with AWS PrivateLink, with no public load balancers. Reach AWS services over virtual private cloud interface endpoints. Control all egress through AWS Network Firewall with a strict domain allow-list, so that no workload subnet has general internet access. Log all flows.
Alternatives considered
Consequences
New external dependencies require an explicit allow-list change, which is a small operational cost that buys a strong containment guarantee.
Service mesh
AcceptedContext
The platform needs mutual authentication between services, fine-grained authorization, and traffic control for progressive delivery, without imposing a heavy sidecar tax on every pod.
Decision
Adopt Istio in ambient mode with mutual Transport Layer Security (mTLS) set to strict, so that every service-to-service call is authenticated and encrypted by identity. Authorization policies govern which service may call which, in addition to network policy. The mesh provides the traffic shifting used by progressive delivery in ADR-13.
Alternatives considered
Consequences
Mesh identity becomes a control that auditors can rely on. Operating the mesh is a platform responsibility owned by the central team.
Security posture and PCI DSS alignment
AcceptedContext
The platform handles card data and must align with the Payment Card Industry Data Security Standard while keeping the scope as small as practical.
Decision
Apply defense in depth across trust zones, from the untrusted partner edge through the ingress and application zones to the cardholder data environment, which is the innermost zone on dedicated nodes. Tokenize the primary account number at the boundary so that clear card data does not propagate inward. Encrypt data at rest with customer-managed keys and in transit with mutual Transport Layer Security inside the mesh and Transport Layer Security at the edge. Establish workload identity with EKS Pod Identity so that there are no standing credentials on nodes. Verify image signatures at admission. Detect at runtime with Amazon GuardDuty for the cluster and a runtime sensor, and scan images and hosts with Amazon Inspector. Provide node access only through AWS Systems Manager, with no secure shell and no bastion. Break-glass access is time-boxed and logged.
Alternatives considered
Consequences
The zoning and the tokenization boundary are load-bearing controls that must be verified continuously. The zone model and its mapping to the standard are shown below.
Secrets and key management
AcceptedContext
The platform needs managed secrets and managed encryption keys, with no secrets baked into images or held as long-lived credentials.
Decision
Store secrets in AWS Secrets Manager and synchronize them into the cluster with the External Secrets operator, so that secrets are never committed to Git. Encrypt data with AWS Key Management Service (KMS) using customer-managed keys, with per-tenant keys where isolation requires it. Sign container images with cosign using a key held in AWS Key Management Service on the elliptic-curve type, and verify those signatures at admission so that only signed and approved images run.
Alternatives considered
Consequences
Key and secret access is governed by identity and is fully audited. Rotation is a managed operation rather than a manual one.
Continuous integration, delivery, and GitOps
AcceptedContext
Delivery must be auditable, must prevent unsigned or unscanned code from reaching production, and must promote changes region by region with the ability to roll back automatically.
Decision
Build and test in GitHub Actions, authenticating to AWS with OpenID Connect so that no static cloud keys exist in the pipeline. Build images with a rootless builder, run unit and contract tests, static application security testing, and software composition analysis, produce a software bill of materials, scan with Amazon Inspector, then sign with cosign and push to the registry. Deliver with ArgoCD in a pull-based GitOps model, where a change to a configuration repository updates the image digest and ArgoCD reconciles it. Verify the image signature, provenance, and policy at admission. Use progressive delivery with canary analysis and mesh traffic shifting. Promote in waves across separate accounts, from non-production to performance to production in Canada Central and then to production in Canada West, with bake time and health gates between waves. A failed gate halts the wave, reverts the change in Git, and reconciles the previous signed digest.
Alternatives considered
Consequences
Every production change is traceable to a signed commit, a signed image, and an approved Git change. The delivery flow is shown below.
Observability and service level objectives
AcceptedContext
A 99.999 percent target leaves a very small error budget, so the platform must detect and respond to problems quickly and must govern change against the budget.
Decision
Collect metrics with Prometheus and aggregate across regions with Thanos for a global view. Instrument services with OpenTelemetry for distributed tracing. Visualize with Grafana. Ship security-relevant logs to a central security information and event management system with immutable retention. Define service level objectives per product, track an error budget, and tie the budget to change policy so that releases slow when the budget is spent. Alert on symptoms that matter to customers rather than on raw resource metrics alone.
Alternatives considered
Consequences
The error budget becomes a shared currency between reliability and delivery, which keeps the 99.999 percent target honest rather than aspirational.
9. Failure modes and runbooks
The table records the failure scenarios the platform is designed to survive, how each is detected, the response, and the resulting recovery point and recovery time for the core tier.
| Scenario | Detection | Response | RPO | RTO |
|---|---|---|---|---|
| Single node or pod loss | Liveness and readiness probes, scheduler | Reschedule on healthy nodes, Karpenter provisions replacements | Zero | Seconds |
| Availability Zone loss | Health checks, replica heartbeats | Traffic shifts to remaining zones, Multi-AZ replica serves, no cross-region action | Zero | Seconds |
| Full region loss | Route 53 health checks, ledger heartbeats | Surviving region adopts the failed cells by promoting the synchronous standby, Route 53 withdraws the region | Zero | Seconds to a minute |
| Cross-region partition | Replication and witness signals | Witness adjudicates ownership per cell, the losing side pauses writes on contested cells | Zero | Write pause on contested cells until heal |
| Partner standby unreachable | Synchronous replication timeout | Local Availability Zone replica keeps the quorum, alert raised, policy in ADR-05 applies | Zero | None for local faults |
| Witness loss | Witness heartbeat | Redundant witness instance takes over, no write impact while a region is healthy | Zero | None |
| Bad release | Canary analysis, service level objective gates | Wave halts, Git change reverts, ArgoCD reconciles the previous signed digest | Zero | Minutes, contained to one wave |
9.1 Runbook, region loss and cell adoption
- Route 53 health checks mark the failed region unhealthy and stop sending it traffic.
- The cell router marks the failed region cells for adoption and points them at the surviving region.
- The surviving region promotes its synchronous standby for those cells to writer. Because the standby holds every committed transaction, no data is lost.
- The surviving region now serves the full keyspace. Capacity scales up under Karpenter to absorb the additional load.
- When the failed region returns, it is rebuilt from the survivor, resynchronized, and cells are rebalanced back during a low-traffic window.
9.2 Runbook, cross-region partition
- Replication stalls and the witness observes that the two regions cannot see each other.
- The witness adjudicates ownership of each contested cell so that only one side keeps writing it.
- The side that loses adjudication pauses writes on those cells and continues to serve reads and the cells it still owns.
- When the partition heals, the paused cells resynchronize and writing resumes. No committed transaction is lost on either side.
10. Capacity and cost optimization
An always-on, active-active platform across two regions costs more than a single-region system, and the design makes that cost deliberate rather than accidental. The optimization tower below moves from the largest levers to the smallest.
| Lever | Approach | Effect |
|---|---|---|
| Processor choice | AWS Graviton across baseline and elastic capacity | Lower price and energy per unit of work than comparable instances |
| Purchase model | Reserved capacity or savings plans for steady-state tier zero, on-demand for the rest | Discounts the predictable baseline without locking in burst |
| Spot for burst | Spot capacity for tier-one and stateless burst under Karpenter | Large savings on interruptible, horizontally scalable work |
| Just-in-time scaling | Karpenter provisions in roughly forty-five to sixty seconds and consolidates idle nodes | Small headroom, less idle capacity, automatic bin-packing |
| Right-sizing | Continuous review of pod requests against real usage | Prevents over-provisioning that no autoscaler can correct |
| Region-loss capacity | Each region runs lean and relies on rapid scale-up on failover rather than carrying a full idle double | Avoids paying for a permanently idle second copy while still meeting the always-on objective |
The cost of resilience is a conscious choice
The two expensive properties are the active-active footprint and the synchronous cross-region commit. The footprint is held down by running each region lean and scaling on failover. The commit cost is held down by confining the cross-region round trip to the commit of mutating transactions, so reads and execution stay local and the premium is paid once per write rather than per statement.
11. Compliance mapping
The platform is designed to align with the Payment Card Industry Data Security Standard and with Canadian regulatory expectations for technology, cyber risk, and operational resilience. The table summarizes how the design supports the standard. The mapping is an architecture aid and not an attestation, which depends on a formal assessment.
| Requirement area | How the architecture supports it |
|---|---|
| Network segmentation | Trust zones, default-deny network policy, a cardholder data environment isolated to dedicated nodes, and a minimized scope |
| Secure configuration | Hardened minimal nodes from signed images, no defaults, immutable infrastructure |
| Protect stored data | Primary account numbers tokenized at the boundary, encryption with customer-managed keys, no clear card data at rest |
| Protect data in transit | Transport Layer Security at the edge and mutual Transport Layer Security inside the mesh |
| Secure development | Signed images, admission verification, static and composition analysis in the pipeline |
| Access control | Least privilege, workload identity without standing credentials, central human identity with short sessions |
| Logging and monitoring | Central logging, control-plane audit, immutable retention in the security information and event management system |
| Testing | Continuous scanning, vulnerability assessment, and scheduled fault injection and recovery tests |
On the regulatory side, the design supports the expectations set out by the Office of the Superintendent of Financial Institutions for technology and cyber risk and for operational resilience. Strict in-Canada data residency, a defined and tested recovery posture, third-party and concentration awareness through the multi-account model, and continuous monitoring all map to those expectations. The relevant guidance is listed in the references.
12. Risks, assumptions, and open items
- Commit latency must be validated against the real budget. The synchronous cross-region commit adds the inter-region round trip on writes. The exact figure between Montreal and Calgary must be measured under load and confirmed to fit the authorization budget before launch.
- Operating synchronous replication is a real commitment. Running the tier-zero ledger with synchronous cross-region replication requires mature operations, tested promotion, and careful handling of the standby-unreachable case. The runbooks address this and must be exercised regularly.
- The witness is a critical dependency. It is made redundant inside the data center, holds only journal metadata, and must be monitored as a tier-zero component.
- Cell rebalancing needs care. Moving cells between regions after a recovery must be done without violating the single-writer rule, on a controlled schedule.
- Revisit if managed options change. If Amazon Aurora DSQL later offers a Canadian multi-region set with an in-country quorum, the tier-zero decision in ADR-02 should be re-evaluated, since a managed synchronous option would reduce the operational burden.
- Throughput ceilings. Per-cell write throughput has limits. The cell count and the hash function must be sized for peak volume with headroom, and the cell map must support growth.
13. References
The decisions that depend on current service behavior were checked against the following sources. They are recorded in monospace for verification.
Part II, Detailed design and configuration
1. Purpose and conventions
This document turns the reference architecture into buildable configuration. It assumes the decisions recorded there and does not repeat their rationale. Snippets are shown in Terraform for AWS resources and in Kubernetes manifests for in-cluster objects, with GitHub Actions and ArgoCD for delivery. Values such as account identifiers, virtual private cloud identifiers, and Amazon Resource Names are shown as placeholders in the form REPLACE_ME or as Terraform references, since they are environment specific.
Implementation refinement to decision record ADR-02
The reference architecture named Amazon Aurora PostgreSQL for the tier-zero ledger as shorthand. At the implementation level that is corrected, because Aurora cannot meet a literal zero recovery point objective across regions. Aurora replicates across regions only through Amazon Aurora Global Database, which is asynchronous. A true zero recovery point objective with PostgreSQL semantics requires synchronous streaming replication with synchronous_commit = remote_apply and a synchronous standby in the partner region, which is a self-managed capability. The tier-zero ledger is therefore self-managed PostgreSQL orchestrated by Patroni, detailed in section 7. The witness from ADR-05 is realized as the third member of the Patroni distributed configuration store, placed on premises in Canada. Aurora remains the right choice for the tier-one products and is detailed in section 8.
Naming and tagging
Every resource carries a consistent set of tags so that cost, ownership, residency, and data classification are queryable. The standard tag set is applied through Terraform default tags and enforced by an organization tag policy.
provider "aws" {
region = var.region # ca-central-1 or ca-west-1
default_tags {
tags = {
Platform = "payments"
Environment = var.environment # prod | perf | test | dev
Region = var.region
DataClassification = var.data_class # restricted | confidential | internal
Residency = "canada-only"
Owner = "payments-platform"
ManagedBy = "terraform"
CostCenter = var.cost_center
}
}
}
# Naming convention: pay-{env}-{region-short}-{component}
# Example: pay-prod-cac1-ledger, pay-prod-caw1-eks
locals {
region_short = { "ca-central-1" = "cac1", "ca-west-1" = "caw1" }
name_prefix = "pay-${var.environment}-${local.region_short[var.region]}"
}2. Organization and account baseline
The landing zone is built on AWS Organizations with a dedicated account per environment and region. Each account receives the same baseline through a shared Terraform module, so that logging, detection, and guardrails are identical everywhere.
| Account | Organizational unit | Purpose |
|---|---|---|
| management | root | AWS Organizations, consolidated billing, service control policies |
| log-archive | Security | Immutable organization CloudTrail and AWS Config delivery, object lock |
| security-tooling | Security | GuardDuty, Security Hub, Amazon Inspector, and Detective delegated administrator |
| network | Infrastructure | AWS Cloud WAN, Direct Connect gateway, central resolver, shared ingress |
| shared-services | Infrastructure | Amazon ECR, cosign signing keys, platform tooling, golden pipelines |
| prod-cac1, prod-caw1 | Workloads / Prod | Production clusters and data tier, one account per region |
| perf-cac1, perf-caw1 | Workloads / Perf | Performance and soak environment, per region |
| dev, test | Workloads / Non-prod | Development and integration |
Region restriction service control policy
This policy denies every action outside the two Canadian regions, with a narrow exception for global services that have no regional endpoint. It is attached to the Workloads organizational unit so that residency is structural.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyOutsideCanada",
"Effect": "Deny",
"NotAction": [
"iam:*", "organizations:*", "route53:*", "cloudfront:*",
"waf:*", "wafv2:*", "support:*", "sts:*", "budgets:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["ca-central-1", "ca-west-1"]
}
}
}]
}Preventive guardrails service control policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyRootUser",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": { "StringLike": { "aws:PrincipalArn": "arn:aws:iam::*:root" } }
},
{
"Sid": "ProtectSecurityControls",
"Effect": "Deny",
"Action": [
"guardduty:DeleteDetector", "guardduty:DisassociateFromMasterAccount",
"cloudtrail:StopLogging", "cloudtrail:DeleteTrail",
"config:DeleteConfigurationRecorder", "config:StopConfigurationRecorder",
"securityhub:DisableSecurityHub"
],
"Resource": "*"
},
{
"Sid": "NoPublicS3",
"Effect": "Deny",
"Action": "s3:PutBucketPublicAccessBlock",
"Resource": "*",
"Condition": { "Bool": { "s3:PublicAccessBlockConfiguration": "false" } }
},
{
"Sid": "RequireImdsV2",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": { "StringNotEquals": { "ec2:MetadataHttpTokens": "required" } }
}
]
}Account baseline
The baseline module enables the organization trail and config aggregation, registers the account with the delegated security administrator, and turns on detection. Delegated administration is configured once from the management account.
resource "aws_guardduty_organization_admin_account" "this" {
admin_account_id = var.security_tooling_account_id
}
resource "aws_securityhub_organization_admin_account" "this" {
admin_account_id = var.security_tooling_account_id
}
# Per-account baseline (applied by stack set to every workload account)
resource "aws_guardduty_detector" "this" {
enable = true
datasources {
kubernetes { audit_logs { enable = true } }
malware_protection { scan_ec2_instance_with_findings { ebs_volumes { enable = true } } }
}
}
resource "aws_inspector2_enabler" "this" {
account_ids = [data.aws_caller_identity.current.account_id]
resource_types = ["ECR", "EC2", "LAMBDA"]
}3. Network configuration
3.1 Address plan
Every virtual private cloud uses a non-overlapping block so that Cloud WAN can route between them without translation. Each environment and region gets its own block.
| Account | Region | VPC CIDR | Notes |
|---|---|---|---|
| prod-cac1 | ca-central-1 | 10.20.0.0/16 | Production, Central |
| prod-caw1 | ca-west-1 | 10.21.0.0/16 | Production, West |
| perf-cac1 | ca-central-1 | 10.30.0.0/16 | Performance, Central |
| perf-caw1 | ca-west-1 | 10.31.0.0/16 | Performance, West |
| test | ca-central-1 | 10.40.0.0/16 | Integration |
| dev | ca-central-1 | 10.41.0.0/16 | Development |
Within a production virtual private cloud the address space is divided into four subnet tiers, each spread across three Availability Zones. The application tier is sized large because the container networking plugin uses prefix delegation for high pod density.
| Tier | AZ a | AZ b | AZ c | Use |
|---|---|---|---|---|
| Ingress | 10.20.0.0/24 | 10.20.1.0/24 | 10.20.2.0/24 | Internal load balancer interfaces |
| Data | 10.20.4.0/24 | 10.20.5.0/24 | 10.20.6.0/24 | PostgreSQL, MSK, Redis |
| Endpoints | 10.20.8.0/24 | 10.20.9.0/24 | 10.20.10.0/24 | VPC interface endpoints |
| Application | 10.20.16.0/20 | 10.20.32.0/20 | 10.20.48.0/20 | EKS nodes and pods |
3.2 Cloud WAN core network
The core network policy defines two segments, production and non-production, that do not share routes. Each virtual private cloud attaches to the segment that matches its environment tag, so attachment is automatic and policy driven.
data "aws_networkmanager_core_network_policy_document" "this" {
core_network_configuration {
asn_ranges = ["64512-64555"]
edge_locations { location = "ca-central-1" }
edge_locations { location = "ca-west-1" }
}
segments { name = "production" isolate_attachments = false require_attachment_acceptance = true }
segments { name = "nonproduction" isolate_attachments = false require_attachment_acceptance = true }
segment_actions {
action = "share"
segment = "production"
share_with = ["production"] # production talks only to production
}
attachment_policies {
rule_number = 100
condition_logic = "and"
conditions { type = "tag-value" key = "Environment" operator = "equals" value = "prod" }
action { association_method = "constant" segment = "production" }
}
attachment_policies {
rule_number = 200
condition_logic = "or"
conditions { type = "tag-value" key = "Environment" operator = "equals" value = "dev" }
conditions { type = "tag-value" key = "Environment" operator = "equals" value = "test" }
action { association_method = "constant" segment = "nonproduction" }
}
}3.3 Direct Connect
Two dedicated connections from separate Direct Connect locations terminate on a Direct Connect gateway, which associates with the Cloud WAN core network so that on-premises reaches both regions over private transit. Bidirectional Forwarding Detection speeds failure detection on the border gateway protocol sessions.
resource "aws_dx_gateway" "core" {
name = "pay-dxgw"
amazon_side_asn = 64600
}
# One transit virtual interface per dedicated connection (two for redundancy)
resource "aws_dx_transit_virtual_interface" "vif" {
for_each = toset(["conn-a", "conn-b"])
connection_id = var.dx_connection_ids[each.key]
dx_gateway_id = aws_dx_gateway.core.id
name = "pay-tvif-${each.key}"
vlan = var.dx_vlans[each.key]
address_family = "ipv4"
bgp_asn = 65000
mtu = 8500 # jumbo frames for replication throughput
}
# Associate the Direct Connect gateway with the Cloud WAN core network
resource "aws_networkmanager_dx_gateway_attachment" "this" {
core_network_id = var.core_network_id
direct_connect_gateway_arn = aws_dx_gateway.core.arn
edge_locations = ["ca-central-1", "ca-west-1"]
}3.4 Route 53 traffic management
A private hosted zone serves the platform domain over the AWS backbone. Each region publishes a latency record guarded by a health check, so a caller reaches the nearest healthy region and a failed region is withdrawn automatically.
resource "aws_route53_health_check" "region" {
for_each = toset(["cac1", "caw1"])
fqdn = "ingress-${each.key}.pay.internal"
type = "HTTPS"
resource_path = "/healthz"
port = 443
failure_threshold = 2
request_interval = 10 # fast detection, ~20 s to unhealthy
}
resource "aws_route53_record" "api" {
for_each = { cac1 = "ca-central-1", caw1 = "ca-west-1" }
zone_id = aws_route53_zone.private.zone_id
name = "api.pay.internal"
type = "A"
set_identifier = each.key
health_check_id = aws_route53_health_check.region[each.key].id
latency_routing_policy { region = each.value }
alias {
name = var.nlb_dns[each.key]
zone_id = var.nlb_zone[each.key]
evaluate_target_health = true
}
}3.5 VPC interface endpoints
Because there is no internet gateway, every AWS service is reached through an interface endpoint in the endpoints subnet tier. The set is created from a list so it stays consistent across accounts.
locals {
interface_endpoints = [
"ecr.api", "ecr.dkr", "sts", "secretsmanager", "kms",
"logs", "monitoring", "ssm", "ssmmessages", "ec2messages",
"eks", "elasticloadbalancing", "kafka", "sts"
]
}
resource "aws_vpc_endpoint" "iface" {
for_each = toset(local.interface_endpoints)
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.${each.key}"
vpc_endpoint_type = "Interface"
subnet_ids = var.endpoint_subnet_ids
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true
}
# S3 reached through a gateway endpoint (no hourly cost, route-table based)
resource "aws_vpc_endpoint" "s3" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
}3.6 Egress control with AWS Network Firewall
All outbound traffic from workload subnets routes through AWS Network Firewall. A stateful rule group permits only an explicit domain allow-list and a default-deny drops everything else. New external dependencies require an allow-list change, which is the intended friction.
resource "aws_networkfirewall_rule_group" "egress_allow" {
capacity = 256
name = "pay-egress-allow"
type = "STATEFUL"
rule_group {
rule_variables {
ip_sets {
key = "HOME_NET"
ip_set { definition = ["10.20.0.0/16"] }
}
}
rules_source {
rules_source_list {
generated_rules_type = "ALLOWLIST"
target_types = ["TLS_SNI", "HTTP_HOST"]
targets = [
".ca-central-1.amazonaws.com",
".ca-west-1.amazonaws.com",
".pay-partner.example" # named partner ranges only
]
}
}
stateful_rule_options { rule_order = "STRICT_ORDER" }
}
}
resource "aws_networkfirewall_firewall_policy" "this" {
name = "pay-egress-policy"
firewall_policy {
stateless_default_actions = ["aws:forward_to_sfe"]
stateless_fragment_default_actions = ["aws:forward_to_sfe"]
stateful_default_actions = ["aws:drop_established", "aws:alert_established"]
stateful_engine_options { rule_order = "STRICT_ORDER" }
stateful_rule_group_reference { resource_arn = aws_networkfirewall_rule_group.egress_allow.arn priority = 100 }
}
}3.7 Security groups
Security groups reference each other by identifier rather than by address range, so access is expressed by role. The matrix summarizes the allowed paths.
| From | To | Port | Purpose |
|---|---|---|---|
| nlb | nodes | 30000-32767 | Ingress to node ports behind the mesh |
| nodes | ledger | 5432, 6432 | PostgreSQL and the connection pooler |
| nodes | msk | 9094 | Kafka over Transport Layer Security |
| nodes | redis | 6379 | Cache with in-transit encryption |
| nodes | endpoints | 443 | AWS service interface endpoints |
| ledger | ledger | 5432 | Streaming replication between PostgreSQL nodes |
resource "aws_security_group" "ledger" {
name = "${local.name_prefix}-ledger"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "ledger_from_nodes" {
security_group_id = aws_security_group.ledger.id
referenced_security_group_id = aws_security_group.nodes.id
from_port = 5432 to_port = 6432 ip_protocol = "tcp"
}
# Cross-region replication peer reached over Cloud WAN, restricted to the partner ledger CIDR
resource "aws_vpc_security_group_ingress_rule" "ledger_replication" {
security_group_id = aws_security_group.ledger.id
cidr_ipv4 = var.partner_region_data_cidr # e.g. 10.21.4.0/22
from_port = 5432 to_port = 5432 ip_protocol = "tcp"
}4. EKS cluster configuration
Each production region runs one private cluster. The API endpoint is private only, secrets are encrypted with a customer-managed key, all control-plane log types are shipped, and access is governed by the access-entry model rather than the legacy authentication config map.
resource "aws_eks_cluster" "this" {
name = "${local.name_prefix}-eks"
role_arn = aws_iam_role.cluster.arn
version = "1.33"
vpc_config {
subnet_ids = var.app_subnet_ids
endpoint_private_access = true
endpoint_public_access = false # private only
security_group_ids = [aws_security_group.cluster.id]
}
access_config {
authentication_mode = "API"
bootstrap_cluster_creator_admin_permissions = false
}
encryption_config {
provider { key_arn = aws_kms_key.eks_secrets.arn }
resources = ["secrets"] # envelope-encrypt etcd secrets
}
kubernetes_network_config {
ip_family = "ipv4"
service_ipv4_cidr = "172.20.0.0/16"
}
enabled_cluster_log_types = [
"api", "audit", "authenticator", "controllerManager", "scheduler"
]
}
# Access by role, least privilege, no shared admin
resource "aws_eks_access_entry" "platform" {
cluster_name = aws_eks_cluster.this.name
principal_arn = aws_iam_role.platform_admin.arn
type = "STANDARD"
}
resource "aws_eks_access_policy_association" "platform" {
cluster_name = aws_eks_cluster.this.name
principal_arn = aws_iam_role.platform_admin.arn
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope { type = "cluster" }
}Core add-ons
The container networking plugin runs with prefix delegation so that each node carries many more pod addresses, which is what allows the application subnets to be sized as they are. Add-ons are pinned to specific versions and updated through the pipeline.
resource "aws_eks_addon" "vpc_cni" {
cluster_name = aws_eks_cluster.this.name
addon_name = "vpc-cni"
addon_version = "v1.19.2-eksbuild.1"
pod_identity_association {
role_arn = aws_iam_role.vpc_cni.arn
service_account = "aws-node"
}
configuration_values = jsonencode({
env = {
ENABLE_PREFIX_DELEGATION = "true"
WARM_PREFIX_TARGET = "1"
ENABLE_POD_ENI = "true" # security groups for pods
}
})
}
resource "aws_eks_addon" "pod_identity" {
cluster_name = aws_eks_cluster.this.name
addon_name = "eks-pod-identity-agent"
addon_version = "v1.3.4-eksbuild.1"
}
# coredns, kube-proxy, aws-ebs-csi-driver follow the same pattern, version pinned.Why the API authentication mode
The access-entry model keeps cluster access in AWS Identity and Access Management, where it is auditable and removable without editing an in-cluster config map. It pairs with the multi-account model so that a role in one account cannot silently gain access to a cluster in another.
5. Node strategy and Karpenter
A small managed node group runs the system components and Karpenter itself, on hardened Bottlerocket nodes. Karpenter then provisions everything else just in time. All node pools are AWS Graviton based.
resource "aws_eks_node_group" "system" {
cluster_name = aws_eks_cluster.this.name
node_group_name = "system"
node_role_arn = aws_iam_role.node.arn
subnet_ids = var.app_subnet_ids
ami_type = "BOTTLEROCKET_ARM_64"
capacity_type = "ON_DEMAND"
instance_types = ["m7g.large"]
scaling_config { min_size = 3 max_size = 6 desired_size = 3 }
update_config { max_unavailable_percentage = 33 }
labels = { "pool" = "system" }
taint { key = "CriticalAddonsOnly" value = "true" effect = "NO_SCHEDULE" }
launch_template { id = aws_launch_template.system.id version = "$Latest" }
}
# Launch template enforces IMDSv2 and encrypted volumes
resource "aws_launch_template" "system" {
name = "${local.name_prefix}-system"
metadata_options { http_tokens = "required" http_put_response_hop_limit = 1 }
block_device_mappings {
device_name = "/dev/xvda"
ebs { volume_size = 40 volume_type = "gp3" encrypted = true kms_key_id = aws_kms_key.ebs.arn }
}
}Karpenter node class and node pools
One node class defines how nodes are built, including the Bottlerocket family, encrypted storage, and enforced instance metadata version two. Two node pools follow. The tier-zero pool uses on-demand capacity for predictable performance, and the tier-one pool mixes Spot and on-demand for cost. Both restrict to Graviton.
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: bottlerocket-arm
spec:
amiFamily: Bottlerocket
amiSelectorTerms:
- alias: bottlerocket@latest
role: KarpenterNodeRole-pay
subnetSelectorTerms:
- tags: { "kubernetes.io/role/internal-elb": "1", "Tier": "application" }
securityGroupSelectorTerms:
- tags: { "karpenter.sh/discovery": "pay-prod-cac1-eks" }
metadataOptions:
httpTokens: required
httpPutResponseHopLimit: 1
blockDeviceMappings:
- deviceName: /dev/xvdb
ebs: { volumeSize: 80Gi, volumeType: gp3, encrypted: true, kmsKeyID: "alias/pay-ebs" }
tags: { "karpenter.sh/discovery": "pay-prod-cac1-eks", "Tier": "application" }apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: tier0-app }
spec:
template:
metadata:
labels: { pool: tier0, workload-tier: "0" }
spec:
nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: bottlerocket-arm }
taints:
- key: workload-tier
value: "0"
effect: NoSchedule
requirements:
- { key: kubernetes.io/arch, operator: In, values: ["arm64"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["on-demand"] }
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["m7g","r7g"] }
- { key: karpenter.k8s.aws/instance-size, operator: In, values: ["xlarge","2xlarge"] }
limits: { cpu: "400", memory: 1600Gi }
disruption:
consolidationPolicy: WhenEmpty # tier-0 is conservative, no live consolidation
consolidateAfter: 5m
budgets: [ { nodes: "10%" } ]
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: tier1-app }
spec:
template:
metadata:
labels: { pool: tier1, workload-tier: "1" }
spec:
nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: bottlerocket-arm }
requirements:
- { key: kubernetes.io/arch, operator: In, values: ["arm64"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["spot","on-demand"] }
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["m7g","c7g","r7g"] }
limits: { cpu: "1000", memory: 4000Gi }
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized # tier-1 packs aggressively
consolidateAfter: 30sShort-lived batch jobs run on AWS Fargate for hard isolation from the node fleet, scoped to a single namespace.
resource "aws_eks_fargate_profile" "batch" {
cluster_name = aws_eks_cluster.this.name
fargate_profile_name = "batch"
pod_execution_role_arn = aws_iam_role.fargate.arn
subnet_ids = var.app_subnet_ids
selector { namespace = "batch" }
}6. Kubernetes platform configuration
Product domains run in their own namespaces. Each namespace joins the ambient mesh, enforces the restricted pod security standard, and starts from default-deny networking. Quotas and limits prevent any one domain from starving the cluster.
apiVersion: v1
kind: Namespace
metadata:
name: pay-auth
labels:
istio.io/dataplane-mode: ambient
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
workload-tier: "0"
---
apiVersion: v1
kind: ResourceQuota
metadata: { name: quota, namespace: pay-auth }
spec:
hard:
requests.cpu: "200"
requests.memory: 400Gi
limits.cpu: "400"
limits.memory: 800Gi
pods: "500"
---
apiVersion: v1
kind: LimitRange
metadata: { name: limits, namespace: pay-auth }
spec:
limits:
- type: Container
default: { cpu: "500m", memory: 512Mi }
defaultRequest: { cpu: "250m", memory: 256Mi }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: pay-auth }
spec:
podSelector: {}
policyTypes: [Ingress, Egress] # nothing is allowed until a policy opens itMesh identity and authorization
Mutual Transport Layer Security is set to strict for the whole mesh, so unauthenticated traffic between workloads is rejected. Authorization policies then express which identity may call which service, which is stronger than network reachability alone.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: { name: default, namespace: istio-system }
spec:
mtls: { mode: STRICT }
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: { name: allow-ingress-to-auth, namespace: pay-auth }
spec:
selector: { matchLabels: { app: payment-auth } }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/ingress/sa/gateway"]
to:
- operation:
methods: ["POST"]
paths: ["/v1/authorize"]Private ingress with the Gateway API
Ingress is an internal network load balancer fronting an Istio gateway. The load balancer is internal and target-type IP, so there is no public listener anywhere. Routes attach to the gateway per product.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: pay-gateway
namespace: ingress
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: external
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-scheme: internal
spec:
gatewayClassName: istio
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs: [ { name: pay-tls } ]
allowedRoutes: { namespaces: { from: Selector, selector: { matchLabels: { ingress: "true" } } } }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: auth, namespace: pay-auth }
spec:
parentRefs: [ { name: pay-gateway, namespace: ingress } ]
hostnames: ["api.pay.internal"]
rules:
- matches: [ { path: { type: PathPrefix, value: /v1/authorize } } ]
backendRefs: [ { name: payment-auth, port: 8080 } ]Secrets from AWS Secrets Manager
The External Secrets operator pulls secrets at runtime using a workload identity, so no secret is ever stored in Git or in an image. The store authenticates through EKS Pod Identity.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata: { name: aws-secrets }
spec:
provider:
aws:
service: SecretsManager
region: ca-central-1
auth: { jwt: { serviceAccountRef: { name: external-secrets, namespace: external-secrets } } }
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: ledger-credentials, namespace: pay-auth }
spec:
refreshInterval: 1h
secretStoreRef: { name: aws-secrets, kind: ClusterSecretStore }
target: { name: ledger-credentials, creationPolicy: Owner }
data:
- secretKey: dsn
remoteRef: { key: pay/prod/ledger/dsn }Admission time signature verification
Kyverno verifies that every image is signed by the platform cosign key held in AWS Key Management Service, rejects unsigned images, resolves tags to digests, and forbids the floating latest tag. This is the in-cluster end of the supply chain that the pipeline builds.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: verify-signed-images }
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: verify-cosign-kms
match:
any: [ { resources: { kinds: ["Pod"] } } ]
verifyImages:
- imageReferences: ["ACCOUNT.dkr.ecr.ca-central-1.amazonaws.com/pay/*"]
mutateDigest: true # pin tag to the verified digest
required: true
attestors:
- count: 1
entries:
- keys:
kms: "awskms:///alias/pay-cosign"
- name: disallow-latest
match: { any: [ { resources: { kinds: ["Pod"] } } ] }
validate:
message: "Floating tags are not allowed, use a digest."
pattern: { spec: { containers: [ { image: "!*:latest" } ] } }7. Tier-zero ledger configuration
The tier-zero ledger is self-managed PostgreSQL 16 on AWS Graviton instances, orchestrated by Patroni, with synchronous streaming replication that requires the partner-region standby to acknowledge before a commit returns. This delivers a literal zero recovery point objective across a region loss with PostgreSQL semantics, which no managed AWS service offers inside Canada today.
Topology and quorum
Each region runs a Patroni-managed PostgreSQL cluster with one primary and two local replicas for fast in-region failover, plus a synchronous standby in the partner region. Patroni stores cluster state in an etcd cluster whose members sit in Canada Central, Canada West, and the on-premises data center. That third on-premises member is the witness from ADR-05. It holds only cluster-coordination metadata, never ledger data, and it gives the cluster an odd number of voters inside Canada so that leader election survives a clean partition without split-brain.
How zero recovery point objective is actually guaranteed
The setting synchronous_commit = remote_apply with a synchronous standby list that requires the partner-region node means a commit is acknowledged only after the partner region has applied it. With synchronous_mode_strict enabled, Patroni never silently falls back to asynchronous replication, so the guarantee is never quietly weakened. If the partner region is unreachable, commits on the affected cells block rather than risk loss, which is the deliberate partition behavior from ADR-05. Patroni promotes only a node known to hold every synchronously confirmed transaction, so failover is also lossless. Operators may add a local synchronous member for faster in-region failover, accepting that a local synchronous node outage then blocks writes until Patroni reconfigures the set.
wal_level = replica synchronous_commit = remote_apply synchronous_standby_names = 'ANY 1 (pay_caw1_standby)' # partner region required max_wal_senders = 16 max_replication_slots = 16 hot_standby = on wal_compression = on wal_keep_size = 8GB max_connections = 600 # PgBouncer fronts client connections shared_buffers = 32GB # on the r7g.4xlarge, 128 GiB class effective_cache_size = 96GB checkpoint_completion_target = 0.9 ssl = on ssl_min_protocol_version = 'TLSv1.3' password_encryption = scram-sha-256 log_min_duration_statement = 500ms
scope: pay-ledger
namespace: /pay/
name: pay_cac1_primary
etcd3:
hosts:
- etcd-cac1.pay.internal:2379
- etcd-caw1.pay.internal:2379
- etcd-onprem.pay.internal:2379 # witness, quorum only, no data
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
synchronous_mode: true
synchronous_mode_strict: true # never degrade to async, preserve 0 RPO
synchronous_node_count: 1
postgresql:
use_pg_rewind: true
parameters:
synchronous_commit: remote_apply
synchronous_standby_names: 'ANY 1 (pay_caw1_standby)'
postgresql:
listen: 0.0.0.0:5432
data_dir: /pgdata/16
authentication:
replication: { username: replicator }
callbacks:
on_role_change: /opt/pay/on_role_change.sh # updates the cell map on promotion
tags:
nofailover: false
clonefrom: trueStorage, encryption, and backups
Data sits on io2 Block Express volumes with provisioned input and output operations for predictable commit latency, encrypted with a customer-managed key. Backups use pgBackRest to an Amazon S3 bucket with object lock for write-once retention, which gives point-in-time recovery that a deletion or a faulty change cannot tamper with.
| Setting | Value | Reason |
|---|---|---|
| Instance | r7g.4xlarge, Graviton | Memory-heavy ledger, efficient cores |
| Volume | io2 Block Express, 64000 provisioned operations | Consistent commit latency under load |
| Encryption | KMS customer-managed key | Key ownership and audit |
| Backup | pgBackRest to Amazon S3 with object lock | Immutable point-in-time recovery |
| Pooling | PgBouncer, transaction mode, port 6432 | Thousands of clients on few server connections |
Cells and routing
The ledger keyspace is split into sixteen cells by a consistent hash of the account key. Cells one through eight are homed to Canada Central and nine through sixteen to Canada West, which keeps a single writer per cell. The cell map is a small, strongly consistent record of which region owns which cell, held in the same etcd cluster that backs Patroni. On a promotion the role-change callback updates the owning region for the affected cells, and the smart client and router read the map to direct each write to the owning region.
{
"version": 184,
"cells": {
"C1": { "home": "ca-central-1", "writer": "pay_cac1_primary", "state": "active" },
"C8": { "home": "ca-central-1", "writer": "pay_cac1_primary", "state": "active" },
"C9": { "home": "ca-west-1", "writer": "pay_caw1_primary", "state": "active" },
"C16": { "home": "ca-west-1", "writer": "pay_caw1_primary", "state": "active" }
},
"adoption": { "in_progress": false, "adopted_by": null }
}8. Tier-one data services
Transfer rails and messaging tolerate a recovery point objective of one to five minutes, so they use managed services with asynchronous cross-region replication. This is where Amazon Aurora is the right tool.
Aurora PostgreSQL Global Database
resource "aws_rds_global_cluster" "rails" {
global_cluster_identifier = "pay-rails"
engine = "aurora-postgresql"
engine_version = "16.4"
storage_encrypted = true
}
resource "aws_rds_cluster" "rails_primary" {
cluster_identifier = "pay-rails-cac1"
engine = "aurora-postgresql"
engine_version = "16.4"
global_cluster_identifier = aws_rds_global_cluster.rails.id
db_subnet_group_name = aws_db_subnet_group.data.name
vpc_security_group_ids = [aws_security_group.aurora.id]
kms_key_id = aws_kms_key.rds.arn
storage_encrypted = true
backup_retention_period = 35
iam_database_authentication_enabled = true
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.rails.name
}
resource "aws_rds_cluster_instance" "rails_primary" {
count = 2 # writer plus reader, Multi-AZ
cluster_identifier = aws_rds_cluster.rails_primary.id
instance_class = "db.r7g.2xlarge" # Graviton
engine = "aurora-postgresql"
}
# Secondary region cluster, asynchronous replica, promotable on failover
resource "aws_rds_cluster" "rails_secondary" {
provider = aws.caw1
cluster_identifier = "pay-rails-caw1"
engine = "aurora-postgresql"
engine_version = "16.4"
global_cluster_identifier = aws_rds_global_cluster.rails.id
kms_key_id = aws_kms_key.rds_caw1.arn
depends_on = [aws_rds_cluster_instance.rails_primary]
}Amazon MSK and cross-region replication
The event backbone is a provisioned MSK cluster with one broker per Availability Zone, encryption in transit and at rest, and identity-based client authentication. MSK Replicator copies topics to the partner region so consumers continue after a region loss.
resource "aws_msk_cluster" "events" {
cluster_name = "pay-events"
kafka_version = "3.7.x"
number_of_broker_nodes = 3
broker_node_group_info {
instance_type = "kafka.m7g.large"
client_subnets = var.data_subnet_ids
security_groups = [aws_security_group.msk.id]
storage_info { ebs_storage_info { volume_size = 1000 } }
}
encryption_info {
encryption_at_rest_kms_key_arn = aws_kms_key.msk.arn
encryption_in_transit { client_broker = "TLS" in_cluster = true }
}
client_authentication { sasl { iam = true } }
open_monitoring { prometheus { jmx_exporter { enabled_in_broker = true } node_exporter { enabled_in_broker = true } } }
}
resource "aws_msk_replicator" "to_caw1" {
replicator_name = "pay-events-to-caw1"
kafka_cluster {
amazon_msk_cluster { msk_cluster_arn = aws_msk_cluster.events.arn }
vpc_config { subnet_ids = var.data_subnet_ids security_group_ids = [aws_security_group.msk.id] }
}
kafka_cluster {
amazon_msk_cluster { msk_cluster_arn = var.caw1_msk_arn }
vpc_config { subnet_ids = var.caw1_data_subnet_ids security_group_ids = [var.caw1_msk_sg] }
}
replication_info_list {
target_compression_type = "GZIP"
topic_replication { topic_name_configuration_sync_ms = 60000 copy_topic_configurations = true }
consumer_group_replication { consumer_groups_to_replicate = ["*"] }
}
}ElastiCache for Redis, global datastore
resource "aws_elasticache_replication_group" "cache" {
replication_group_id = "pay-cache-cac1"
description = "payments cache, primary"
engine = "redis"
engine_version = "7.1"
node_type = "cache.r7g.large"
num_node_groups = 3 # cluster mode, sharded
replicas_per_node_group = 1
automatic_failover_enabled = true
multi_az_enabled = true
transit_encryption_enabled = true
at_rest_encryption_enabled = true
kms_key_id = aws_kms_key.redis.arn
subnet_group_name = aws_elasticache_subnet_group.data.name
security_group_ids = [aws_security_group.redis.id]
}
resource "aws_elasticache_global_replication_group" "cache" {
global_replication_group_id_suffix = "pay-cache"
primary_replication_group_id = aws_elasticache_replication_group.cache.id
}9. Identity, access, and keys
Workload access to AWS is granted through EKS Pod Identity, so a pod assumes a role scoped to exactly what it needs, with no static keys on nodes and no shared node role for application access. Each association binds a Kubernetes service account to an IAM role.
resource "aws_eks_pod_identity_association" "auth" {
cluster_name = aws_eks_cluster.this.name
namespace = "pay-auth"
service_account = "payment-auth"
role_arn = aws_iam_role.payment_auth.arn
}
resource "aws_iam_role" "payment_auth" {
name = "${local.name_prefix}-payment-auth"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "pods.eks.amazonaws.com" }
Action = ["sts:AssumeRole", "sts:TagSession"]
}]
})
}
resource "aws_iam_role_policy" "payment_auth" {
role = aws_iam_role.payment_auth.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ReadOwnSecret"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = "arn:aws:secretsmanager:ca-central-1:ACCOUNT:secret:pay/prod/ledger/*"
},
{
Sid = "DecryptWithTenantKey"
Effect = "Allow"
Action = ["kms:Decrypt"]
Resource = aws_kms_key.tenant_auth.arn
}
]
})
}Key management
Encryption uses customer-managed keys, with separate keys per purpose and per tenant where isolation requires it. Rotation is enabled. The key policy grants use to the specific roles and services that need it and denies everything else.
resource "aws_kms_key" "tenant_auth" {
description = "pay tenant key, payment-auth"
enable_key_rotation = true
rotation_period_in_days = 90
deletion_window_in_days = 30
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "RootAdmin"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::ACCOUNT:root" }
Action = ["kms:Create*","kms:Describe*","kms:Enable*","kms:List*","kms:Put*","kms:ScheduleKeyDeletion"]
Resource = "*"
},
{
Sid = "WorkloadUse"
Effect = "Allow"
Principal = { AWS = aws_iam_role.payment_auth.arn }
Action = ["kms:Decrypt","kms:GenerateDataKey"]
Resource = "*"
}
]
})
}
# The cosign signing key is asymmetric on the elliptic curve, used by CI to sign and by Kyverno to verify
resource "aws_kms_key" "cosign" {
description = "pay image signing"
customer_master_key_spec = "ECC_NIST_P256"
key_usage = "SIGN_VERIFY"
enable_key_rotation = false # asymmetric keys are not auto-rotated
}
resource "aws_kms_alias" "cosign" { name = "alias/pay-cosign" target_key_id = aws_kms_key.cosign.id }10. Security services
Detection runs at the account, cluster, and host levels. The account baseline in section 2 turns on the AWS managed services. Inside the cluster a runtime sensor watches for suspicious process and file activity. The table summarizes the layers and what each one covers.
| Layer | Service | Coverage |
|---|---|---|
| Threat detection | Amazon GuardDuty, EKS protection and runtime monitoring | Control-plane audit anomalies and on-host runtime threats |
| Posture | AWS Security Hub | Aggregated findings against the Foundational and PCI standards |
| Vulnerabilities | Amazon Inspector | Continuous image, host, and function scanning |
| Configuration | AWS Config conformance pack | Drift from required controls, encryption, public access |
| Runtime, in-cluster | Falco | Process, file, and network syscall rules on every node |
| Sensitive data | Amazon Macie | Detection of card data patterns in any storage bucket |
The runtime sensor runs as a privileged DaemonSet on a dedicated path, sending alerts to the central security information and event management system. A small set of rules is tuned to the payments workloads, for example flagging any shell spawned inside a cardholder container.
driver: { kind: modern_ebpf } # no kernel module, eBPF probe
falco:
rules_files:
- /etc/falco/falco_rules.yaml
- /etc/falco/pay_rules.yaml
json_output: true
customRules:
pay_rules.yaml: |-
- rule: Shell in cardholder container
desc: A shell was started inside a CDE workload
condition: spawned_process and container and proc.name in (bash, sh, zsh)
and k8s.ns.name in (pay-auth, pay-settlement)
output: "Shell in CDE (pod=%k8s.pod.name ns=%k8s.ns.name cmd=%proc.cmdline)"
priority: CRITICAL
falcosidekick:
enabled: true
config: { aws: { sns: { topicarn: "arn:aws:sns:ca-central-1:ACCOUNT:pay-security" } } }11. CI/CD configuration
GitHub Actions authenticates to AWS with OpenID Connect, so no static cloud credentials exist in the pipeline. The role that the pipeline assumes trusts only the specific repository and only on protected references.
resource "aws_iam_role" "ci_build" {
name = "pay-ci-build"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = "arn:aws:iam::ACCOUNT:oidc-provider/token.actions.githubusercontent.com" }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
StringLike = { "token.actions.githubusercontent.com:sub" = "repo:payments-org/pay-services:ref:refs/heads/main" }
}
}]
})
}Build, test, sign, and push
The pipeline builds with a rootless builder, runs tests and static and composition analysis, generates a software bill of materials, scans, then signs with the cosign key in Key Management Service and pushes by digest. A failing security gate fails the build.
name: build-sign-push
on:
push: { branches: [main] }
pull_request: {}
permissions:
id-token: write # required for OIDC
contents: read
env:
ECR: ACCOUNT.dkr.ecr.ca-central-1.amazonaws.com/pay/payment-auth
jobs:
build:
runs-on: [self-hosted, linux, arm64] # private Fargate runners, see prior reference
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/pay-ci-build
aws-region: ca-central-1
- uses: aws-actions/amazon-ecr-login@v2
- name: Unit and contract tests
run: make test
- name: Static analysis (SAST)
run: semgrep ci --error
- name: Build with rootless BuildKit
id: build
uses: docker/build-push-action@v6
with:
platforms: linux/arm64
push: true
tags: ${{ env.ECR }}:${{ github.sha }}
provenance: true
sbom: true
- name: Generate SBOM
uses: anchore/sbom-action@v0
with: { image: "${{ env.ECR }}@${{ steps.build.outputs.digest }}", format: cyclonedx-json }
- name: Vulnerability scan (fail on high)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: "${{ env.ECR }}@${{ steps.build.outputs.digest }}"
severity: HIGH,CRITICAL
exit-code: "1"
- name: Sign image with cosign and KMS
run: |
cosign sign --yes --key awskms:///alias/pay-cosign \
${{ env.ECR }}@${{ steps.build.outputs.digest }}
- name: Attest the SBOM
run: |
cosign attest --yes --key awskms:///alias/pay-cosign \
--predicate sbom.cdx.json --type cyclonedx \
${{ env.ECR }}@${{ steps.build.outputs.digest }}
- name: Record digest for delivery
run: echo "${{ steps.build.outputs.digest }}" > image-digest.txt
- uses: actions/upload-artifact@v4
with: { name: image-digest, path: image-digest.txt }Promote the digest into Git
Delivery is pull based. After a successful build, a second workflow updates the image digest in the configuration repository and opens a pull request, so every deployment is a reviewed Git change rather than a push from the pipeline.
name: promote-digest
on:
workflow_run: { workflows: [build-sign-push], types: [completed], branches: [main] }
jobs:
bump:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: [self-hosted, linux, arm64]
steps:
- uses: actions/checkout@v4
with: { repository: payments-org/pay-config, ssh-key: ${{ secrets.CONFIG_DEPLOY_KEY }} }
- uses: actions/download-artifact@v4
with: { name: image-digest }
- name: Update the non-production overlay first
run: |
DIGEST=$(cat image-digest.txt)
cd overlays/nonprod
kustomize edit set image pay/payment-auth=ACCOUNT.dkr.ecr.ca-central-1.amazonaws.com/pay/payment-auth@${DIGEST}
- uses: peter-evans/create-pull-request@v6
with:
branch: promote/payment-auth
title: "Promote payment-auth to ${{ github.sha }}"
labels: automated, promotionArgoCD project and application set
An AppProject confines what may be deployed and where. An ApplicationSet then generates one application per environment and region from a list, each pointing at the matching overlay, with sync waves so that namespaces and policies land before workloads.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata: { name: payments, namespace: argocd }
spec:
sourceRepos: ["https://github.com/payments-org/pay-config.git"]
destinations:
- { server: "https://kubernetes.default.svc", namespace: "pay-*" }
- { name: "prod-caw1", namespace: "pay-*" }
clusterResourceWhitelist: [ { group: "", kind: Namespace } ]
roles:
- name: deployer
policies: ["p, proj:payments:deployer, applications, sync, payments/*, allow"]
---
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata: { name: payment-auth, namespace: argocd }
spec:
generators:
- list:
elements:
- { env: prod, region: cac1, cluster: "https://kubernetes.default.svc", wave: "2" }
- { env: prod, region: caw1, cluster: "prod-caw1", wave: "3" }
template:
metadata:
name: "payment-auth-{{env}}-{{region}}"
annotations: { argocd.argoproj.io/sync-wave: "{{wave}}" }
spec:
project: payments
source:
repoURL: "https://github.com/payments-org/pay-config.git"
targetRevision: main
path: "overlays/{{env}}-{{region}}"
destination: { server: "{{cluster}}", namespace: pay-auth }
syncPolicy:
automated: { prune: true, selfHeal: true }
retry: { limit: 5 }Progressive delivery with metric analysis
Workloads are delivered as Argo Rollouts using a canary that shifts mesh traffic in steps and runs an automated analysis at each step. The analysis queries Prometheus for success rate and latency, and a breach fails the rollout, which halts the wave and triggers the rollback path.
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: payment-auth, namespace: pay-auth }
spec:
strategy:
canary:
canaryService: payment-auth-canary
stableService: payment-auth-stable
trafficRouting: { plugins: { argoproj-labs/gatewayAPI: { httpRoute: auth, namespace: pay-auth } } }
steps:
- setWeight: 5
- pause: { duration: 5m }
- analysis: { templates: [ { templateName: canary-health } ] }
- setWeight: 25
- pause: { duration: 10m }
- analysis: { templates: [ { templateName: canary-health } ] }
- setWeight: 50
- pause: { duration: 10m }
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata: { name: canary-health, namespace: pay-auth }
spec:
metrics:
- name: success-rate
interval: 1m
successCondition: result[0] >= 0.999
failureLimit: 1
provider:
prometheus:
address: http://thanos-query.monitoring:9090
query: |
sum(rate(http_requests_total{app="payment-auth",code!~"5.."}[2m]))
/ sum(rate(http_requests_total{app="payment-auth"}[2m]))
- name: p99-latency
interval: 1m
successCondition: result[0] <= 0.250
provider:
prometheus:
address: http://thanos-query.monitoring:9090
query: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{app="payment-auth"}[2m])) by (le))Promotion across the waves is driven by the sync-wave annotations in the application set and by the analysis results. Non-production reconciles first, then the performance environment runs load and soak, then production in Canada Central, and only after its bake time passes does production in Canada West proceed. A failed analysis at any wave reverts the Git change and ArgoCD reconciles the previous signed digest, which is the automated rollback from ADR-13.
12. Observability stack
Each region runs Prometheus through the kube-prometheus-stack, with a Thanos sidecar shipping blocks to Amazon S3. A Thanos querier fans out across both regions to give a single global view, which an active-active platform needs. Traces flow through an OpenTelemetry collector, and Grafana renders the dashboards.
# thanos object store, per region, S3 with the regional KMS key
type: S3
config:
bucket: pay-thanos-cac1
endpoint: s3.ca-central-1.amazonaws.com
sse_config: { type: SSE-KMS, kms_key_id: "alias/pay-observability" }
---
# OpenTelemetry collector, traces and metrics
receivers:
otlp: { protocols: { grpc: {}, http: {} } }
processors:
batch: {}
memory_limiter: { check_interval: 1s, limit_percentage: 80 }
exporters:
prometheusremotewrite: { endpoint: "http://prometheus.monitoring:9090/api/v1/write" }
otlphttp/tempo: { endpoint: "http://tempo.monitoring:4318" }
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [otlphttp/tempo] }
metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [prometheusremotewrite] }Service level objective and error budget
The objective is expressed as recording and alerting rules. A 99.999 percent target leaves a very small budget, so the alerts use multi-window burn-rate logic, which pages on a fast burn and warns on a slow burn rather than on every brief dip.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata: { name: pay-auth-slo, namespace: monitoring }
spec:
groups:
- name: payment-auth.slo
rules:
- record: job:auth_request_error:ratio_rate5m
expr: |
sum(rate(http_requests_total{app="payment-auth",code=~"5.."}[5m]))
/ sum(rate(http_requests_total{app="payment-auth"}[5m]))
- alert: AuthErrorBudgetFastBurn
# 99.999 percent objective, page when burning the budget 14x over a short window
expr: |
job:auth_request_error:ratio_rate5m > (14.4 * 0.00001)
and ignoring(window)
job:auth_request_error:ratio_rate1h > (14.4 * 0.00001)
for: 2m
labels: { severity: page }
annotations: { summary: "Authorization error budget fast burn" }13. Resilience testing
The resilience claims are verified, not assumed. AWS Fault Injection Service runs scheduled experiments that remove an Availability Zone, terminate ledger instances, and impair the cross-region path, while the team measures whether the recovery point and recovery time objectives hold. The example experiment fails a ledger primary and confirms that failover is lossless.
resource "aws_fis_experiment_template" "ledger_primary_loss" {
description = "Terminate the ledger primary and verify zero data loss failover"
role_arn = aws_iam_role.fis.arn
stop_condition {
source = "aws:cloudwatch:alarm"
value = aws_cloudwatch_metric_alarm.auth_error_budget.arn # abort if customers are harmed
}
action {
name = "stop-primary"
action_id = "aws:ec2:stop-instances"
target { key = "Instances" value = "ledger-primary" }
}
target {
name = "ledger-primary"
resource_type = "aws:ec2:instance"
selection_mode = "ALL"
resource_tag { key = "Role" value = "ledger-primary" }
}
experiment_options { account_targeting = "single-account" empty_target_resolution_mode = "fail" }
}Each experiment has a documented expected outcome. For a region loss the expected result is a recovery point objective of zero, confirmed by reconciling the last committed transaction identifier on both sides, and a recovery time objective measured in seconds, confirmed by the gap in the authorization success metric. Results are recorded and reviewed, and a regression in either objective blocks the next release train.