<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Victor Ojeje</title>
    <description>The latest articles on DEV Community by Victor Ojeje (@escanut).</description>
    <link>https://dev.to/escanut</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3717092%2Feb83272c-b400-4201-93bc-c3d414522d38.jpeg</url>
      <title>DEV Community: Victor Ojeje</title>
      <link>https://dev.to/escanut</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/escanut"/>
    <language>en</language>
    <item>
      <title>I Built On-Prem Payment Infrastructure for a PCI DSS SAQ A-EP Audit. Here's Exactly How.</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:42:30 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-on-prem-payment-infrastructure-for-a-pci-dss-saq-a-ep-audit-heres-exactly-how-ajc</link>
      <guid>https://dev.to/escanut/i-built-on-prem-payment-infrastructure-for-a-pci-dss-saq-a-ep-audit-heres-exactly-how-ajc</guid>
      <description>&lt;p&gt;Most engineers who deal with PCI DSS treat compliance as something that happens &lt;em&gt;after&lt;/em&gt; the infrastructure exists: build the thing, then ask someone to audit it. That backwards process is why compliance engagements are painful and expensive.&lt;/p&gt;

&lt;p&gt;For this project I reversed it. The architecture was designed specifically to qualify for SAQ A-EP first, and then every technical control was built and documented against the corresponding PCI DSS v4.0.1 requirement. The output is a completed Self-Assessment Questionnaire (SAQ A-EP) and a signed Attestation of Compliance (AOC), not just working infrastructure.&lt;/p&gt;

&lt;p&gt;This post covers what SAQ A-EP actually requires, why the architecture I chose qualifies for it, and exactly how each control was implemented in code. AWS equivalents are called out throughout because the principles transfer directly even though the tooling differs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What SAQ A-EP Is and Why It Matters
&lt;/h2&gt;

&lt;p&gt;PCI DSS defines several SAQ forms based on how a merchant handles cardholder data. Most engineers are familiar with SAQ A (fully outsourced payment pages, simplest) and SAQ D (you store, process, or transmit PANs, heaviest). SAQ A-EP sits between them.&lt;/p&gt;

&lt;p&gt;SAQ A-EP applies when all three of the following are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your own server hosts the payment page&lt;/li&gt;
&lt;li&gt;All actual card data entry happens inside a PCI-certified third party's environment (like Stripe's Embedded Checkout)&lt;/li&gt;
&lt;li&gt;Your servers never receive, process, or store PANs, CVVs, or track data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WorkUp is a fictional B2B project-management SaaS charging $49/month per workspace with roughly 150,000 transactions per year. SAQ A-EP is the correct form. The payment page (&lt;code&gt;billing.html&lt;/code&gt;) loads from WorkUp's own nginx. The card entry form is a Stripe-controlled iframe served from &lt;code&gt;js.stripe.com&lt;/code&gt;. The Go API never receives card data. It only creates a Stripe Checkout Session and receives a webhook event after payment completes.&lt;/p&gt;

&lt;p&gt;Because of this, Requirements 3 (stored card data), 4 (card data in transit), and 9 (physical access) are legitimately Not Applicable. Requirements 1, 2, 5, 6, 7, 8, 10, and 11 still apply in full to the systems hosting the payment page and supporting infrastructure.&lt;/p&gt;

&lt;p&gt;That scope decision is the entire foundation of this project. Everything else builds on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: Four Isolated VMs, Zero LAN Traffic
&lt;/h2&gt;

&lt;p&gt;The infrastructure runs on a self-hosted Proxmox node with four Ubuntu 22.04 VMs, each serving one segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend VM  (192.168.123.21)  nginx serving static HTML/JS
Auth VM      (192.168.123.22)  Keycloak 26 + Postgres 18
Backend VM   (192.168.123.23)  Go/Gin API + Postgres 18
Obs VM       (192.168.123.24)  Grafana + Prometheus + Loki + Grafana Alloy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design choice: none of these VMs talk to each other over the internal LAN. Every inter-segment call goes through its public Cloudflare Tunnel URL. The frontend calls &lt;code&gt;https://api.victorojeje.xyz&lt;/code&gt;, not &lt;code&gt;http://192.168.123.23:8081&lt;/code&gt;. Auth tokens, API calls, log shipping: all of it exits through Cloudflare and comes back in through the tunnel on the destination VM.&lt;/p&gt;

&lt;p&gt;This has a direct compliance consequence. PCI DSS Req 1 requires network segmentation. In most setups that means careful firewall rules to control east-west traffic between segments. Here, there &lt;em&gt;is&lt;/em&gt; no east-west traffic. The default-DROP firewall policy on each VM is correct with zero application-level exceptions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; In AWS this maps to placing each segment in its own subnet with a default-deny Network Access Control List (NACL), and using VPC Endpoints or PrivateLink for inter-service communication instead of direct subnet routing. The principle is the same: make the default behavior secure, then open only what's necessary.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 1: Provisioning Infrastructure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Proxmox Template
&lt;/h3&gt;

&lt;p&gt;Before Terraform provisions anything, a base VM template needs to exist. &lt;code&gt;infra/template.sh&lt;/code&gt; handles this once. It downloads the Ubuntu 22.04 Jammy cloud image, uploads it to Proxmox, boots a VM, installs Docker and the QEMU guest agent, then converts it to a Proxmox template.&lt;/p&gt;

&lt;p&gt;The script solves a real problem that breaks naively-written provisioning: Ubuntu cloud images fire &lt;code&gt;apt-daily.timer&lt;/code&gt; with &lt;code&gt;Persistent=true&lt;/code&gt; on first boot, which holds &lt;code&gt;/var/lib/dpkg/lock-frontend&lt;/code&gt; and makes &lt;code&gt;apt install&lt;/code&gt; fail immediately. The fix runs before touching apt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop apt-daily.service apt-daily-upgrade.service &lt;span class="se"&gt;\&lt;/span&gt;
  apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;systemctl mask apt-daily.service apt-daily-upgrade.service &lt;span class="se"&gt;\&lt;/span&gt;
  apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service

&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;DEBIAN_FRONTEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;noninteractive apt-get &lt;span class="nt"&gt;-o&lt;/span&gt; DPkg::Lock::Timeout&lt;span class="o"&gt;=&lt;/span&gt;120 update &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;DEBIAN_FRONTEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;noninteractive apt-get &lt;span class="nt"&gt;-o&lt;/span&gt; DPkg::Lock::Timeout&lt;span class="o"&gt;=&lt;/span&gt;120 &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  qemu-guest-agent curl ca-certificates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Docker installs, the VM shuts down and becomes a template with &lt;code&gt;ciupgrade=0&lt;/code&gt; set, which stops cloud-init from running package upgrades every time a VM is cloned from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terraform VM Provisioning
&lt;/h3&gt;

&lt;p&gt;Provider: &lt;code&gt;bpg/proxmox&lt;/code&gt; v0.112.0. The older &lt;code&gt;telmate/proxmox&lt;/code&gt; provider is unmaintained. &lt;code&gt;bpg/proxmox&lt;/code&gt; has proper cloud-init support, a cleaner resource model, and active maintenance.&lt;/p&gt;

&lt;p&gt;All four VMs follow the same pattern in &lt;code&gt;clones.tf&lt;/code&gt;. They clone from the template with a full clone (not linked), get a static IP, and sized memory based on what each service actually needs: 768 MB for nginx, 1536 MB for Keycloak's JVM, 1024 MB for Go, 1536 MB for the full observability stack.&lt;/p&gt;

&lt;p&gt;The single most important line in the VM definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;network_device&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bridge&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vmbr0"&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"virtio"&lt;/span&gt;
  &lt;span class="nx"&gt;firewall&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# without this, firewall.tf rules exist but never enforce&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;firewall = true&lt;/code&gt; tells Proxmox to attach the NIC-level firewall policy to that specific network interface. Without this flag, you can define all the rules you want in &lt;code&gt;firewall.tf&lt;/code&gt; and they will be stored in Proxmox's API but never evaluated. This was a real misconfiguration found mid-project; the auth, backend, and observability VMs were missing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proxmox Firewall as Code
&lt;/h3&gt;

&lt;p&gt;The firewall runs at three levels: Datacenter, Node, and per-VM. All three are declared in &lt;code&gt;firewall.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Define who is allowed to manage things&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_firewall_ipset"&lt;/span&gt; &lt;span class="s2"&gt;"management"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"management"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"192.168.123.132/32"&lt;/span&gt;
    &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Lab / management network"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Datacenter-level: drop everything by default&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_cluster_firewall"&lt;/span&gt; &lt;span class="s2"&gt;"cluster"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;input_policy&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DROP"&lt;/span&gt;
  &lt;span class="nx"&gt;output_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACCEPT"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Cluster-level rules: Proxmox GUI + SSH from management only&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_firewall_rules"&lt;/span&gt; &lt;span class="s2"&gt;"cluster_mgmt"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACCEPT"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"+management"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"8006"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACCEPT"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"+management"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;   &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACCEPT"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"+management"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmp"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Reusable security groups attached to each VM&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_cluster_firewall_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"mgmt_ssh"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mgmt-ssh"&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACCEPT"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"+management"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Enable DROP on each VM, attach security groups&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_firewall_options"&lt;/span&gt; &lt;span class="s2"&gt;"frontend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_name&lt;/span&gt;
  &lt;span class="nx"&gt;vm_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm_id&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;input_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DROP"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_firewall_rules"&lt;/span&gt; &lt;span class="s2"&gt;"frontend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_name&lt;/span&gt;
  &lt;span class="nx"&gt;vm_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm_id&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;security_group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_cluster_firewall_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mgmt_ssh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;iface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"net0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;security_group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxmox_virtual_environment_cluster_firewall_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mgmt_icmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;iface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"net0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: all four VMs drop all inbound traffic except SSH and ICMP from the management machine. No application ports are reachable from the LAN. Public traffic reaches services through Cloudflare Tunnels only.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; This maps to a layered defense using NACLs (stateless, subnet-level, applied to all traffic entering the subnet) and Security Groups (stateful, instance-level, applied to specific ENIs). The three-level Proxmox hierarchy mirrors that model: cluster policy is the NACL equivalent, per-VM policy is the Security Group equivalent, and security groups in Proxmox are reusable rule sets like AWS managed prefix lists attached to SGs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 2: Hardening Every VM With Ansible
&lt;/h2&gt;

&lt;p&gt;Four playbooks, one per VM segment. Every playbook runs the same task sequence before deploying its application: &lt;code&gt;host_firewall.yml&lt;/code&gt;, &lt;code&gt;os_hardening.yml&lt;/code&gt;, &lt;code&gt;auditd.yml&lt;/code&gt;, &lt;code&gt;aide.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest OS Firewall (ufw)
&lt;/h3&gt;

&lt;p&gt;The Proxmox NIC firewall is one layer. ufw inside each guest OS is a second, independent layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tasks/host_firewall.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reset ufw to defaults&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reset&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set default incoming policy to deny&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;incoming&lt;/span&gt;
    &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow SSH from management network only&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;22"&lt;/span&gt;
    &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;management_network&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enable ufw&lt;/span&gt;
  &lt;span class="na"&gt;community.general.ufw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;enabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no ICMP rule in ufw. The &lt;code&gt;community.general.ufw&lt;/code&gt; module doesn't handle ICMP cleanly, and the Proxmox firewall already allows management ICMP. Adding a poorly-written ICMP rule in ufw adds complexity without benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Docker Port Publishing Problem
&lt;/h3&gt;

&lt;p&gt;Docker bypasses ufw by writing directly to &lt;code&gt;iptables PREROUTING&lt;/code&gt;. Any &lt;code&gt;ports:&lt;/code&gt; mapping in a Compose file exposes that port to the LAN regardless of ufw's default-deny. This means a &lt;code&gt;ports: ["8081:8081"]&lt;/code&gt; entry on the API container makes port 8081 reachable from any machine on the LAN, even with &lt;code&gt;ufw default deny incoming&lt;/code&gt; in place.&lt;/p&gt;

&lt;p&gt;The fix: no &lt;code&gt;ports:&lt;/code&gt; block on any application service. &lt;code&gt;cloudflared&lt;/code&gt; reaches the API container via Docker's internal service DNS (&lt;code&gt;http://api:8081&lt;/code&gt;) without the port being published. The Keycloak container uses &lt;code&gt;127.0.0.1:8080:8080&lt;/code&gt; to bind to the host loopback only for local bootstrap access, and cloudflared reaches it via internal DNS anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  OS Hardening
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tasks/os_hardening.yml (condensed)&lt;/span&gt;

&lt;span class="c1"&gt;# Scope ubuntu's sudo: removes cloud-init's dangerous NOPASSWD: ALL default&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Scope ubuntu sudo to required commands only&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/sudoers.d/90-ubuntu-scoped&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;ubuntu ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/systemctl, /usr/sbin/ufw&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0440"&lt;/span&gt;
    &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visudo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-cf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;%s"&lt;/span&gt;   &lt;span class="c1"&gt;# validate before writing, prevents sudo lockout&lt;/span&gt;

&lt;span class="c1"&gt;# SSH hardening&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Harden sshd_config&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.lineinfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssh/sshd_config&lt;/span&gt;
    &lt;span class="na"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.regexp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.line&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/sbin/sshd -t -f %s&lt;/span&gt;   &lt;span class="c1"&gt;# test config before writing&lt;/span&gt;
  &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?PermitRootLogin'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;        &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PermitRootLogin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?PasswordAuthentication'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PasswordAuthentication&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?MaxAuthTries'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;           &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MaxAuthTries&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?X11Forwarding'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;          &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;X11Forwarding&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?AllowTcpForwarding'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;     &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AllowTcpForwarding&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?ClientAliveInterval'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;    &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ClientAliveInterval&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;300'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;regexp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^#?ClientAliveCountMax'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;    &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ClientAliveCountMax&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart ssh&lt;/span&gt;

&lt;span class="c1"&gt;# Kernel hardening via sysctl&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Apply sysctl hardening&lt;/span&gt;
  &lt;span class="na"&gt;ansible.posix.sysctl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;sysctl_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/sysctl.d/99-hardening.conf&lt;/span&gt;
  &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kernel.randomize_va_space'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;           &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# ASLR&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;net.ipv4.tcp_syncookies'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;             &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# SYN flood protection&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;net.ipv4.conf.all.rp_filter'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;         &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# reverse path filtering&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;net.ipv4.conf.all.accept_redirects'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;  &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# no ICMP redirects&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;net.ipv4.conf.all.send_redirects'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;    &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fs.suid_dumpable'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;                    &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# no setuid core dumps&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kernel.dmesg_restrict'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;               &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# non-root can't read dmesg&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kernel.kptr_restrict'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;                &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# hide kernel pointers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;validate&lt;/code&gt; on &lt;code&gt;sshd_config&lt;/code&gt; changes is not optional. Running &lt;code&gt;sshd -t&lt;/code&gt; before writing the file ensures you never write a config that breaks SSH access. That validation step has saved this project from a lockout at least once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; Ansible playbooks here are functionally equivalent to AWS Systems Manager (SSM) State Manager associations. SSM State Manager applies configurations on a schedule to EC2 instances, and SSM Parameter Store holds secrets the same way &lt;code&gt;vars/secrets.yml&lt;/code&gt; (Ansible Vault) does here. The sysctl and sshd hardening tasks map directly to EC2 user data scripts or SSM Run Command documents.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 3: Audit Logging
&lt;/h2&gt;

&lt;p&gt;Two controls here. Both are required by PCI DSS. Both ship their output to the central observability stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  auditd: Kernel-Level Audit (PCI Req 10.2.1)
&lt;/h3&gt;

&lt;p&gt;auditd is installed on all four VMs and configured with rules that map directly to PCI DSS Req 10.2.1 sub-requirements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# infra/ansible/playbooks/files/pci-audit.rules&lt;/span&gt;

&lt;span class="c"&gt;## 10.2.1.6: detect tampering with the audit subsystem itself&lt;/span&gt;
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/audit/ &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; audit_config
&lt;span class="nt"&gt;-w&lt;/span&gt; /sbin/auditctl &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; audit_tools
&lt;span class="nt"&gt;-w&lt;/span&gt; /sbin/auditd &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; audit_tools

&lt;span class="c"&gt;## 10.2.1.5: identity and authentication file changes&lt;/span&gt;
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/passwd &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; identity
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/shadow &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; identity
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/sudoers &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; identity
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/sudoers.d/ &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; identity
&lt;span class="nt"&gt;-w&lt;/span&gt; /etc/ssh/sshd_config &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; sshd_config

&lt;span class="c"&gt;## 10.2.1.2: privileged command execution&lt;/span&gt;
&lt;span class="nt"&gt;-w&lt;/span&gt; /usr/bin/sudo &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; privileged
&lt;span class="nt"&gt;-w&lt;/span&gt; /usr/bin/su &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; privileged
&lt;span class="nt"&gt;-w&lt;/span&gt; /usr/sbin/useradd &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; privileged
&lt;span class="nt"&gt;-w&lt;/span&gt; /usr/sbin/userdel &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; privileged
&lt;span class="nt"&gt;-w&lt;/span&gt; /usr/sbin/usermod &lt;span class="nt"&gt;-p&lt;/span&gt; x &lt;span class="nt"&gt;-k&lt;/span&gt; privileged

&lt;span class="c"&gt;## 10.2.1.3: access to the audit log itself&lt;/span&gt;
&lt;span class="nt"&gt;-w&lt;/span&gt; /var/log/audit/ &lt;span class="nt"&gt;-p&lt;/span&gt; rwa &lt;span class="nt"&gt;-k&lt;/span&gt; audit_log_access

&lt;span class="c"&gt;## 10.2.1.4: failed login tracking&lt;/span&gt;
&lt;span class="nt"&gt;-w&lt;/span&gt; /var/log/faillog &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; logins
&lt;span class="nt"&gt;-w&lt;/span&gt; /var/run/faillock/ &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; logins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-p wa&lt;/code&gt; flag watches for writes and attribute changes. &lt;code&gt;-p x&lt;/code&gt; watches for execution. &lt;code&gt;-k&lt;/code&gt; is the key used to filter audit events when searching logs. These map one-to-one to the SAQ A-EP sub-requirements, which matters when filling in the questionnaire; you can point to a specific rule file, not just say "yes we log things."&lt;/p&gt;

&lt;p&gt;The Ansible task that deploys these rules uses a handler to reload them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tasks/auditd.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy PCI audit rules&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;files/pci-audit.rules&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/audit/rules.d/pci-dss.rules&lt;/span&gt;
    &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0640"&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reload auditd rules&lt;/span&gt;   &lt;span class="c1"&gt;# handler: augenrules --load&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  AIDE: File Integrity Monitoring (PCI Req 11.5.2)
&lt;/h3&gt;

&lt;p&gt;AIDE (Advanced Intrusion Detection Environment) runs on all four VMs. The configuration template defines what to monitor and what to exclude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# aide.conf.j2 (relevant portions)
&lt;/span&gt;&lt;span class="n"&gt;FULL&lt;/span&gt;   = &lt;span class="n"&gt;p&lt;/span&gt;+&lt;span class="n"&gt;i&lt;/span&gt;+&lt;span class="n"&gt;n&lt;/span&gt;+&lt;span class="n"&gt;u&lt;/span&gt;+&lt;span class="n"&gt;g&lt;/span&gt;+&lt;span class="n"&gt;s&lt;/span&gt;+&lt;span class="n"&gt;b&lt;/span&gt;+&lt;span class="n"&gt;m&lt;/span&gt;+&lt;span class="n"&gt;c&lt;/span&gt;+&lt;span class="n"&gt;sha256&lt;/span&gt;   &lt;span class="c"&gt;# perms+inode+links+user+group+size+blocks+mtime+ctime+hash
&lt;/span&gt;&lt;span class="n"&gt;BINLIB&lt;/span&gt; = &lt;span class="n"&gt;p&lt;/span&gt;+&lt;span class="n"&gt;u&lt;/span&gt;+&lt;span class="n"&gt;g&lt;/span&gt;+&lt;span class="n"&gt;sha256&lt;/span&gt;                &lt;span class="c"&gt;# for binaries: just identity + hash
&lt;/span&gt;&lt;span class="n"&gt;CONFIG&lt;/span&gt; = &lt;span class="n"&gt;p&lt;/span&gt;+&lt;span class="n"&gt;u&lt;/span&gt;+&lt;span class="n"&gt;g&lt;/span&gt;+&lt;span class="n"&gt;n&lt;/span&gt;+&lt;span class="n"&gt;sha256&lt;/span&gt;              &lt;span class="c"&gt;# for config files: + symlinks
&lt;/span&gt;
/&lt;span class="n"&gt;boot&lt;/span&gt;           &lt;span class="n"&gt;BINLIB&lt;/span&gt;
/&lt;span class="n"&gt;bin&lt;/span&gt;            &lt;span class="n"&gt;BINLIB&lt;/span&gt;
/&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;bin&lt;/span&gt;        &lt;span class="n"&gt;BINLIB&lt;/span&gt;
/&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;sbin&lt;/span&gt;       &lt;span class="n"&gt;BINLIB&lt;/span&gt;
/&lt;span class="n"&gt;etc&lt;/span&gt;            &lt;span class="n"&gt;CONFIG&lt;/span&gt;
/&lt;span class="n"&gt;opt&lt;/span&gt;            &lt;span class="n"&gt;BINLIB&lt;/span&gt;     &lt;span class="c"&gt;# where Docker Compose stacks live
&lt;/span&gt;
&lt;span class="c"&gt;# Exclude volatile paths: these change constantly and would flood alerts
&lt;/span&gt;!/&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;lib&lt;/span&gt;/&lt;span class="n"&gt;docker&lt;/span&gt;
!/&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;log&lt;/span&gt;
!/&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;cache&lt;/span&gt;
!/&lt;span class="n"&gt;proc&lt;/span&gt;
!/&lt;span class="n"&gt;run&lt;/span&gt;
!/&lt;span class="n"&gt;tmp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exclusions are as important as the inclusions. Without excluding &lt;code&gt;/var/log&lt;/code&gt; and &lt;code&gt;/var/lib/docker&lt;/code&gt;, every Docker layer pull and every log rotation would trigger a false positive. The point is detecting &lt;em&gt;unexpected&lt;/em&gt; changes to binaries and configuration, not logging every file write.&lt;/p&gt;

&lt;p&gt;The Ansible task initializes the baseline and installs the daily cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tasks/aide.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Initialize AIDE database (first run only)&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aideinit&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;creates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/aide/aide.db&lt;/span&gt;   &lt;span class="c1"&gt;# idempotent: skip if db already exists&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Daily AIDE integrity check&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AIDE&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;daily&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;integrity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check"&lt;/span&gt;
    &lt;span class="na"&gt;minute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;17"&lt;/span&gt;
    &lt;span class="na"&gt;hour&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/aide&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/var/log/aide/aide-check.log&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&amp;gt;&amp;amp;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron runs at 03:17, not 03:00. Running at exactly :00 means competing with every other cron job that defaults to that time. The &lt;code&gt;|| true&lt;/code&gt; prevents a non-zero exit from AIDE (which reports differences, not errors) from looking like a cron failure.&lt;/p&gt;

&lt;p&gt;Grafana Alloy ships the AIDE log file to Loki. Changes to monitored files show up in Grafana immediately.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; In AWS, file integrity monitoring at this level combines AWS Config (detects configuration drift on EC2 instances), AWS CloudTrail with CloudTrail Insights (detects unusual API patterns), and Amazon GuardDuty (anomaly detection). For a direct equivalent of AIDE's file hash comparison, Amazon Inspector also includes a reachability and package vulnerability component. None of these is a 1:1 match for AIDE's low-level file monitoring, which is why on-prem compliance often runs AIDE even in hybrid environments.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 4: Identity and Access
&lt;/h2&gt;

&lt;p&gt;Keycloak 26 runs on the Auth VM in production mode (&lt;code&gt;start&lt;/code&gt;, not &lt;code&gt;start-dev&lt;/code&gt;). Everything is configured through a bootstrap script that calls the Keycloak Admin REST API, with no manual GUI steps after the first deploy.&lt;/p&gt;

&lt;p&gt;Why REST API instead of a realm JSON export? An exported JSON file becomes a maintenance burden. When one setting needs updating, you diff and re-import the whole thing. The bootstrap script is idempotent: it checks whether each resource exists, creates it if missing, and updates it if present. The same &lt;code&gt;REALM_SETTINGS&lt;/code&gt; block handles both the initial create (&lt;code&gt;POST&lt;/code&gt;) and subsequent updates (&lt;code&gt;PUT&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# app/auth/bootstrap.sh: realm settings (key security parameters shown)&lt;/span&gt;
&lt;span class="nv"&gt;REALM_SETTINGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "realm": "workup",
  "enabled": true,
  "accessTokenLifespan": 300,
  "ssoSessionIdleTimeout": 900,
  "ssoSessionMaxLifespan": 28800,
  "verifyEmail": true,
  "passwordPolicy": "length(12) and upperCase(1) and lowerCase(1) and digits(1) and specialChars(1) and notUsername and passwordHistory(5)",
  "bruteForceProtected": true,
  "failureFactor": 5,
  "maxFailureWaitSeconds": 1800,
  "smtpServer": {
    "host": "smtp.resend.com",
    "port": "465",
    "ssl": "true",
    "auth": "true",
    "user": "resend",
    "password": "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These values map directly to PCI DSS requirements: password complexity maps to Req 8.3.6, brute-force protection maps to Req 8.3.4, session timeouts map to Req 8.2.8.&lt;/p&gt;

&lt;p&gt;TOTP is configured as a required action on the realm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:8080/admin/realms/workup/authentication/required-actions/CONFIGURE_TOTP"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ADMIN_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "alias": "CONFIGURE_TOTP",
    "enabled": true,
    "defaultAction": true,
    "priority": 10
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;defaultAction: true&lt;/code&gt; means every new user is prompted to configure an authenticator app on first login. This satisfies PCI DSS Req 8.4 (MFA for all accounts with access to the cardholder data environment).&lt;/p&gt;

&lt;p&gt;The RBAC model is deliberately minimal. One realm role: &lt;code&gt;admin&lt;/code&gt;. It is only meaningful in systems that read it from the JWT; currently only Grafana (&lt;code&gt;role_attribute_path = "contains(realm_access.roles[*], 'admin')"&lt;/code&gt; with &lt;code&gt;role_attribute_strict = true&lt;/code&gt;). An early design had four roles: &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;billing-ops&lt;/code&gt;, &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;developer&lt;/code&gt;. &lt;code&gt;billing-ops&lt;/code&gt; maps to Stripe, &lt;code&gt;developer&lt;/code&gt; maps to GitLab. Neither system authenticates through Keycloak. Creating Keycloak roles for them produces dead configuration that looks good on paper but enforces nothing. The correct approach is to document each system's own access controls separately, which is what the evidence docs do.&lt;/p&gt;

&lt;p&gt;Three OIDC clients are created by the bootstrap: &lt;code&gt;workup-frontend&lt;/code&gt; (public, PKCE S256, standard flow only), &lt;code&gt;cloudflare-access&lt;/code&gt; (confidential, used as the IdP for Cloudflare Zero Trust), and &lt;code&gt;grafana&lt;/code&gt; (confidential, for Grafana OAuth). Direct access grants are disabled on all three.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; Amazon Cognito User Pools is the managed equivalent of Keycloak here. It supports OIDC/OAuth2, PKCE, built-in MFA (TOTP via software tokens), and password policies configured in the pool settings. The bootstrap.sh pattern maps to Cognito's API or Terraform's &lt;code&gt;aws_cognito_user_pool&lt;/code&gt; resource. One meaningful difference: Cognito doesn't have an equivalent of Keycloak's Admin REST API for idempotent realm bootstrapping; you use Terraform or CDK instead, which has the same idempotency property.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 5: The Payment Flow
&lt;/h2&gt;

&lt;p&gt;This is the architectural core of the SAQ A-EP eligibility. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authenticated user opens &lt;code&gt;billing.html&lt;/code&gt; (served by nginx on the Frontend VM)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;billing.js&lt;/code&gt; calls &lt;code&gt;POST /billing/create-checkout-session&lt;/code&gt; with a Keycloak JWT in the &lt;code&gt;Authorization&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;The Go API validates the JWT, creates a Stripe Checkout Session, returns a &lt;code&gt;clientSecret&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;billing.js&lt;/code&gt; calls &lt;code&gt;stripe.initEmbeddedCheckout({ clientSecret })&lt;/code&gt; and mounts the checkout UI&lt;/li&gt;
&lt;li&gt;The card entry iframe is served from &lt;code&gt;js.stripe.com&lt;/code&gt; (Stripe's servers, not WorkUp's)&lt;/li&gt;
&lt;li&gt;The user submits card details directly to Stripe&lt;/li&gt;
&lt;li&gt;Stripe calls the backend's webhook endpoint after payment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the entire basis for the N/A decisions on Req 3, 4, and 9. Card data (PAN, CVV, expiry) is submitted directly from the browser to Stripe's servers. WorkUp's servers are not in that data path.&lt;/p&gt;

&lt;p&gt;The backend's JWT validation uses &lt;code&gt;github.com/MicahParks/keyfunc/v3&lt;/code&gt; to fetch and cache the JWKS from Keycloak's public endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// app/backend/internal/auth/auth.go&lt;/span&gt;
&lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;keyfunc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDefaultCtx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeycloakIssuerURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/protocol/openid-connect/certs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Keyfunc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithValidMethods&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithIssuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeycloakIssuerURL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithAudience&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeycloakClientID&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It validates algorithm (RS256), signature (against Keycloak's public keys), issuer, and audience. A rejected token is logged and counted as a Prometheus metric.&lt;/p&gt;

&lt;p&gt;The Stripe webhook endpoint at &lt;code&gt;POST /billing/webhook&lt;/code&gt; verifies the &lt;code&gt;Stripe-Signature&lt;/code&gt; header before processing any event. A Cloudflare Custom Rule also blocks any POST to &lt;code&gt;/billing/webhook&lt;/code&gt; that arrives without the &lt;code&gt;stripe-signature&lt;/code&gt; header; unsigned payloads are rejected at the CDN edge before reaching the backend.&lt;/p&gt;

&lt;p&gt;The database schema confirms the scope in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- app/backend/db/schema.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;workspaces&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;keycloak_sub&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                     &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;workspace_id&lt;/span&gt;           &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;workspaces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;stripe_customer_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stripe_subscription_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;                 &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No PAN fields. No &lt;code&gt;card_last4&lt;/code&gt;. No CVV. No expiry date. Not even a placeholder column. This was confirmed by direct code review, not assumed from the Stripe integration model. That distinction matters when filling in the SAQ; you have to be able to point to evidence, not just assert.&lt;/p&gt;

&lt;p&gt;The Stripe secret key in use is a Restricted key scoped to only the operations the backend performs. The project started with a Standard key and rotated to a Restricted key mid-project when the gap was identified. Standard keys grant full Stripe API access; a restricted key for checkout sessions and webhook processing is the correct least-privilege approach.&lt;/p&gt;

&lt;p&gt;The Go backend runs in a multi-stage Docker container. Builder stage uses &lt;code&gt;golang:1.27-alpine&lt;/code&gt; with &lt;code&gt;CGO_ENABLED=0 GOOS=linux&lt;/code&gt; and &lt;code&gt;-ldflags="-s -w"&lt;/code&gt; to produce a statically linked binary with no debug symbols. The final stage uses &lt;code&gt;alpine:3.20&lt;/code&gt; with a non-root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.27-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; git ca-certificates
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux go build &lt;span class="nt"&gt;-ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-s -w"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /workup-backend ./cmd/server

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.20&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; ca-certificates
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-g&lt;/span&gt; 1000 workup &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-u&lt;/span&gt; 1000 &lt;span class="nt"&gt;-G&lt;/span&gt; workup &lt;span class="nt"&gt;-D&lt;/span&gt; workup
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /workup-backend /app/workup-backend&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; workup:workup /app
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/app/workup-backend"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; In AWS, this payment architecture would put the API behind an ALB with WAF rules (equivalent to the Cloudflare Custom Rules). The Stripe webhook signature verification is identical. Secrets (Stripe keys, Keycloak client credentials) would live in AWS Secrets Manager rather than Ansible Vault-encrypted files, with IAM roles granting the EC2 instance access. The JWT validation library and logic are the same regardless of where Keycloak runs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 6: Observability
&lt;/h2&gt;

&lt;p&gt;Grafana Alloy runs on all four VMs. On the three non-observability VMs, it ships four log sources to the central Loki instance: container stdout/stderr (via Docker socket), systemd journal, auditd output, and AIDE check reports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# app/auth/alloy-config.river (pattern used on all non-obs VMs)

# Ship container logs
discovery.docker "containers" {
  host = "unix:///var/run/docker.sock"
}

loki.source.docker "default" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.relabel.containers.output
  forward_to = [loki.write.central.receiver]
}

# Ship auditd logs
local.file_match "audit" {
  path_targets = [{"__path__" = "/var/log/audit/audit.log"}]
}
loki.source.file "audit" {
  targets    = local.file_match.audit.targets
  forward_to = [loki.relabel.audit.receiver]
}

# Ship AIDE check reports
local.file_match "aide" {
  path_targets = [{"__path__" = "/var/log/aide/*.log"}]
}
loki.source.file "aide_logs" {
  targets    = local.file_match.aide.targets
  forward_to = [loki.relabel.aide.receiver]
}

# All remote writes include Cloudflare Access service token headers
loki.write "central" {
  endpoint {
    url = "https://logs.victorojeje.xyz/loki/api/v1/push"
    headers = {
      "CF-Access-Client-Id"     = env("CF_ACCESS_CLIENT_ID"),
      "CF-Access-Client-Secret" = env("CF_ACCESS_CLIENT_SECRET"),
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;logs.victorojeje.xyz&lt;/code&gt; URL is a Cloudflare Tunnel that reaches Loki on the observability VM. The CF_ACCESS headers carry a Cloudflare service token that authenticates Alloy to the tunnel; random internet traffic trying to write to Loki over that URL gets rejected.&lt;/p&gt;

&lt;p&gt;The observability VM's own Alloy writes locally (same Docker network as Loki and Prometheus). No CF_ACCESS headers needed.&lt;/p&gt;

&lt;p&gt;Loki's retention is set to exactly 365 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/observability/loki-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;limits_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;retention_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;8760h&lt;/span&gt;   &lt;span class="c1"&gt;# 365 days: PCI Req 10.5.1 requires 12 months minimum&lt;/span&gt;

&lt;span class="na"&gt;compactor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;compaction_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;retention_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;retention_delete_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;8760h&lt;/code&gt; is 365 days. Not "approximately 12 months": exactly 365 days. PCI Req 10.5.1 requires 12 months. Any rounding that lands below 365 is a compliance gap.&lt;/p&gt;

&lt;p&gt;Grafana is accessed via Cloudflare Access Auth Proxy, not a second OIDC login. When a user hits &lt;code&gt;grafana.victorojeje.xyz&lt;/code&gt;, Cloudflare Access authenticates them (via Keycloak or OTP). If they pass, Cloudflare forwards the request to Grafana with the &lt;code&gt;Cf-Access-Authenticated-User-Email&lt;/code&gt; header set. Grafana reads that header and signs the user in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/observability/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_PROXY_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_PROXY_HEADER_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cf-Access-Authenticated-User-Email"&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_PROXY_AUTO_SIGN_UP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_DISABLE_LOGIN_FORM&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_ANONYMOUS_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
    &lt;span class="na"&gt;GF_AUTH_BASIC_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The earlier implementation used Keycloak OIDC for Grafana. It caused a double-login: Cloudflare Access prompts for credentials, then Grafana's OIDC redirect triggers another Keycloak login. Auth Proxy eliminates the redundant prompt while keeping the access gate at the Cloudflare edge.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; Amazon CloudWatch Logs is the managed equivalent of Loki here. Set the log group retention policy to 365 days (&lt;code&gt;aws logs put-retention-policy --log-group-name /workup/audit --retention-in-days 365&lt;/code&gt;). AWS CloudTrail provides API-level audit logging equivalent to what auditd captures at the OS level. Amazon OpenSearch Service (or the Grafana integration with CloudWatch) replaces the local Grafana + Loki stack. The Grafana Auth Proxy pattern maps to Cognito-based authentication in AWS CloudWatch dashboards or directly in managed Grafana on AWS.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 7: CI/CD Security
&lt;/h2&gt;

&lt;p&gt;GitHub is the code source of truth. On every push, a GitHub Actions workflow mirrors the code to GitLab, where the full CI pipeline runs.&lt;/p&gt;

&lt;p&gt;The mirror strips &lt;code&gt;.github/&lt;/code&gt; before pushing; GitHub Actions config doesn't belong on GitLab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/mirror-to-gitlab.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push to GitLab without .github&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;git checkout -b mirror-temp&lt;/span&gt;
    &lt;span class="s"&gt;git rm -r .github&lt;/span&gt;
    &lt;span class="s"&gt;git commit -m "temp: remove .github for GitLab mirror"&lt;/span&gt;
    &lt;span class="s"&gt;git push gitlab mirror-temp:main --force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitLab CI pipeline runs five jobs across three stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitlab-ci.yml (structure)&lt;/span&gt;
&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Security/SAST.gitlab-ci.yml&lt;/span&gt;        &lt;span class="c1"&gt;# Semgrep: Go source analysis&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Security/Secret-Detection.gitlab-ci.yml&lt;/span&gt;

&lt;span class="na"&gt;govulncheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang:1.27&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;go install golang.org/x/vuln/cmd/govulncheck@latest&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;govulncheck ./...&lt;/span&gt;    &lt;span class="c1"&gt;# checks Go deps against the Go vuln database&lt;/span&gt;

&lt;span class="na"&gt;build-backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:24.0&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;24.0-dind&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build --tag "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" --tag "$BACKEND_IMAGE:latest" \&lt;/span&gt;
        &lt;span class="s"&gt;-f app/backend/Dockerfile app/backend&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push "$BACKEND_IMAGE:latest"&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$CI_COMMIT_BRANCH == "main"&lt;/span&gt;

&lt;span class="na"&gt;trivy-container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scan&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;trivy image --severity HIGH,CRITICAL \&lt;/span&gt;
        &lt;span class="s"&gt;--format json --output gl-container-scanning-report.json \&lt;/span&gt;
        &lt;span class="s"&gt;"$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gl-container-scanning-report.json&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;expire_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;govulncheck&lt;/code&gt; is specifically for Go. It checks the module's dependencies against the Go vulnerability database (&lt;code&gt;vuln.go.dev&lt;/code&gt;), not just generic CVEs. A dependency can appear clean in a general scanner like Trivy but show up in govulncheck because the vulnerability is in a code path the module actually calls.&lt;/p&gt;

&lt;p&gt;The built image is stored in the GitLab Container Registry. The Backend VM pulls it at deploy time using a GitLab deploy token scoped to &lt;code&gt;read_registry&lt;/code&gt; only (not a personal access token, not the CI runner token).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AWS parallel:&lt;/strong&gt; This pipeline maps to AWS CodePipeline triggering CodeBuild on push. The &lt;code&gt;govulncheck&lt;/code&gt; step becomes a CodeBuild phase. The Docker build and push targets Amazon ECR. Trivy is replaced by Amazon Inspector V2, which integrates natively with ECR and scans images on push. GitLab's Secret Detection maps to AWS CodeGuru Security's secret detection. The registry pull uses an IAM role attached to the EC2 instance with &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; and &lt;code&gt;ecr:BatchGetImage&lt;/code&gt; permissions, equivalent to the scoped deploy token.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What the SAQ A-EP Actually Required
&lt;/h2&gt;

&lt;p&gt;The SAQ itself is a 100-page questionnaire. Most of it is not surprising if you understand the scope. A few decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Req 3, 4, 9: Not Applicable.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
These requirements cover protecting stored card data, card data in transit, and physical access to card data. None of them apply because WorkUp never has card data. The N/A justifications are written documents (&lt;code&gt;docs/evidence/na-justifications.md&lt;/code&gt;) that explain &lt;em&gt;why&lt;/em&gt; each requirement doesn't apply, not just check a box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Req 5: Anti-malware, Not Applicable (justified).&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
PCI DSS Req 5.1 allows anti-malware to be declared not applicable for systems that are "not commonly affected by malware." These VMs have no interactive logins (SSH from management only), no browsers, no file uploads, and no email. They run containerized workloads with images scanned by Trivy in CI. The N/A justification documents the reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Req 6.4.3: Payment page scripts.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
This requirement asks for a list of all scripts on the payment page, the purpose of each, and either an integrity mechanism or a written justification for scripts that don't support it. The payment page (&lt;code&gt;billing.html&lt;/code&gt;) loads five scripts. Four are first-party. The fifth is &lt;code&gt;https://js.stripe.com/v3/&lt;/code&gt;. Stripe.js explicitly does not support SRI (Subresource Integrity); their documentation states the script updates dynamically for fraud detection. The justification for the SRI exception is Stripe's own PCI Level 1 certification status. All five scripts are inventoried in &lt;code&gt;docs/evidence/payment-page-script-inventory.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Req 8.4: MFA for all access to the cardholder data environment.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
TOTP via Keycloak covers human users. Cloudflare Access provides OTP as a backup login method. Machine-to-machine access (Alloy to Loki/Prometheus, backend to Keycloak JWKS endpoint) uses service tokens and JWT validation respectively.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things That Went Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;firewall = true&lt;/code&gt; missing on VM network interfaces.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The auth, backend, and observability VMs were defined in Terraform without &lt;code&gt;firewall = true&lt;/code&gt; on their &lt;code&gt;network_device&lt;/code&gt; blocks. The Proxmox firewall rules for those VMs existed in Terraform state but were never evaluated. Caught when checking the Proxmox GUI and noticing the shield icon was greyed out on three of four VMs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker published ports bypassing ufw.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
An early version of the backend's &lt;code&gt;docker-compose.yml&lt;/code&gt; had &lt;code&gt;ports: ["8081:8081"]&lt;/code&gt; on the &lt;code&gt;api&lt;/code&gt; service. Port 8081 was reachable from the LAN even with &lt;code&gt;ufw default deny incoming&lt;/code&gt;. Docker writes &lt;code&gt;iptables PREROUTING&lt;/code&gt; rules that the INPUT chain (where ufw operates) never sees. Fix: remove the &lt;code&gt;ports:&lt;/code&gt; block entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grafana double-login.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Using both Cloudflare Access (with Keycloak as IdP) and Grafana's own Keycloak OIDC integration created two separate login prompts. The fix was to disable Grafana's own OAuth and switch to Auth Proxy mode, where Cloudflare passes the authenticated user's email in a trusted header and Grafana reads it directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Access session duration set to "No duration, expires immediately" on the Grafana application.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
This is correct for machine-to-machine service token applications. It is wrong for user-facing applications. Every page load triggered a Cloudflare Access re-authentication. Setting the session to 24h fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres 18 volume mount path changed.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The volume for Postgres 18 mounts at &lt;code&gt;/var/lib/postgresql&lt;/code&gt; (not &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt; as in earlier versions). Using the old path created a nested directory structure where Postgres thought the data directory was uninitialized on every container restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt; accidentally hiding non-secret variable files.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
An early &lt;code&gt;.gitignore&lt;/code&gt; entry used &lt;code&gt;vars/*&lt;/code&gt;, which matched &lt;code&gt;management.yml&lt;/code&gt; and &lt;code&gt;os_hardening.yml&lt;/code&gt; in addition to &lt;code&gt;secrets.yml&lt;/code&gt;. Those files were never committed, and Ansible runs that referenced them failed silently. Changed to &lt;code&gt;vars/secrets.yml&lt;/code&gt; specifically.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;One completed SAQ A-EP, one signed AOC. The infrastructure runs. The audit trail is in Loki. AIDE runs nightly on all four VMs. The CI pipeline scans every push. The payment flow routes card data through Stripe without touching WorkUp.&lt;/p&gt;

&lt;p&gt;The point of building this was not to demonstrate that you can pass a PCI audit. The point was to demonstrate that compliance is an architectural decision, not a retrofit. When the architecture is correct, when card data genuinely never reaches your servers, the audit becomes a documentation exercise rather than a gap-remediation project.&lt;/p&gt;

&lt;p&gt;If you want to see the implementation, the repo is at &lt;code&gt;github.com/escanut/on-prem-workup-pci-dss&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Victor Ojeje is a Cloud and DevOps Engineer based in Lagos. He holds AWS SAA-C03, CompTIA Security+, and CEH certifications.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/escanut"&gt;Dev.to&lt;/a&gt; | &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>infrastructure</category>
      <category>linux</category>
    </item>
    <item>
      <title>PayCore v2: I Removed My Own High Availability, Added a Ledger, and Finally Added Monitoring</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Thu, 18 Jun 2026 11:49:39 +0000</pubDate>
      <link>https://dev.to/escanut/paycore-v2-i-removed-my-own-high-availability-added-a-ledger-and-finally-added-monitoring-5gn6</link>
      <guid>https://dev.to/escanut/paycore-v2-i-removed-my-own-high-availability-added-a-ledger-and-finally-added-monitoring-5gn6</guid>
      <description>&lt;h3&gt;
  
  
  What was wrong with v1?
&lt;/h3&gt;

&lt;p&gt;Three things.&lt;/p&gt;

&lt;p&gt;The networking was over-engineered. I built a two-node WireGuard setup where a CloudWatch alarm would trigger a Lambda to move an Elastic IP from the primary EC2 node to a standby node on failure. Looked great on paper. Then I found a gap I had documented myself: the failover Lambda moved the Elastic IP but never updated the VPC route table. The route table still pointed at EC2 node 0's ENI after failover. That means traffic from Lambda would still hit the old interface. What I had was not high availability. It was a more complicated single point of failure.&lt;/p&gt;

&lt;p&gt;The application had no real financial data model. PayCore had one table: &lt;code&gt;transaction&lt;/code&gt;. You could queue a payment, Lambda would validate it, status would get patched back. That demonstrates async processing. It does not demonstrate a payment backend. A payment backend needs accounts, ledger entries, and idempotency. Without those three things you are building a webhook relay, not a financial system.&lt;/p&gt;

&lt;p&gt;There was no observability. I could deploy the thing but I could not show I was able to operate it. Every Cloud and SRE role I have applied to at financial institutions, telcos, or larger tech companies lists Prometheus and Grafana. My project had nothing.&lt;/p&gt;

&lt;p&gt;v2 fixes all three.&lt;/p&gt;




&lt;h4&gt;
  
  
  The networking got simpler
&lt;/h4&gt;

&lt;p&gt;I removed the dual-EC2 failover setup entirely. PayCore now runs a single EC2 instance as the WireGuard gateway, one public subnet, one route table. The Elastic IP is still there for address stability. There is no failover automation.&lt;/p&gt;

&lt;p&gt;This is the honest call. At homelab scale, the two-node setup added operational complexity without actually delivering HA because of the route table gap. At real enterprise scale, the right answer is AWS Site-to-Site VPN or AWS Transit Gateway with redundant tunnels. Both handle route propagation automatically. AWS Site-to-Site VPN is around $0.05 per hour per connection, roughly $36 a month. The cost of managing WireGuard HA yourself, patching it, monitoring tunnel health, scripting route updates, and testing failover paths, does not make sense when a managed service exists that handles all of that.&lt;/p&gt;

&lt;p&gt;I am genuinely curious how people running hybrid infrastructure in financial services or telco handle this in practice. If you have done it at scale, I want to know what the actual operational overhead looked like.&lt;/p&gt;




&lt;h4&gt;
  
  
  The app now has a proper financial data model
&lt;/h4&gt;

&lt;p&gt;Three tables got added: &lt;code&gt;accounts&lt;/code&gt;, &lt;code&gt;ledger_entry&lt;/code&gt;, and &lt;code&gt;idempotency_keys&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;accounts&lt;/code&gt; is scoped per merchant per currency. Before a merchant can submit an NGN payment, they need an NGN account. Currency isolation enforced at the data layer, not just the API.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ledger_entry&lt;/code&gt; records every credit and debit against an account. Balance is computed by summing across entries, not stored as a column. Storing a running balance as a column creates race conditions under concurrent writes and makes it harder to audit individual movements.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LedgerEntry&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LedgerEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;movement_direction&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LedgerMovement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;idempotency_keys&lt;/code&gt; stores the response body against a client-supplied header. If the same key arrives twice from the same merchant, the stored response comes back without touching the database a second time. Network retries are real. A duplicate charge is a support incident. This is not optional in payment systems.&lt;/p&gt;




&lt;h4&gt;
  
  
  Prometheus and Grafana, finally
&lt;/h4&gt;

&lt;p&gt;Four exporters running in the same Docker Compose stack:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prometheus-fastapi-instrumentator&lt;/code&gt; instruments the FastAPI app at startup and exposes &lt;code&gt;/metrics&lt;/code&gt;. Prometheus scrapes it every 10 seconds. You get request count, latency histograms, and error rates per endpoint without writing custom metric code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node-exporter&lt;/code&gt; for host-level metrics. CPU, memory, disk, network. It runs with &lt;code&gt;pid: host&lt;/code&gt; and sees the host's process tree from inside the container.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cAdvisor&lt;/code&gt; (Container Advisor) for per-container resource usage. Reads directly from the Docker daemon.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;postgres-exporter&lt;/code&gt; for database internals. Connection counts, query duration, table sizes.&lt;/p&gt;

&lt;p&gt;Grafana is provisioned entirely through code. Datasource and dashboard JSON are mounted via Docker volumes at startup. No manual UI setup. Tear it down, redeploy, dashboards come back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana_data:/var/lib/grafana&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./monitoring/grafana/provisioning:/etc/grafana/provisioning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four alerts in &lt;code&gt;alerts.yml&lt;/code&gt;: API down, PostgreSQL down, host CPU above 85% for five minutes, disk below 15% free. Alertmanager is not deployed in the dev setup so these do not deliver anywhere right now. In production this routes to PagerDuty or SNS.&lt;/p&gt;

&lt;p&gt;The reason I cared about this specifically: most of the Cloud and SRE roles I have looked at in finance, oil and gas, and telco all list observability. The day to day work in those environments is not architecture. It is watching dashboards, chasing slow queries, correlating an API error spike with a disk event. This stack is proof I can do that, not just deploy infrastructure.&lt;/p&gt;




&lt;h4&gt;
  
  
  What is still not production-ready
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Terraform state is local, on disk. Production uses S3 backend with DynamoDB locking.&lt;/li&gt;
&lt;li&gt;No alertmanager target configured as to keep things simple.&lt;/li&gt;
&lt;li&gt;No settlement layer. The ledger tracks movements. There is no reconciliation job, no settlement engine, no balance sheet. The goal of this project was the infrastructure, implementing the ledger application layer was a good opportunity to delve deeper than I normally would.&lt;/li&gt;
&lt;li&gt;Secrets Manager recovery window is 0. Fast teardown for dev. Production minimum is 7 days.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Code is public: &lt;a href="https://github.com/escanut/paycore-hybrid-payment-infra" rel="noopener noreferrer"&gt;github.com/escanut/paycore-hybrid-payment-infra&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Victor Ojeje, Cloud and DevOps Engineer&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/escanut"&gt;Dev.to&lt;/a&gt; | &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>python</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>I Built a Hybrid Cloud Payment Backend: WireGuard, FastAPI, AWS Lambda, and a Proxmox VM</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sun, 24 May 2026 18:46:39 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-a-hybrid-cloud-payment-backend-wireguard-fastapi-aws-lambda-and-a-proxmox-vm-3m3n</link>
      <guid>https://dev.to/escanut/i-built-a-hybrid-cloud-payment-backend-wireguard-fastapi-aws-lambda-and-a-proxmox-vm-3m3n</guid>
      <description>&lt;p&gt;It has been a while since I posted here. One of my previous article on GitHub Actions OIDC got picked up and reposted by Dev.to's own X account, which I did not expect. That was a good signal to keep writing, but between job applications, AWS SAA-C03 prep, and a few client things, the writing fell off.&lt;/p&gt;

&lt;p&gt;The reason I am back now is a specific realisation I had while reviewing my own portfolio: every single project I have built is greenfield AWS. Clean VPCs, Lambda functions, managed services from day one. There is nothing wrong with that, but the real world is not greenfield. Most companies are running something on-premises, in a data centre, or on a server that someone's CTO refused to migrate five years ago. If I cannot architect across that boundary, I am limited.&lt;/p&gt;

&lt;p&gt;So I built Paycore: a hybrid cloud B2B payment middleware that runs a FastAPI application on an on-premises Proxmox VM, bridged to AWS serverless infrastructure via WireGuard VPN. This is not a tutorial scaffold. The infrastructure, secrets management, networking, and deployment pipeline are all production-pattern. I will walk through the architecture, the security decisions, and the parts I deliberately left imperfect and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Project Actually Is
&lt;/h2&gt;

&lt;p&gt;PayCore accepts payment requests from merchants over HTTPS, tokenises the card number, writes the transaction to a PostgreSQL database, queues it to AWS SQS (Simple Queue Service), and returns a &lt;code&gt;queued&lt;/code&gt; status immediately. An AWS Lambda function picks up the message, archives the raw payload to S3, runs fraud detection logic, and PATCHes the transaction status back to the on-premises API over the VPN tunnel.&lt;/p&gt;

&lt;p&gt;The on-prem VM has no public IP. AWS Lambda never touches the public internet to reach the API. The only path in is the WireGuard tunnel through the EC2 nodes acting as VPN gateways.&lt;/p&gt;

&lt;p&gt;This is a hybrid cloud architecture of the kind most enterprises actually run, not the simplified "everything in AWS" patterns you see in certifications. It covers secure cross-boundary networking, IAM (Identity and Access Management) scoping, event-driven async processing, secrets management, and automated failover. The constraints are real: PAN tokenisation so raw card numbers never hit the database or message queue, callback authentication to prevent spoofed status updates, and a KMS (Key Management Service) Customer Master Key encrypting both S3 objects and Secrets Manager entries.&lt;/p&gt;

&lt;p&gt;One core problem i encountered was getting Lambda to reach a private API on a Proxmox VM with no public IP. The solution is a two-node WireGuard setup where EC2 instances act purely as VPN gateways in which no application logic runs on EC2. Lambda sits inside a VPC (Virtual Private Cloud) with a route table entry pointing &lt;code&gt;10.10.0.0/24&lt;/code&gt; at EC2 node 0's ENI (Elastic Network Interface). The API port binds exclusively to the WireGuard interface on the VM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Network Topology
&lt;/h2&gt;

&lt;p&gt;A merchant sends an HTTPS request which is first handled by Cloudflare. The request passes through a Cloudflare Tunnel into an Nginx reverse proxy, which forwards it to a FastAPI application running on a Proxmox virtual machine with no public IP address.&lt;/p&gt;

&lt;p&gt;When the FastAPI service processes the request, it publishes a message to Amazon Simple Queue Service (SQS). This triggers an AWS Lambda function running inside a Virtual Private Cloud (VPC).&lt;/p&gt;

&lt;p&gt;The Lambda function needs to communicate back to the FastAPI service on the Proxmox VM. To achieve this, the VPC route table contains a rule that directs traffic for the subnet &lt;code&gt;10.10.0.0/24&lt;/code&gt; to the Elastic Network Interface (ENI) of the currently active EC2 instance.&lt;/p&gt;

&lt;p&gt;This EC2 instance acts as a WireGuard gateway. Traffic from Lambda is routed to this instance, then forwarded through a WireGuard tunnel to the Proxmox VM. The FastAPI service receives the request and processes the PATCH call to update the transaction status.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failover Mechanism
&lt;/h3&gt;

&lt;p&gt;Two EC2 instances are configured as WireGuard gateways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;EC2 node 0 is the primary and holds the Elastic IP&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;EC2 node 1 is the standby and does not hold the Elastic IP unless failover occurs&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Amazon CloudWatch monitors the health of the primary node. When it detects two consecutive failed health checks, it triggers a failover Lambda function.&lt;/p&gt;

&lt;p&gt;This function detaches the Elastic IP from the primary node and attaches it to the standby node. Because the WireGuard configuration uses the Elastic IP as its endpoint, the tunnel automatically reconnects to the new active node.&lt;/p&gt;

&lt;p&gt;The VPC route table does not change during this process. It continues to route traffic to the same ENI target logic, while the public-facing identity shifts between EC2 instances.&lt;/p&gt;




&lt;h3&gt;
  
  
  Key Design Behavior
&lt;/h3&gt;

&lt;p&gt;The system separates two concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Private routing inside the VPC remains constant and does not require updates during failover&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Public connectivity is abstracted through the Elastic IP, which can be reassigned between instances&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This design avoids modifying route tables during failure events and allows faster recovery since only the public endpoint changes while internal routing stays stable.&lt;/p&gt;




&lt;h3&gt;
  
  
  Node Roles
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Proxmox VM at 10.10.0.1 runs the FastAPI application and initiates the WireGuard tunnel&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;EC2 node 0 at 10.10.0.2 acts as the primary WireGuard gateway with the Elastic IP&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;EC2 node 1 at 10.10.0.3 acts as the standby gateway and takes over during failover&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Security Decisions Worth Explaining
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PAN Tokenisation
&lt;/h3&gt;

&lt;p&gt;The PAN (Primary Account Number, the 16-digit card number) is tokenised on arrival before it touches the database or SQS. What gets stored and queued is a UUID token and a masked string like &lt;code&gt;**** **** **** 1111&lt;/code&gt;. The raw number is used once, validated, masked, and discarded. This is the minimum floor for anything handling card data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokenise_pan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**** **** **** &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;masked&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dual Authentication: JWT and API Key
&lt;/h3&gt;

&lt;p&gt;Two separate auth flows exist intentionally. Merchant-facing endpoints use JWT (JSON Web Token) with HS256, issued on login and validated via FastAPI dependency injection. The Lambda callback endpoint (&lt;code&gt;PATCH /transactions/{token}/status&lt;/code&gt;) uses a separate &lt;code&gt;X-API-Key&lt;/code&gt; header, validated against a value fetched from AWS Secrets Manager at Lambda startup.&lt;/p&gt;

&lt;p&gt;These are intentionally different because they protect against different threats. A compromised JWT does not give an attacker the ability to spoof transaction statuses. A leaked API key cannot be used to enumerate merchant transactions.&lt;/p&gt;

&lt;h3&gt;
  
  
  bcrypt_sha256 Instead of bcrypt
&lt;/h3&gt;

&lt;p&gt;Standard bcrypt silently truncates passwords at 72 bytes. A password longer than 72 characters hashes identically to its first 72 characters. &lt;code&gt;bcrypt_sha256&lt;/code&gt; from passlib pre-hashes with SHA-256 before bcrypt, eliminating the truncation issue entirely. This is a real vulnerability, not a theoretical one. It affects production systems using bcrypt with long or Unicode-heavy passwords.&lt;/p&gt;

&lt;h3&gt;
  
  
  IAM Scoping
&lt;/h3&gt;

&lt;p&gt;The Lambda validator role has explicit, resource-scoped permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  SQS permissions scoped to the single queue ARN&lt;/li&gt;
&lt;li&gt;  S3 &lt;code&gt;PutObject&lt;/code&gt; scoped to &lt;code&gt;arn:aws:s3:::${bucket_name}/transactions/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Secrets Manager access scoped to &lt;code&gt;paycore/internal/config*&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The EC2 permissions (&lt;code&gt;DescribeInstances&lt;/code&gt;, &lt;code&gt;DescribeNetworkInterfaces&lt;/code&gt;) are wildcarded. That is a known gap, documented in the README. Those should be scoped to specific resource ARNs in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  KMS Encryption
&lt;/h3&gt;

&lt;p&gt;A single Customer Master Key (CMK) with key rotation enabled covers both S3 server-side encryption and Secrets Manager entries. The KMS key policy explicitly grants decrypt access to the Lambda IAM role, rather than relying on root account inheritance. The S3 bucket also has &lt;code&gt;bucket_key_enabled = true&lt;/code&gt;, which caches the data key at the bucket level and reduces KMS API call volume against your monthly quota.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;Provisioning runs in three layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Build the Proxmox golden image&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A cloud-init Ubuntu 22.04 VM is provisioned with Ansible: Docker Engine, Docker Compose plugin, WireGuard tools, SSH hardening, QEMU guest agent. Once provisioned, it is converted to an immutable Proxmox template with &lt;code&gt;qm template&lt;/code&gt;. Any VM cloned from that template is pre-hardened and Docker-ready without running a single extra apt command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Provision AWS infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Terraform manages the full AWS side: VPC, subnets, route tables, security groups, EC2 WireGuard nodes, Elastic IP, SQS queue with DLQ (Dead Letter Queue), SNS (Simple Notification Service) topic, S3 bucket, KMS key, Lambda functions, Secrets Manager secrets, and CloudWatch alarm. The layout is modular across five directories: &lt;code&gt;networking&lt;/code&gt;, &lt;code&gt;compute&lt;/code&gt;, &lt;code&gt;kms&lt;/code&gt;, &lt;code&gt;messaging&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;. Each module has its own variables and outputs. &lt;code&gt;aws-infra/dev/main.tf&lt;/code&gt; wires them together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Configure on-prem and deploy the app&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single Ansible playbook pair (&lt;code&gt;configure.yml&lt;/code&gt; then &lt;code&gt;deploy_app.yml&lt;/code&gt;) handles WireGuard setup on the VM, fetches secrets from Secrets Manager, git-pulls the repository, and brings up the Docker Compose stack. The final health check hits &lt;code&gt;http://10.10.0.1:80/api/health&lt;/code&gt; over the VPN tunnel. The playbook does not exit clean until that endpoint returns &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fraud Detection Flow
&lt;/h2&gt;

&lt;p&gt;Lambda processes one SQS record per invocation (&lt;code&gt;batch_size = 1&lt;/code&gt;). Before any fraud check runs, the raw payload is archived to S3. The archive happens before the decision, not after, so both flagged and processed transactions have an immutable record regardless of outcome.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_fraud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 10M NGN threshold
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NGN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If fraud is detected, Lambda publishes to the SNS topic (email alert) and PATCHes the status to &lt;code&gt;flagged&lt;/code&gt;. Otherwise it PATCHes &lt;code&gt;processed&lt;/code&gt;. The SQS queue has a 3-retry DLQ policy. If Lambda fails three times on a given message, the message moves to the DLQ for investigation rather than being silently dropped.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Deliberately Left Imperfect
&lt;/h2&gt;

&lt;p&gt;Honest projects document their gaps. These are in the README and I will repeat them here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local Terraform state.&lt;/strong&gt; &lt;code&gt;dev.tfstate&lt;/code&gt; is on disk. Production requires an S3 backend with DynamoDB state locking to prevent concurrent apply conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route table failover gap.&lt;/strong&gt; The failover Lambda moves the Elastic IP to the standby EC2 node, but the VPC route table entry still points at EC2 node 0's ENI. Full failover requires additional Lambda logic to update the route table. There is currently a window where the Elastic IP is on node 1 but VPN traffic is still routing through node 0's interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 Object Lock disabled.&lt;/strong&gt; &lt;code&gt;force_destroy = true&lt;/code&gt; for fast teardown in dev. Production would use &lt;code&gt;COMPLIANCE&lt;/code&gt; mode with a minimum 365-day retention period, consistent with PCI DSS Requirement 10.7 on audit log retention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No ledger engine.&lt;/strong&gt; PayCore validates and processes payment events. It does not maintain merchant balances, settlement records, or fund movement. A production payment platform needs double-entry bookkeeping, reconciliation, and settlement logic sitting on top of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets Manager recovery window is 0.&lt;/strong&gt; Immediate deletion enabled for fast iteration in dev. Production minimum is 7 days.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Stack Demonstrates
&lt;/h2&gt;

&lt;p&gt;This project covers hybrid cloud networking (WireGuard VPN bridging AWS and on-prem), event-driven async processing (SQS to Lambda with DLQ), layered authentication (JWT plus API key separation), KMS-backed secrets management, immutable infrastructure patterns (Proxmox golden image via cloud-init and Ansible), and automated failover via CloudWatch alarms. The architecture decisions are reasoned out, and the gaps are explicitly documented, not hidden.&lt;/p&gt;

&lt;p&gt;The code is public: &lt;a href="https://github.com/escanut/paycore-hybrid-payment-infra" rel="noopener noreferrer"&gt;github.com/escanut/paycore-hybrid-payment-infra&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Victor Ojeje, Cloud and DevOps Engineer&lt;/em&gt; &lt;em&gt;&lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/escanut"&gt;Dev.to&lt;/a&gt; | &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>I Passed AWS SAA-C03. Here's What Actually Mattered.</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Fri, 03 Apr 2026 21:16:46 +0000</pubDate>
      <link>https://dev.to/escanut/i-passed-aws-saa-c03-heres-what-actually-mattered-4n6n</link>
      <guid>https://dev.to/escanut/i-passed-aws-saa-c03-heres-what-actually-mattered-4n6n</guid>
      <description>&lt;p&gt;I sat the SAA-C03 recently and passed. Before I forget what actually worked, here's the honest breakdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exam is not a memory test&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AWS publishes the exam guide openly. Four domains, weighted percentages: Secure Architectures is 30%, Resilient Architectures 26%, High-Performing 24%, Cost-Optimized 20%. The questions drop you inside a scenario with a compliance constraint, a budget ceiling, or a failure condition, then ask you to pick the right architecture.&lt;/p&gt;

&lt;p&gt;If you study by memorising service features, you will fail on the scenario questions because you have never practiced making the decision. I knew this going in because I had already built a fraud detection pipeline with SQS, Lambda, DynamoDB, and SNS. When a question asked me to choose between Lambda and Fargate for a PCI-constrained workload, I had a real reference point. That matters more than any flashcard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tutorials Dojo was the core resource, not a supplement&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used Jon Bonso's practice exams as the primary study mechanism, not a final check before sitting. The reason it works is the explanations. When you get a question wrong, it does not just show you the correct answer. It tells you exactly why each wrong answer is wrong.&lt;/p&gt;

&lt;p&gt;That distinction is the whole exam. You are not picking the right option out of five. You are eliminating three plausible options. If you do not understand why the wrong ones fail, you will keep making the same mistakes on different question wording.&lt;/p&gt;

&lt;p&gt;Community benchmark on Tutorials Dojo is consistent 80% before sitting the real exam. I used that as my gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every wrong answer became an architecture pattern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I got a question wrong I did not just note the correct service. I mapped the whole scenario.&lt;/p&gt;

&lt;p&gt;Wrong on a DynamoDB composite key question? That becomes a working mental model for when a Global Secondary Index is the actual fix versus just restructuring the partition key. Wrong on an S3 lifecycle transition? That maps to the 30-day minimum billing rule for Standard-IA and why an aggressive lifecycle policy can cost more than doing nothing and oh boy does the exam love S3.&lt;/p&gt;

&lt;p&gt;You build a schema that generalises to questions you have never seen. That is the only way to handle the 65 questions in 130 minutes without running out of time on edge cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breadth over depth, done deliberately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SAA-C03 tests roughly 35 to 40 services with meaningful depth. Going deep on everything is a waste. Going shallow on the high-weight domains is how people fail.&lt;/p&gt;

&lt;p&gt;Secure Architectures is 30% of the exam. If you cannot immediately distinguish when to use a Customer Managed KMS key versus an AWS Managed Key for an audit requirement, you are losing points on the biggest single domain. I spent more time on IAM policies, KMS, VPC endpoints, and CloudTrail than on anything else, specifically because the exam weights said to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would skip next time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I spent too long on ECS versus EKS distinctions early on and very hyper focused portions of services like lambda, Eventbridge and Config for example. That is not where the exam goes. The exam goes into architectural tradeoffs including Multi-AZ RDS versus Read Replicas versus Aurora Global Database. That type of decision appears repeatedly.&lt;/p&gt;




&lt;p&gt;If you're preparing for SAA-C03, start with Tutorials Dojo's AWS Certified Solutions Architect Associate Practice Exams from day one, not after you've finished a course. The feedback loop from testing yourself early is faster than any other method.&lt;/p&gt;

&lt;p&gt;Follow me on Reddit: &lt;a href="https://reddit.com/user/Escanut/" rel="noopener noreferrer"&gt;reddit.com/user/Escanut/&lt;/a&gt; &lt;br&gt;
GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;github.com/escanut&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>certification</category>
    </item>
    <item>
      <title>How I Built a PCI-Ready Merchant Onboarding API on AWS for Under $5/Month</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sun, 22 Mar 2026 17:40:23 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-built-a-pci-ready-merchant-onboarding-api-on-aws-for-under-5month-4958</link>
      <guid>https://dev.to/escanut/how-i-built-a-pci-ready-merchant-onboarding-api-on-aws-for-under-5month-4958</guid>
      <description>&lt;p&gt;Payment processors get rejected from enterprise contracts  because it wasn't auditable. PCI DSS cares whether every data access event is logged, every record is encrypted with a key you control, and whether you can recover a merchant record to a specific second if a dispute arises.&lt;/p&gt;

&lt;p&gt;This post walks through a serverless merchant onboarding API built to those standards, with full cost transparency.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Solves
&lt;/h2&gt;

&lt;p&gt;A payment processor needs to register merchants: collect business details, store KYC documents, and expose that data to internal admin systems. Straightforward on the surface. The compliance overhead is where most implementations fall apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 3.5.1&lt;/strong&gt; — stored data must be encrypted with a key the organization controls, not a cloud-provider default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 8&lt;/strong&gt; — every API call must be authenticated and tied to an identity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 10&lt;/strong&gt; — every data access event must produce audit evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building infra that can survive a compliance audit without emergency rework is the goal to strive for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flbnuhwe2rgdw819ds8m0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flbnuhwe2rgdw819ds8m0.png" alt="Architectural Diagram" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Merchant authenticates with email and password via &lt;strong&gt;Amazon Cognito&lt;/strong&gt;, receives a JWT (JSON Web Token)&lt;/li&gt;
&lt;li&gt;Merchant calls &lt;strong&gt;API Gateway&lt;/strong&gt; with that JWT in the &lt;code&gt;Authorization&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;API Gateway validates the JWT against Cognito ensuring Lambda never receives an unauthenticated request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; processes the request and creates merchant profile in DynamoDB, stores KYC documents in S3 if present&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB&lt;/strong&gt; stores profiles encrypted at rest with a customer-managed KMS key, PITR enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; stores KYC documents encrypted with SSE-KMS, Bucket Key enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail&lt;/strong&gt; captures every API call, Lambda invocation, and DynamoDB operation as mandated by PCI DSS Requirement 10 &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudWatch&lt;/strong&gt; monitors operational metrics&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Assumptions and Traffic Model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly Active Users (MAU)&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;Initial operating scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projected new merchants/month&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Growth estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB writes/month&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;One write per new merchant registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB reads/month&lt;/td&gt;
&lt;td&gt;330,000&lt;/td&gt;
&lt;td&gt;10,000 users × 3 reads + 500 new records × 20 reads × 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 PUT requests/month&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;500 merchants × 4 KYC documents each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 GET requests/month&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;2,000 PUTs × 3 retrieval average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway + Lambda requests&lt;/td&gt;
&lt;td&gt;330,500&lt;/td&gt;
&lt;td&gt;Matches read/write totals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They were derived from real behavioral assumptions: how often a merchant logs in, how often admins pull records, document retrieval patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Decisions and Why They Were Made
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. JWT Validation at API Gateway, Not Lambda
&lt;/h3&gt;

&lt;p&gt;Authentication is enforced at the API Gateway layer using a Cognito authorizer. This means Lambda is never invoked for an unauthenticated request meaning there is no compute cost and no attack surface for unauthenticated traffic.&lt;/p&gt;

&lt;p&gt;If you push JWT validation into Lambda, you pay for Lambda invocations even for rejected requests, and you've moved your authentication boundary inward, increasing blast radius.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Customer-Managed KMS Key on DynamoDB (Not AWS-Owned)
&lt;/h3&gt;

&lt;p&gt;The DynamoDB table uses a customer-managed KMS key&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fesjb4s4vmmpp50kdssfq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fesjb4s4vmmpp50kdssfq.png" alt="DynamoDB Details" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS-Owned Keys (the free default) fail PCI DSS Requirement 3.5.1 for a specific reason: you cannot produce key usage evidence for an auditor because you have no visibility into key operations. CloudTrail does not log AWS-Owned Key usage. Customer-managed keys generate CloudTrail entries on every encrypt and decrypt operation. That's the audit evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. S3 SSE-KMS with Bucket Key Enabled
&lt;/h3&gt;

&lt;p&gt;KYC documents in S3 are encrypted with SSE-KMS. Bucket Key is enabled, which reduces the number of KMS API calls by batching envelope encryption at the bucket level rather than generating a unique data key per object.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5enzkfdaku84fv5duv8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5enzkfdaku84fv5duv8l.png" alt="S3 Details" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Without Bucket Key, every S3 object PUT and GET generates a KMS API call. At 8,000 S3 requests/month, that's 8,000 additional KMS calls on top of DynamoDB operations. Bucket Key collapses that significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Point-in-Time Recovery (PITR) on DynamoDB
&lt;/h3&gt;

&lt;p&gt;PITR is enabled on the merchants table. This provides continuous backups with one-second granularity for the last 35 days.&lt;/p&gt;

&lt;p&gt;In a payment dispute or fraud investigation, you may need to prove what a merchant record looked like at a specific timestamp. Without PITR, you either rebuild from CloudTrail (tedious) or you can't answer the question. PITR makes this a 30-second console operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. GSI for Read Efficiency
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;GET /merchants&lt;/code&gt; endpoint (admin listing) queries a Global Secondary Index (GSI) called &lt;code&gt;entity-type-index&lt;/code&gt; rather than scanning the full table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IndexName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GSI_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DynamoDB Scan reads every item in the table regardless of what you need. At scale, that becomes expensive. A GSI query reads only matching items. With 300,000+ reads per month, using Scan instead of a GSI query would multiply your read capacity consumption by the full table size factor.&lt;/p&gt;

&lt;p&gt;Cursor-based pagination is implemented using &lt;code&gt;ExclusiveStartKey&lt;/code&gt; and base64-encoded tokens, which is the correct DynamoDB pagination pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Duplicate Registration Prevention at the Database Level
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(cac_number)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A merchant's CAC (Corporate Affairs Commission) registration number is unique to their business. This condition expression makes DynamoDB reject the write if a record with that CAC number already exists. This is enforced at the storage layer, not in application logic, so it cannot be bypassed by a Lambda bug or a parallel request race condition.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. CloudTrail for PCI DSS Requirement 10
&lt;/h3&gt;

&lt;p&gt;CloudTrail is configured to log management events and Lambda data events. Every &lt;code&gt;PutItem&lt;/code&gt;, &lt;code&gt;GetItem&lt;/code&gt;, and &lt;code&gt;Query&lt;/code&gt; operation on the merchants table appears in CloudTrail. Every S3 object PUT and GET is logged.&lt;/p&gt;

&lt;p&gt;PCI DSS Requirement 10.2 specifies the exact event types that must be logged: user access to cardholder data, invalid logical access attempts, use of privilege escalation mechanisms. CloudTrail at this configuration covers all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Lambda Function
&lt;/h2&gt;

&lt;p&gt;This single function handles all three API routes. Every design decision in the security section above maps directly to something in this code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;boto3.dynamodb.conditions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;

&lt;span class="n"&gt;dynamodb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# GSI (Global Secondary Index) used for GET /merchants — avoids a full table
# Scan which reads every item regardless of what you need. At 300,000 admin
# reads/month, Scan would multiply read capacity consumption by the full
# table size factor.
&lt;/span&gt;&lt;span class="n"&gt;GSI_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity-type-index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DEFAULT_PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="n"&gt;MAX_PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="n"&gt;EMAIL_REGEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[\w\.-]+@[\w\.-]+\.\w+$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_post_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing or empty fields: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;EMAIL_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid contact_email format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# CAC = Corporate Affairs Commission registration number (Nigeria).
&lt;/span&gt;    &lt;span class="c1"&gt;# Exactly 6 digits — validated here before it ever touches DynamoDB.
&lt;/span&gt;    &lt;span class="n"&gt;cac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number must be exactly 6 digits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_merchant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Request body must be valid JSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_post_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;merchant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;created_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# ConditionExpression enforces uniqueness at the storage layer.
&lt;/span&gt;    &lt;span class="c1"&gt;# If a record with this CAC number already exists, DynamoDB rejects
&lt;/span&gt;    &lt;span class="c1"&gt;# the write with ConditionalCheckFailedException — no application-layer
&lt;/span&gt;    &lt;span class="c1"&gt;# race condition can bypass this.
&lt;/span&gt;    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(cac_number)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_all_merchants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;MAX_PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit must be an integer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;query_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IndexName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GSI_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# newest first
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Cursor-based pagination using DynamoDB's ExclusiveStartKey.
&lt;/span&gt;    &lt;span class="c1"&gt;# The cursor is base64-encoded JSON of the last evaluated key — safe
&lt;/span&gt;    &lt;span class="c1"&gt;# to pass to clients without exposing internal DynamoDB key structure.
&lt;/span&gt;    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;exclusive_start_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;query_kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ExclusiveStartKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exclusive_start_key&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;query_kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;next_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LastEvaluatedKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;next_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LastEvaluatedKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;next_cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_merchant_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Merchant not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="c1"&gt;# Route table — static dispatch is faster than regex matching at Lambda
# invocation frequency and easier to read during a code review.
&lt;/span&gt;&lt;span class="n"&gt;ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;post_merchant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;get_all_merchants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpMethod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pathParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)](&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Dynamic route: /merchants/{id}
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;path_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;get_merchant_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path_params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Route not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noting in this code that matter for regulated environments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input validation happens before any AWS SDK call.&lt;/strong&gt; Email format, CAC number format, and required field presence are all checked before Lambda touches DynamoDB. Malformed requests never generate KMS decrypt operations, which keeps the KMS call count accurate to your cost model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;entity_type: "MERCHANT"&lt;/code&gt; is set by the server, not the client.&lt;/strong&gt; The GSI partition key cannot be spoofed by a bad actor sending a crafted payload. If it were client-supplied, a request sending &lt;code&gt;entity_type: "ADMIN"&lt;/code&gt; could pollute the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The route table (&lt;code&gt;ROUTES&lt;/code&gt; dict) replaces a chain of if/elif checks.&lt;/strong&gt; At 330,500 invocations per month this is not a performance concern, but during a code review at a financial institution, explicit dispatch tables are easier to audit than nested conditionals.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Authentication Was Tested
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate SECRET_HASH (required for Cognito app clients with secret)&lt;/span&gt;
import hmac, hashlib, &lt;span class="nb"&gt;base64
&lt;/span&gt;message &lt;span class="o"&gt;=&lt;/span&gt; username + client_id
secret_hash &lt;span class="o"&gt;=&lt;/span&gt; base64.b64encode&lt;span class="o"&gt;(&lt;/span&gt;
    hmac.new&lt;span class="o"&gt;(&lt;/span&gt;client_secret.encode&lt;span class="o"&gt;()&lt;/span&gt;, message.encode&lt;span class="o"&gt;()&lt;/span&gt;, hashlib.sha256&lt;span class="o"&gt;)&lt;/span&gt;.digest&lt;span class="o"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;.decode&lt;span class="o"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;# Get JWT from Cognito&lt;/span&gt;
aws cognito-idp initiate-auth &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; 3einfu39fvd2opcoldicn0fh2q &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auth-flow&lt;/span&gt; USER_PASSWORD_AUTH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auth-parameters&lt;/span&gt; &lt;span class="nv"&gt;USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;username&amp;gt;,PASSWORD&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;password&amp;gt;,SECRET_HASH&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;# Call the API with the JWT&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"business_name": "Jiji", "cac_number": "128957", "contact_email": "jiji@example.com"}'&lt;/span&gt;

&lt;span class="c"&gt;# List all merchants&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Retrieve a specific merchant&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants/01fcd787-47d1-4db5-a396-f2adcf0c3e18 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests without a valid JWT are rejected at the API Gateway layer before Lambda is invoked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Source: AWS Pricing Calculator export, 03/22/2026, us-east-1. 10,000 MAU, 330,500 requests/month.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;% of Bill&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;KMS&lt;/td&gt;
&lt;td&gt;$2.01&lt;/td&gt;
&lt;td&gt;41.3%&lt;/td&gt;
&lt;td&gt;669,000 symmetric API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;$1.16&lt;/td&gt;
&lt;td&gt;23.8%&lt;/td&gt;
&lt;td&gt;330,500 requests, avg 34KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;$0.76&lt;/td&gt;
&lt;td&gt;15.6%&lt;/td&gt;
&lt;td&gt;330,500 invocations, 512MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudTrail&lt;/td&gt;
&lt;td&gt;$0.34&lt;/td&gt;
&lt;td&gt;7.0%&lt;/td&gt;
&lt;td&gt;Lambda data events at volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;td&gt;6.6%&lt;/td&gt;
&lt;td&gt;0.63GB log ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;$0.24&lt;/td&gt;
&lt;td&gt;4.9%&lt;/td&gt;
&lt;td&gt;On-demand, PITR, KMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;$0.04&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;1GB storage, 8,000 requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cognito&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;10,000 MAU = within free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.87/month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What most engineers miss about this bill:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;KMS is 41% of the monthly cost at this scale. The moment you add customer-managed encryption to DynamoDB and S3 (which PCI DSS requires), KMS becomes the dominant cost driver. Every DynamoDB read and write generates KMS API calls. Without Bucket Key on S3, every object operation would add to that count.&lt;/p&gt;

&lt;p&gt;At 10,000 MAU this is $4.87/month. At 100,000 MAU, KMS call volume scales roughly linearly with request volume. Planning for this in the initial architecture prevents a compliance requirement from arriving as a surprise bill.&lt;/p&gt;

&lt;p&gt;Cognito at this scale is free. The first 10,000 MAU are within the AWS free tier perpetually.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;This architecture is appropriate for the stated scale. Three things need revisiting before 10x growth:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. CloudTrail cost at high Lambda data event volume&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
CloudTrail logs Lambda data events at $0.10 per 100,000 events. At 330,500 Lambda invocations, that's $0.34/month. At 3.3M invocations, it's $3.30/month. At 33M, $33/month. Worth modelling before reaching that range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. GSI read capacity under admin query load&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
300,000 admin reads per month assumes 20 reads per admin per day. If admin tooling increases query frequency, the GSI read cost scales. Add DynamoDB DAX (DynamoDB Accelerator) at that point, DAX adds $150+/month at minimum instance size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. KMS request limits&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
KMS has a default quota of 5,500 symmetric requests per second in us-east-1. At the traffic levels modelled here, this is not a constraint. At high-throughput payment processing scale, it becomes one. Request a quota increase before hitting the ceiling, not after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Architecture Over EC2-Based Alternatives
&lt;/h2&gt;

&lt;p&gt;The comparable EC2 architecture (t3.micro + RDS + ALB) runs approximately $50-80/month before adding KMS, CloudTrail, and WAF. Serverless brings this to $4.87/month at 10,000 MAU.&lt;/p&gt;

&lt;p&gt;More importantly: there are no servers to patch. A common PCI DSS finding in EC2-based environments is unpatched OS vulnerabilities. Lambda eliminates that finding category entirely with AWS managing the runtime, and the function execution environment is ephemeral.&lt;/p&gt;

&lt;p&gt;The tradeoff is cold starts. Lambda cold starts for Python average 100-300ms. For a merchant onboarding API (not a real-time payment path), this is acceptable. For a transaction authorization endpoint, it is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Amazon Cognito (authentication, JWT issuance)&lt;/li&gt;
&lt;li&gt;Amazon API Gateway (JWT validation, request routing)&lt;/li&gt;
&lt;li&gt;AWS Lambda (Python 3.14, business logic)&lt;/li&gt;
&lt;li&gt;Amazon DynamoDB (merchant profiles, SSE-KMS, PITR, on-demand)&lt;/li&gt;
&lt;li&gt;Amazon S3 (KYC documents(for demonstration), SSE-KMS, Bucket Key, versioning)&lt;/li&gt;
&lt;li&gt;AWS CloudTrail (audit logging, PCI DSS Requirement 10)&lt;/li&gt;
&lt;li&gt;Amazon CloudWatch (operational monitoring)&lt;/li&gt;
&lt;li&gt;AWS KMS (customer-managed encryption keys)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're hiring for Cloud Engineer, DevOps, or SRE roles with infrastructure security depth, I'm open to remote opportunities.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built a Serverless Fraud Detection Pipeline on AWS. Here's What It Actually Costs to Do It Right.</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Tue, 17 Mar 2026 22:19:14 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-a-serverless-fraud-detection-pipeline-on-aws-heres-what-it-actually-costs-to-do-it-right-5hd7</link>
      <guid>https://dev.to/escanut/i-built-a-serverless-fraud-detection-pipeline-on-aws-heres-what-it-actually-costs-to-do-it-right-5hd7</guid>
      <description>&lt;p&gt;Most cloud portfolio projects look like this: spin up an EC2 instance, deploy a web app, take a screenshot. Done.&lt;/p&gt;

&lt;p&gt;That tells a hiring manager you can follow a tutorial.&lt;br&gt;
This is not that.&lt;/p&gt;

&lt;p&gt;I built a real-time transaction screening pipeline modeled after how financial institutions actually handle suspicious activity routing.&lt;br&gt;
The entire processing backbone costs $2.48/month to run at 500 000 transactions.&lt;br&gt;
Here is how it works, why every decision was made, and what it actually costs to secure it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem This Solves
&lt;/h2&gt;

&lt;p&gt;Payment processors and banks screen every transaction before it clears. A transaction above a defined threshold, or matching a suspicious pattern, needs to be flagged, stored, and routed to an analyst in near real-time. Latency here is not a UX problem. It is a compliance and fraud exposure problem.&lt;br&gt;
The architecture needs to handle three things without failing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Ingest transactions reliably, even under burst load&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Evaluate and persist every transaction regardless of outcome&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Alert immediately when a transaction crosses the threshold, with zero alert loss&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A traditional EC2-based approach handles this with always-on servers. You pay for compute whether transactions are flowing or not. You manage patching, availability zones, and process monitoring yourself.&lt;/p&gt;

&lt;p&gt;The serverless approach inverts this entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Every transaction enters through SQS. Lambda processes each message, writes the full record to DynamoDB regardless of whether it is flagged, and fires an SNS alert only if the amount exceeds the threshold. If Lambda fails to process a message three times, SQS routes it to a Dead Letter Queue for manual investigation. Nothing is silently dropped.&lt;/p&gt;

&lt;p&gt;Lambda was deployed inside a VPC across two availability zones. All connections to SQS, DynamoDB, SNS, and CloudWatch run through VPC interface and gateway endpoints. No traffic touches the public internet at any point in the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda Function
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="n"&gt;dyn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fraud-detections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SNS_ARN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SNS_ARN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:sns:us-east-1:461840362463:fraud-alerts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Threshold represents configurable screening rule for the institution
&lt;/span&gt;&lt;span class="n"&gt;THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;txn_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="n"&gt;merchant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;

        &lt;span class="n"&gt;dyn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flagged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;TopicArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SNS_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Suspicious transfer Flagged: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — NGN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fraud Alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every transaction is written to DynamoDB first, before the flag check. This matters. In a real system you need a complete audit trail. A transaction that does not trigger an alert is not necessarily clean. It needs to exist in the record. Regulators do not accept gaps.&lt;/p&gt;

&lt;p&gt;The threshold is a variable, not a hardcoded business rule. In production this would be pulled from a configuration store or Parameter Store, allowing compliance teams to adjust screening rules without a code deployment.&lt;/p&gt;

&lt;p&gt;Decimal is used instead of float for the amount. DynamoDB does not accept Python floats. Using float here causes silent precision errors on large transaction amounts. This is a real production bug that shows up in systems built by engineers who have not actually handled financial data before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verified Results
&lt;/h2&gt;

&lt;p&gt;Two transactions were sent through the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk9pfi0aerydqh3tg41ee.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk9pfi0aerydqh3tg41ee.png" alt="Dynamodb table with table items" width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;TXN-001 crossed the NGN 500,000 threshold. The SNS alert fired immediately to email with the subject line "Fraud Alert" and the full transaction details in the message body.&lt;br&gt;
TXN-002 was written to DynamoDB with flagged: false. No alert sent. Complete audit record preserved.&lt;/p&gt;

&lt;p&gt;Both records confirmed live in DynamoDB&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6aufl0ox01q5f3op8ar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6aufl0ox01q5f3op8ar.png" alt="Email alert from flagged transaction" width="725" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Email alert received from SNS Topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda (500k requests)&lt;/td&gt;
&lt;td&gt;$1.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SQS (1.5M requests)&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon DynamoDB (1GB)&lt;/td&gt;
&lt;td&gt;$0.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SNS (10k alerts)&lt;/td&gt;
&lt;td&gt;$0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$2.48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC Interface Endpoints (3x)&lt;/td&gt;
&lt;td&gt;$43.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secured Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$46.29/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pipeline itself costs $2.48/month to process 500,000 transactions.&lt;/p&gt;

&lt;p&gt;The VPC endpoints cost $43.81/month. &lt;br&gt;
That is 94.6% of the total bill for a security control, not compute.&lt;/p&gt;

&lt;p&gt;This is an intentional tradeoff. Without VPC endpoints, Lambda communicates with SQS, DynamoDB, SNS, and CloudWatch over the public internet. In a financial context that is not a configuration choice. It is an audit finding. VPC endpoints keep all traffic private within the AWS network, satisfy network isolation requirements common in PCI-DSS and SOC 2 environments, and eliminate the data exfiltration risk that comes with public endpoint exposure.&lt;/p&gt;

&lt;p&gt;If this were a cost-only conversation, you would skip the endpoints and save $43.81/month. In a production fintech environment, that decision gets flagged in the first security review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Is Not
&lt;/h2&gt;

&lt;p&gt;This pipeline has a single threshold rule. Production fraud detection at scale uses ML-based anomaly scoring, velocity checks across merchant categories, device fingerprinting, and graph-based relationship analysis. Those are layered on top of an event-driven backbone exactly like this one.&lt;/p&gt;

&lt;p&gt;This is the infrastructure layer. It is the part that has to work before any model or rule engine gets plugged in. Getting this layer wrong means no amount of ML sophistication above it recovers cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Serverless for This
&lt;/h2&gt;

&lt;p&gt;Three concrete reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Burst handling without pre-provisioning. Payment transaction volume is not linear. End-of-month salary runs, Black Friday, public holiday spikes. Lambda scales to concurrency automatically. An EC2-based system requires capacity planning or auto-scaling lag measured in minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No idle cost. An EC2 t3.micro running 24/7 costs approximately $7.59/month before you add storage, monitoring, or patching overhead. Lambda at 500,000 transactions costs $1.14/month. At zero transactions it costs nothing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Operational surface reduced to code. There is no OS to patch, no SSH access to harden, no process monitor to configure. The attack surface is the IAM role and the function code. Both are auditable and version-controlled.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Carries Into Production
&lt;/h2&gt;

&lt;p&gt;The patterns here are not demo-specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;SQS as a durable ingest buffer decouples transaction producers from the processing layer. A downstream Lambda failure does not lose the transaction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DLQ after three failures means no silent message loss. Every failed transaction is recoverable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VPC-private connectivity means the pipeline satisfies network isolation requirements out of the box.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IAM roles scoped to least privilege mean Lambda cannot touch anything outside its defined resource set.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the same architectural decisions you find in production payment infrastructure. The scale is different. The patterns are not.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Victor Ojeje is a Cloud and Infrastructure Engineer based in Lagos. He builds production-grade AWS infrastructure with a focus on security, automation, and cost-conscious design.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LinkedIn: &lt;a href="//linkedin.com/in/victorojeje"&gt;linkedin.com/in/victorojeje&lt;/a&gt; | GitHub: &lt;a href="//github.com/escanut]"&gt;github.com/escanut&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>cloudcomputing</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>How I went from Docker Compose to production EKS without burning AWS budget on mistakes</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Wed, 18 Feb 2026 06:12:33 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-went-from-docker-compose-to-production-eks-without-burning-aws-budget-on-mistakes-1aio</link>
      <guid>https://dev.to/escanut/how-i-went-from-docker-compose-to-production-eks-without-burning-aws-budget-on-mistakes-1aio</guid>
      <description>&lt;h2&gt;
  
  
  The Problem with Learning Kubernetes Directly on AWS
&lt;/h2&gt;

&lt;p&gt;Most tutorials for Kubernetes on AWS tell you to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin up an EKS cluster
&lt;/li&gt;
&lt;li&gt;Apply your manifests
&lt;/li&gt;
&lt;li&gt;Debug why nothing works
&lt;/li&gt;
&lt;li&gt;Watch your bill climb while you figure it out
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EKS charges &lt;strong&gt;$0.10 per hour&lt;/strong&gt; just for the control plane, before a single EC2 node is added. That is about $72/month for a cluster sitting idle while you troubleshoot ingress routing, secret injection failures, and container networking issues.&lt;/p&gt;

&lt;p&gt;That is fine for a company budget. It hurts when you are learning on your own.&lt;/p&gt;

&lt;p&gt;There is a better workflow. Test locally, prove it works, then pay for AWS.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A production-grade containerized web application with a staged deployment workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1:&lt;/strong&gt; Docker Compose for fast local iteration
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2:&lt;/strong&gt; Minikube to validate Kubernetes behavior before touching AWS
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 3:&lt;/strong&gt; EKS with full CI/CD, AWS Secrets Manager, and automated ALB provisioning
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;App repo:&lt;/strong&gt; &lt;a href="https://github.com/escanut/fastapi-k8s-project" rel="noopener noreferrer"&gt;https://github.com/escanut/fastapi-k8s-project&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Infra repo:&lt;/strong&gt; &lt;a href="https://github.com/escanut/fastapi-aws-infra" rel="noopener noreferrer"&gt;https://github.com/escanut/fastapi-aws-infra&lt;/a&gt;  &lt;/p&gt;

&lt;p&gt;The app is a FastAPI backend with async request handling and connection pooling connected to PostgreSQL, plus a vanilla JS frontend. Simple product catalog with create, retrieve, delete items. Nothing fancy on purpose. The focus is the infrastructure pattern and delivery workflow.&lt;/p&gt;


&lt;h2&gt;
  
  
  Stage 1: Docker Compose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; instant feedback on code changes with zero cluster overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;products"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend:/app&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nginx routes /api traffic to FastAPI. Credentials are plain env variables here, intentionally different from production. That contrast is the lesson.&lt;/p&gt;

&lt;p&gt;Validates: app logic, queries, routing, frontend-backend communication.&lt;br&gt;
Does not validate: Kubernetes behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stage 2: Minikube
&lt;/h2&gt;

&lt;p&gt;Goal: catch Kubernetes failures at zero cost before provisioning AWS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube start
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;minikube docker-env&lt;span class="si"&gt;)&lt;/span&gt;
minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;ingress
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; dev/k8s/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setup mirrors production closely:&lt;/p&gt;

&lt;p&gt;Secrets from Kubernetes Secrets, not env vars&lt;/p&gt;

&lt;p&gt;Postgres as a pod with a PVC&lt;/p&gt;

&lt;p&gt;Ingress configured like production&lt;/p&gt;

&lt;p&gt;Broken ingress, wrong ports, and secret issues show up here instead of on a paid cluster. Minikube is not identical to ALB, but it is close enough to surface real problems early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD Pipeline
&lt;/h2&gt;

&lt;p&gt;On every push to main:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="s"&gt;This uses aws-actions/configure-aws-credentials@v4&lt;/span&gt;

  &lt;span class="s"&gt;Build and push frontend &amp;amp; backend image&lt;/span&gt;
  &lt;span class="s"&gt;This runs docker build + push&lt;/span&gt;

  &lt;span class="s"&gt;Updates deployment&lt;/span&gt;
  &lt;span class="s"&gt;This runs kubectl set image ...&lt;/span&gt;

  &lt;span class="s"&gt;Verify&lt;/span&gt;
  &lt;span class="s"&gt;This runs kubectl rollout status ...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each image is tagged with commit SHA. Rollback just means redeploying a previous SHA. No guesswork.&lt;/p&gt;

&lt;p&gt;OIDC is used for short-lived credentials. No static keys stored.&lt;/p&gt;

&lt;h2&gt;
  
  
  VPC Endpoints: Why They Matter Beyond Security
&lt;/h2&gt;

&lt;p&gt;Without endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Worker → NAT → Internet → AWS API → Internet → NAT → Worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NAT data processing costs $0.045/GB. Image pulls, logs, and secrets traffic add up fast.&lt;/p&gt;

&lt;p&gt;Eight endpoints route AWS-internal traffic privately. NAT remains only for external access during bootstrap.&lt;/p&gt;

&lt;p&gt;This results to lower cost and reduced exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets Management Progression
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Stage 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Plain env var&lt;/span&gt;  
&lt;span class="na"&gt;Stage 2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;K8s Secret&lt;/span&gt;  
&lt;span class="na"&gt;Stage 3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExternalSecret synced from Secrets Manager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production password is randomly generated and stored in Secrets Manager. Terraform state is encrypted. Password is not floating around in plaintext.&lt;/p&gt;

&lt;h2&gt;
  
  
  IAM Design: Least Privilege
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;GitHub Actions: scoped OIDC role&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node role: read-only ECR&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Backend pods: read specific secret via IRSA&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ALB Controller: ALB-only permissions&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each component gets only what it needs to perform its task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-Install: Why Not Everything in Terraform
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;post-install.sh&lt;/code&gt; installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;AWS Load Balancer Controller&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;External Secrets Operator&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CRDs plus dependency ordering make Terraform Helm messy here. This approach is predictable and aligns with upstream guidance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Cost Snapshot
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EKS&lt;/td&gt;
&lt;td&gt;$72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EC2 nodes&lt;/td&gt;
&lt;td&gt;~$30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS&lt;/td&gt;
&lt;td&gt;~$12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT&lt;/td&gt;
&lt;td&gt;~$65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC endpoints&lt;/td&gt;
&lt;td&gt;~$11.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;~$16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Endpoints reduce NAT data charges on AWS API traffic. In active pipelines, that tradeoff makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Demonstrates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cost-aware Kubernetes learning path&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OIDC instead of static keys&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IRSA for pod-level IAM&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VPC endpoints for cost and security&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;External Secrets integration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fully automated ALB provisioning&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Repos are public and documented. Start local, move to Minikube, then EKS.&lt;/p&gt;

&lt;p&gt;If something breaks or you want to discuss design choices, reach out. I am always refining this setup myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open to Work
&lt;/h2&gt;

&lt;p&gt;Currently seeking remote roles in Cloud, DevOps, and Platform Engineering.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;LinkedIn: &lt;a href="https://linkedin.com/in/victorojeje" rel="noopener noreferrer"&gt;https://linkedin.com/in/victorojeje&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Email: &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;https://github.com/escanut&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>I built auto-scaling infrastructure that actually survives failures</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sat, 31 Jan 2026 21:19:07 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-auto-scaling-infrastructure-that-actually-survives-failures-3np9</link>
      <guid>https://dev.to/escanut/i-built-auto-scaling-infrastructure-that-actually-survives-failures-3np9</guid>
      <description>&lt;p&gt;Most deployment tutorials get you to "it works on my machine" status. Few show you how to build infrastructure that survives database connection failures, instance crashes during deployment, or availability zone outages.&lt;/p&gt;

&lt;p&gt;I deployed a production-grade Flask API on AWS with auto-scaling that maintains availability through failures. Here's what separates infrastructure that works in demos from infrastructure that works in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A three-tier Flask API deployment on AWS with PostgreSQL, designed to survive common failure scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Repository&lt;/strong&gt;: &lt;a href="https://github.com/escanut/terraform-aws-flask-oidc" rel="noopener noreferrer"&gt;github.com/escanut/terraform-aws-flask-oidc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application Load Balancer (Multi-AZ)
        ↓
Auto Scaling Group (2-4 instances)
        ↓
RDS PostgreSQL (Multi-AZ with automatic failover)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Stats&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero-downtime deployments (50% minimum healthy capacity)&lt;/li&gt;
&lt;li&gt;6-8 minute deployment from commit to live&lt;/li&gt;
&lt;li&gt;380-second instance warmup prevents premature failures&lt;/li&gt;
&lt;li&gt;Multi-AZ redundancy across database and networking&lt;/li&gt;
&lt;li&gt;Runtime credential retrieval (no secrets in code or Docker images)&lt;/li&gt;
&lt;li&gt;Monthly cost: $158 (us-east-1)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Health Check Integration: The Critical Path
&lt;/h2&gt;

&lt;p&gt;Health checks are where most auto-scaling setups fail. You need three layers working together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Docker Container Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s --start-period=40s --retries=3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;40-second start period accounts for ECR image pull (15-20 seconds) and application initialization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Flask Application Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Executes real database query
&lt;/span&gt;    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validates the application can actually reach RDS. An instance passing Docker checks but unable to connect to the database is useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ALB Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;healthy_threshold&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;unhealthy_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;interval&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/health"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2 consecutive successes (20 seconds) confirms stability. 3 consecutive failures (30 seconds) removes flapping instances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deregistration delay: 30 seconds&lt;/strong&gt; allows in-flight requests to complete before removing unhealthy instances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instance Warmup: Preventing Cascading Failures
&lt;/h2&gt;

&lt;p&gt;This configuration prevented deployment failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;instance_refresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Rolling"&lt;/span&gt;
  &lt;span class="nx"&gt;preferences&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_healthy_percentage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
    &lt;span class="nx"&gt;instance_warmup&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;380&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instance warmup: 380 seconds (6+ minutes)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens during warmup:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;EC2 instance launches (30-60 seconds)&lt;/li&gt;
&lt;li&gt;User data script: apt updates, Docker install, AWS CLI install, ECR login, image pull (4-5 minutes)&lt;/li&gt;
&lt;li&gt;Docker health checks stabilize (40 seconds)&lt;/li&gt;
&lt;li&gt;ALB health checks pass 2 consecutive times (20 seconds)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What happens if warmup is too short (e.g., 120 seconds):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto Scaling Group marks instance "healthy" before Docker finishes pulling the image&lt;/li&gt;
&lt;li&gt;ASG immediately terminates old instances&lt;/li&gt;
&lt;li&gt;New instances aren't ready for traffic&lt;/li&gt;
&lt;li&gt;ALB routes traffic to instances returning connection refused&lt;/li&gt;
&lt;li&gt;502 Bad Gateway errors cascade to users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;380-second warmup ensures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old instances stay alive until new instances are genuinely ready&lt;/li&gt;
&lt;li&gt;Zero user-facing errors during deployments&lt;/li&gt;
&lt;li&gt;Rolling updates complete without capacity loss&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This number was determined empirically by monitoring actual instance launch times, not guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-AZ Architecture: Eliminating Single Points of Failure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RDS Multi-AZ Configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"rds"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;multi_az&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.t4g.micro"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_encrypted&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost: $24.82/month&lt;/strong&gt; (vs $12.41 for single-AZ)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Multi-AZ provides:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Synchronous replication to standby in different availability zone&lt;/li&gt;
&lt;li&gt;Automatic failover in 1-2 minutes (zero manual intervention)&lt;/li&gt;
&lt;li&gt;Zero data loss during AZ failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What happens during AZ failure without Multi-AZ:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database becomes unreachable&lt;/li&gt;
&lt;li&gt;Application returns 503 errors to all users&lt;/li&gt;
&lt;li&gt;Manual recovery required (10-30+ minutes)&lt;/li&gt;
&lt;li&gt;Potential data loss depending on last backup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS availability zone failures occur 2-3 times per year per region. Production systems justify the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dual NAT Gateways:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_nat_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"nat"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;# One per AZ&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost: $65.70/month&lt;/strong&gt; (vs $32.85 for single NAT)&lt;/p&gt;

&lt;p&gt;EC2 instances in private subnets use NAT Gateways for internet access (apt updates, Docker installation, security patches). Single NAT Gateway creates single point of failure. If the NAT Gateway's AZ fails, instances in the other AZ can't bootstrap or update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total redundancy cost: $45/month&lt;/strong&gt; ($12.41 for Multi-AZ RDS + $32.85 for second NAT Gateway)&lt;/p&gt;

&lt;p&gt;This is insurance against extended outages, manual recovery procedures, and potential data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: Runtime Credentials, Not Hardcoded Secrets
&lt;/h2&gt;

&lt;p&gt;Database credentials are retrieved at runtime from AWS Secrets Manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;secret_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DB_SECRET_NAME&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;secretsmanager&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_secret_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SecretId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;secret_name&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SecretString&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where credentials are NOT:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application code&lt;/li&gt;
&lt;li&gt;Docker image layers&lt;/li&gt;
&lt;li&gt;Environment variables (visible in process listings)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where credentials ARE:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Secrets Manager (encrypted at rest, access logged via CloudTrail)&lt;/li&gt;
&lt;li&gt;EC2 instance memory only (ephemeral, destroyed on termination)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;IAM instance profile grants read-only access to one specific secret. Even if an instance is compromised, the attacker can't access other secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling Deployment Strategy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;min_healthy_percentage&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deployment flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub Actions builds Docker image, pushes to ECR (tagged with commit SHA)&lt;/li&gt;
&lt;li&gt;Triggers Auto Scaling Group instance refresh&lt;/li&gt;
&lt;li&gt;ASG replaces 50% of instances at a time&lt;/li&gt;
&lt;li&gt;New instances pull latest image, complete 380-second warmup&lt;/li&gt;
&lt;li&gt;ALB confirms new instances are healthy&lt;/li&gt;
&lt;li&gt;Old instances terminated&lt;/li&gt;
&lt;li&gt;Process repeats for remaining instances&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Deployment time: 6-8 minutes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker build/push: 3 minutes&lt;/li&gt;
&lt;li&gt;Rolling update with warmup: 3-5 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Zero downtime&lt;/strong&gt;: ALB continues routing traffic to healthy instances throughout deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Scenarios and Recovery
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;EC2 instance crash:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ALB health checks fail after 30 seconds&lt;/li&gt;
&lt;li&gt;ALB stops routing traffic to failed instance&lt;/li&gt;
&lt;li&gt;Auto Scaling Group replaces unhealthy instance&lt;/li&gt;
&lt;li&gt;New instance completes warmup and joins rotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: Zero user-facing errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Availability zone failure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-AZ RDS fails over to standby (1-2 minutes)&lt;/li&gt;
&lt;li&gt;EC2 instances in failed AZ marked unhealthy&lt;/li&gt;
&lt;li&gt;Auto Scaling Group launches replacements in healthy AZ&lt;/li&gt;
&lt;li&gt;NAT Gateway redundancy ensures new instances bootstrap successfully&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: 1-2 minutes degraded performance (reduced capacity)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Docker image pull failure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instance warmup prevents premature traffic routing&lt;/li&gt;
&lt;li&gt;Failed instance never receives traffic&lt;/li&gt;
&lt;li&gt;Auto Scaling Group retries launch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: Zero (deployment time increases, no user errors)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Instance warmup must account for real bootstrapping time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I initially set warmup to 120 seconds. Deployments failed because Docker image pull took 40-60 seconds and health checks needed 20 seconds to stabilize. 380 seconds was determined by monitoring actual instance launch times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health checks must validate actual functionality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A health check that only verifies "the container is running" is useless. The &lt;code&gt;/health&lt;/code&gt; endpoint executes a real database query. If the database is unreachable, the instance is genuinely unhealthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-AZ costs are insurance premiums&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extra $45/month for Multi-AZ RDS and dual NAT Gateways prevents manual failover procedures, potential data loss, and extended outages. For production systems, this is a rounding error compared to downtime costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Production infrastructure is measured by how it handles failures, not how it performs in demos.&lt;/p&gt;

&lt;p&gt;Multi-layer health checks, proper warmup periods, Multi-AZ redundancy, and zero-downtime deployments separate infrastructure that works in tutorials from infrastructure that works when databases fail, instances crash, or entire availability zones go offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/escanut/terraform-aws-flask-oidc" rel="noopener noreferrer"&gt;github.com/escanut/terraform-aws-flask-oidc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach out&lt;/strong&gt;: &lt;a href="https://linkedin.com/in/victorojeje" rel="noopener noreferrer"&gt;linkedin.com/in/victorojeje&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm seeking remote opportunities in Cloud Engineering, DevOps, and Infrastructure Engineering. If your team needs someone who thinks about failure scenarios and production resilience, let's connect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>How I eliminated access keys from my deployment pipeline with OIDC, Terraform, and GitHub Actions</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Fri, 23 Jan 2026 21:45:10 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-eliminated-access-keys-from-my-deployment-pipeline-with-oidc-terraform-and-github-actions-jek</link>
      <guid>https://dev.to/escanut/how-i-eliminated-access-keys-from-my-deployment-pipeline-with-oidc-terraform-and-github-actions-jek</guid>
      <description>&lt;h2&gt;
  
  
  The Problem with Traditional CI/CD
&lt;/h2&gt;

&lt;p&gt;Most tutorials for deploying static sites to AWS tell you to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate AWS access keys&lt;/li&gt;
&lt;li&gt;Store them as GitHub secrets&lt;/li&gt;
&lt;li&gt;Hope nobody compromises your repository&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach has serious security implications. Long-lived credentials in CI/CD pipelines are a common attack vector since if your repository is compromised or secrets are accidentally exposed, attackers gain full AWS access with those credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a better way: OpenID Connect (OIDC) federation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A fully automated static website deployment pipeline that eliminates access keys entirely by using temporary credentials exchanged through OIDC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a href="https://github.com/escanut/aws-s3-static-site-cicd" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture Overview
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer commits → GitHub Actions → OIDC token exchange 
→ Temporary AWS credentials → Deploy to S3 → CloudFront invalidation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Components:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; for static website hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; for global CDN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; for infrastructure as code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; for CI/CD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OIDC&lt;/strong&gt; for secure authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why OIDC Over Access Keys?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Traditional Approach (Access Keys):
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Potential security risk&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keys are long-lived (valid until manually rotated)&lt;/li&gt;
&lt;li&gt;If leaked, provide persistent AWS access&lt;/li&gt;
&lt;li&gt;Require manual rotation and management&lt;/li&gt;
&lt;li&gt;Stored as static secrets in GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  OIDC Approach:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This is secure&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_REGION }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Benefits:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credentials expire in 1 hour (temporary)&lt;/li&gt;
&lt;li&gt;No static secrets to leak&lt;/li&gt;
&lt;li&gt;Zero credential rotation needed&lt;/li&gt;
&lt;li&gt;AWS manages the trust relationship&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Setting Up the OIDC Provider in Terraform
&lt;/h3&gt;

&lt;p&gt;First, create an OIDC identity provider that trusts GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_openid_connect_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;

  &lt;span class="nx"&gt;client_id_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;thumbprint_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"6938fd4d98bab03faadb97b34396831e3780aea1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thumbprint verifies GitHub's signing certificate authenticity.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Creating the IAM Role with Trust Policy
&lt;/h3&gt;

&lt;p&gt;For this to work, we define &lt;strong&gt;exactly&lt;/strong&gt; which repositories can assume this role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github-actions-s3-deployment"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;StringLike&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"repo:${var.github_username}/${var.repo_name}:ref:refs/heads/main"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical security feature:&lt;/strong&gt; The &lt;code&gt;StringLike&lt;/code&gt; condition restricts access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your specific GitHub username&lt;/li&gt;
&lt;li&gt;Your specific repository&lt;/li&gt;
&lt;li&gt;Only the &lt;code&gt;main&lt;/code&gt; branch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if someone forks your repo, they can't assume this role.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Least-Privilege IAM Permissions
&lt;/h3&gt;

&lt;p&gt;The role only gets permissions it absolutely needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"${aws_s3_bucket.website_storage.arn}/*"&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront:CreateInvalidation"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_distribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No overly broad permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CloudFront Configuration Challenge
&lt;/h3&gt;

&lt;p&gt;Here's something that tripped me up: S3 website endpoints require &lt;strong&gt;custom origin configuration&lt;/strong&gt;, not standard S3 origin configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This doesn't work for S3 website endpoints&lt;/span&gt;

&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_regional_domain_name&lt;/span&gt;
  &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3-Website"&lt;/span&gt;

  &lt;span class="nx"&gt;s3_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_access_identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_origin_access_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudfront_access_identity_path&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# This works for the s3 website endpoints&lt;/span&gt;

&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_endpoint&lt;/span&gt;
  &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3-Website"&lt;/span&gt;

  &lt;span class="nx"&gt;custom_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;http_port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="nx"&gt;https_port&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
    &lt;span class="nx"&gt;origin_protocol_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http-only"&lt;/span&gt;
    &lt;span class="nx"&gt;origin_ssl_protocols&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TLSv1.2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; S3 website endpoints function as HTTP servers, supporting redirects and error documents. Standard S3 origins access the S3 API directly and don't support these features.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Remote State Management
&lt;/h3&gt;

&lt;p&gt;For production infrastructure, always use remote state with locking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"escanut-tf-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-website/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf-state-lock"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will enable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team collaboration without conflicts&lt;/li&gt;
&lt;li&gt;State encryption at rest&lt;/li&gt;
&lt;li&gt;Prevention of concurrent modifications&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The GitHub Actions Workflow
&lt;/h2&gt;

&lt;p&gt;The deployment workflow is remarkably simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to S3&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;  &lt;span class="c1"&gt;# Required for OIDC&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_REGION }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync to S3&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Invalidate CloudFront&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws cloudfront create-invalidation \&lt;/span&gt;
            &lt;span class="s"&gt;--distribution-id ${{ secrets.CLOUDFRONT_ID }} \&lt;/span&gt;
            &lt;span class="s"&gt;--paths "/*"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment Overview&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "Website deployment successful" &lt;/span&gt;
          &lt;span class="s"&gt;echo "https://${{ secrets.CLOUDFRONT_URL }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deployment time:&lt;/strong&gt; Approximately 30 seconds from commit to live site (measured from workflow logs).&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Analysis
&lt;/h2&gt;

&lt;p&gt;One of the best parts? This infrastructure costs almost nothing to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly costs&lt;/strong&gt; (based on January 2026 AWS pricing):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 Storage&lt;/td&gt;
&lt;td&gt;&amp;lt;1GB&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Requests&lt;/td&gt;
&lt;td&gt;10k/month&lt;/td&gt;
&lt;td&gt;$0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront&lt;/td&gt;
&lt;td&gt;10GB transfer&lt;/td&gt;
&lt;td&gt;$0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.92/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For comparison, a single t3.micro EC2 instance costs $8-15/month and requires active management.&lt;br&gt;
.&lt;/p&gt;

&lt;p&gt;AWS's eventually consistent nature means public access settings must be applied before the bucket policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Wins
&lt;/h2&gt;

&lt;p&gt;This architecture eliminates several attack vectors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No long-lived credentials&lt;/strong&gt; - temporary credentials expire in 1 hour&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Repository-scoped access&lt;/strong&gt; - only your specific repo can deploy&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Branch-specific trust&lt;/strong&gt; - only &lt;code&gt;main&lt;/code&gt; branch can trigger deployments&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Least-privilege permissions&lt;/strong&gt; - role can only write to specific S3 bucket&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Encrypted state&lt;/strong&gt; - Terraform state encrypted at rest in S3&lt;br&gt;&lt;br&gt;
&lt;strong&gt;No credential rotation&lt;/strong&gt; - AWS handles credential lifecycle automatically&lt;/p&gt;

&lt;p&gt;The current implementation shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infrastructure as Code proficiency&lt;/li&gt;
&lt;li&gt;Cloud security best practices&lt;/li&gt;
&lt;li&gt;CI/CD automation&lt;/li&gt;
&lt;li&gt;Cost optimization awareness&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;OIDC federation with GitHub Actions is the modern standard for CI/CD authentication to AWS. It's more secure, requires zero maintenance, and is easier to implement than managing long-lived access keys.&lt;/p&gt;

&lt;p&gt;If you're still using AWS access keys in your deployment pipelines, it's time to upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it yourself:&lt;/strong&gt; Fork the &lt;a href="https://github.com/escanut/aws-s3-static-site-cicd" rel="noopener noreferrer"&gt;repository&lt;/a&gt;, update the variables, and deploy your own infrastructure in under 5 minutes.&lt;/p&gt;




&lt;p&gt;Drop a comment below or reach out on &lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this was helpful, consider giving the GitHub repo a ⭐!&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Deploying Secure WordPress on AWS: A Multi-Tier Architecture Approach</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Tue, 20 Jan 2026 18:07:20 +0000</pubDate>
      <link>https://dev.to/escanut/deploying-secure-wordpress-on-aws-a-multi-tier-architecture-approach-1igf</link>
      <guid>https://dev.to/escanut/deploying-secure-wordpress-on-aws-a-multi-tier-architecture-approach-1igf</guid>
      <description>&lt;p&gt;This project started as a learning exercise and ended up looking very close to how small production workloads are actually deployed. The goal was simple: run WordPress on AWS with proper network isolation, least-privilege access, and basic monitoring, without spending money or cutting corners.&lt;/p&gt;

&lt;p&gt;Everything here was built inside Free Tier limits, but the design decisions follow the same patterns used in real environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;I deployed WordPress using a classic two-tier setup inside a custom VPC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core components:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom VPC: 10.0.0.0/16&lt;/li&gt;
&lt;li&gt;Public subnet: 10.0.1.0/24 for the web server&lt;/li&gt;
&lt;li&gt;Private subnets: 10.0.10.0/24 and 10.0.11.0/24 for RDS&lt;/li&gt;
&lt;li&gt;EC2 t2.micro running Ubuntu 24.04&lt;/li&gt;
&lt;li&gt;RDS MySQL db.t4g.micro&lt;/li&gt;
&lt;li&gt;Elastic IP for stable external access&lt;/li&gt;
&lt;li&gt;Apache 2.4, PHP 8.1, WordPress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Traffic flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → Internet Gateway → EC2 Compute (public subnet) → RDS (private subnets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdsvh19ea76hiy9eve6ly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdsvh19ea76hiy9eve6ly.png" alt="Architecture and traffic flow" width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[Architecture and traffic flow]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The database has no public IP and no route to the internet. All access is controlled with security groups. I intentionally did not rely on NACLs or host firewalls to keep the design aligned with how AWS environments are usually managed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Security Groups
&lt;/h3&gt;

&lt;p&gt;The security group setup is minimal and intentional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web tier security group:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH (22): allowed only from my admin IP&lt;/li&gt;
&lt;li&gt;HTTP (80) and HTTPS (443): open to the internet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Database tier security group:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MySQL (3306): allowed only from the web tier security group&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No IP-based rules between tiers. Only security group references.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkaeu3c4smtyih10wnnb1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkaeu3c4smtyih10wnnb1.png" alt="EC2 Security Group Details" width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[EC2 security group details]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo5365zo4dkpmwi7o7xac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo5365zo4dkpmwi7o7xac.png" alt="RDS security group details" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[RDS security group details]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This matters. The first version used the EC2 private IP as the database allow list. That failed immediately after a reboot when the IP changed. Switching to security group IDs fixed the issue permanently and reflects how AWS expects environments to scale and change.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress Hardening
&lt;/h3&gt;

&lt;p&gt;I applied basic but meaningful WordPress security controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disabled file editing in the admin dashboard&lt;/li&gt;
&lt;li&gt;Limited login attempts to reduce brute-force risk&lt;/li&gt;
&lt;li&gt;Disabled XML-RPC&lt;/li&gt;
&lt;li&gt;Enforced sane file permissions (755 for directories, 644 for files)&lt;/li&gt;
&lt;li&gt;Enabled HTTPS using a self-signed certificate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's Encrypt does not issue certificates for AWS public DNS names. Using a self-signed cert keeps traffic encrypted while avoiding extra costs. Which is fine in this case, as its a demo, not production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring and Alerting
&lt;/h2&gt;

&lt;p&gt;Both EC2 and RDS are monitored with CloudWatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alarm configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU utilization threshold: 70 percent&lt;/li&gt;
&lt;li&gt;Action: SNS email notification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose 70 percent instead of waiting for 80 to 90 percent. On small instance types, sustained CPU spikes quickly translate into performance problems.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsoab158f5m7viujibhap.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsoab158f5m7viujibhap.png" alt="CloudWatch dashboard" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[CloudWatch dashboard]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjkoep084gy91f714c7wq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjkoep084gy91f714c7wq.png" alt="CloudWatch alarms" width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[CloudWatch alarms]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;During normal usage, EC2 CPU sat around 12 to 18 percent. During load testing, alarms triggered correctly and SNS emails arrived within about 2 to 3 minutes. That confirms the alerting path actually works, not just that it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Problems Encountered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Security Group Misconfiguration
&lt;/h3&gt;

&lt;p&gt;Using IP addresses for internal trust broke the moment the instance restarted. This is a common mistake in AWS environments and a good lesson. In AWS, security groups are the trust boundary, not IPs.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress URL Handling
&lt;/h3&gt;

&lt;p&gt;WordPress stores absolute URLs inside the database. Changing the Elastic IP meant internal links broke even after updating wp-config.php.&lt;/p&gt;

&lt;p&gt;The fix required a full database search and replace using WP-CLI. This isn't documented in most WordPress migration guides but breaks production migrations regularly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Tier Cost Awareness
&lt;/h3&gt;

&lt;p&gt;AWS does not stop you from spending money. An unattached Elastic IP started generating charges within hours.&lt;/p&gt;

&lt;p&gt;Billing alarms should be set up immediately, even for learning projects. Free Tier does not mean free by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Trade-offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Single-AZ RDS
&lt;/h3&gt;

&lt;p&gt;Multi-AZ was skipped to stay within Free Tier. This means backups exist, but there is no automatic failover. Recovery requires restoring from a snapshot and takes several minutes. That is acceptable here, not for systems with tight recovery objectives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache instead of Nginx
&lt;/h3&gt;

&lt;p&gt;Apache was chosen for simplicity and WordPress compatibility. Nginx would scale better under high concurrency, but that advantage does not matter at this size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-signed SSL
&lt;/h3&gt;

&lt;p&gt;Encrypted traffic without a domain cost. Browser warnings are expected. This was a deliberate trade-off, not an oversight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd9erli4fkdelrxdgxfha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd9erli4fkdelrxdgxfha.png" alt="WordPress live test" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[WordPress live test]&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress reachable over HTTPS via Elastic IP&lt;/li&gt;
&lt;li&gt;RDS fully isolated in private subnets&lt;/li&gt;
&lt;li&gt;Database access restricted to the web tier only&lt;/li&gt;
&lt;li&gt;CloudWatch alarms and SNS notifications verified&lt;/li&gt;
&lt;li&gt;Automated RDS backups tested via snapshot restore&lt;/li&gt;
&lt;li&gt;Total cost stayed at $0 within Free Tier limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Skills Demonstrated
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud and Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VPC and subnet design&lt;/li&gt;
&lt;li&gt;Security group based access control&lt;/li&gt;
&lt;li&gt;EC2 and RDS provisioning&lt;/li&gt;
&lt;li&gt;Elastic IP management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Linux and Systems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu server setup&lt;/li&gt;
&lt;li&gt;Apache and PHP configuration&lt;/li&gt;
&lt;li&gt;MySQL client usage&lt;/li&gt;
&lt;li&gt;Service and permission management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Security:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Network isolation&lt;/li&gt;
&lt;li&gt;Least-privilege enforcement&lt;/li&gt;
&lt;li&gt;TLS configuration&lt;/li&gt;
&lt;li&gt;Application-level hardening&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Operations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch monitoring&lt;/li&gt;
&lt;li&gt;SNS alerting&lt;/li&gt;
&lt;li&gt;Backup validation&lt;/li&gt;
&lt;li&gt;Cost awareness&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Notes
&lt;/h2&gt;

&lt;p&gt;This project reflects how small but real AWS environments are built. The focus was fully on correct fundamentals including isolation, access control, monitoring, and understanding trade-offs.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Victor Ogechukwu Ojeje&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Email: &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;linkedin.com/in/victorojeje&lt;/a&gt;&lt;br&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;github.com/escanut&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Deployment screenshots and configuration details available in the &lt;a href="https://github.com/escanut/WordPress-Deployment-on-AWS" rel="noopener noreferrer"&gt;project repository&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
