<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Super Washington Banga — Cloud-Native Systems &amp; DevSecOps Engineer</title>
    <link>https://swbanga.com/</link>
    <description>Designing and building secure, scalable, cloud-native platforms — multi-cloud architectures (Azure &amp; GCP), immutable financial systems (.NET 10), and Zero-Trust healthcare APIs (FastAPI).</description>
    <language>en</language>
    <lastBuildDate>Fri, 28 Aug 2026 18:26:46 GMT</lastBuildDate>
    <generator>generate-llms-txt.mjs (sprint 08)</generator>
    <atom:link href="https://swbanga.com/rss.xml" rel="self" type="application/rss+xml"/>
    <webMaster>super@swbanga.com (Super Washington Banga)</webMaster>
  <item>
    <title>Deploying FastAPI to Azure Container Apps with scale-to-zero</title>
    <link>https://swbanga.com/blog/deploying-fastapi-azure-container-apps/</link>
    <guid isPermaLink="true">https://swbanga.com/blog/deploying-fastapi-azure-container-apps/</guid>
    <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
    <description>How Med-Core&apos;s FastAPI API ships to Azure Container Apps: a Terraform module with min-replicas zero, KEDA-driven scale-up, and the cold-start budget that makes scale-to-zero the default deployment posture.</description>
    <category>fastapi</category>
    <category>azure-container-apps</category>
    <category>terraform</category>
    <category>scale-to-zero</category>
    <category>postgresql</category>
    <content:encoded><![CDATA[## Why Azure Container Apps for Med-Core

Med-Core is a FastAPI service handling PHI intake for a hospital queue. The
traffic profile is hostile to a fixed cluster: bursts during clinic hours,
near-zero overnight, and a compliance posture that punishes paying for idle
compute. The architecture decision for the platform (ADR-003) landed on
Azure Container Apps after rejecting AKS for exactly this reason — a managed
PaaS that owns the Kubernetes under the hood, but exposes only what the
workload needs: revisions, ingress, and a scale rule.

The scale rule is the whole point. Container Apps wraps KEDA, so a service
can declare its own drivers: HTTP traffic, queue depth, cron. Med-Core's
intake worker scales on the queue; the API scales on HTTP. And both ship
with `min_replicas = 0`, which is the setting that turns "serverless" from a
marketing term into a cost line that reads zero overnight.

## The Terraform module

The deployment is a shared module used by every Med-Core service, so the
scale posture lives in one place instead of drifting across resource
groups:

```hcl
resource "azurerm_container_app" "service" {
  name                = var.service_name
  container_app_environment_id = var.environment_id
  resource_group_name = var.resource_group
  revision_mode       = "Single"

  template {
    min_replicas = 0
    max_replicas = var.max_replicas

    container {
      name   = var.service_name
      image  = var.image
      cpu    = var.cpu
      memory = var.memory

      env {
        name  = "DATABASE_URL"
        value = "@Microsoft.KeyVault(VaultName=${var.key_vault_name};SecretName=db-url)"
      }

      readiness_probe {
        path                = "/health"
        port                = 8000
        transport           = "HTTP"
        interval_seconds    = 10
        failure_threshold   = 5
      }
    }

    scale {
      min_replicas = 0
      max_replicas = var.max_replicas

      rule {
        name = "http-scale"
        custom {
          type = "http"
          metadata = {
            concurrentRequests = "50"
          }
        }
      }
    }
  }
}
```

Two details are deliberate. First, the readiness probe — it is the mechanism
that lets a scale-to-zero revision come back cleanly, and it doubles as the
warm-up trigger for anything that needs priming at startup. Second, the
secrets reference keeps `DATABASE_URL` out of the image and out of the
resource definition, which matters when the deployment itself is Git-backed.

## Building and pushing the image

Images flow through a GitHub Actions workflow that builds once and pushes to
the service's ACR registry; the Container Apps revision redeploys from the
tag:

```yaml
name: build-and-deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: medcore.azurecr.io
  IMAGE: med-core-api

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.ACR_CLIENT_ID }}
          password: ${{ secrets.ACR_CLIENT_SECRET }}
      - run: docker build -t $REGISTRY/$IMAGE:${{ github.sha }} .
      - run: docker push $REGISTRY/$IMAGE:${{ github.sha }}
      - run: >
          az containerapp update --name med-core-api
          --resource-group med-core-rg --image $REGISTRY/$IMAGE:${{ github.sha }}
```

The short SHA is the revision tag, so every commit maps to a reproducible
image and the container app's revision history doubles as a deploy ledger.

## Database migrations at startup

The API owns its schema. Running migrations as part of the startup path
removes a whole class of "migrated in prod, not in the deploy" drift, but it
has to be safe against the scale-to-zero lifecycle: a cold start can hit a
revision that has been sleeping since before the last migration shipped.

```python
import asyncio
import asyncpg

async def wait_for_database(url: str, attempts: int = 10, delay: float = 2.0) -> None:
    """Block startup until Postgres is reachable, then let Alembic catch up."""
    for attempt in range(attempts):
        try:
            conn = await asyncpg.connect(url, timeout=5)
            await conn.close()
            break
        except (OSError, asyncpg.PostgresError):
            if attempt == attempts - 1:
                raise
            await asyncio.sleep(delay)
```

The retry loop exists because on a cold start the database container can
still be coming up — PostgreSQL on the same Container Apps environment does
not inherit the API's zero-replica sleep schedule. After the loop succeeds,
`alembic upgrade head` runs synchronously before uvicorn binds the port, so
the readiness probe only ever sees a migrated service.

## Verifying scale-to-zero

Scale-to-zero is only real when it is observed. The deployment log records
the first verified run: zero replicas through the overnight window, KEDA
scaling up within one polling interval of the first morning request, and a
cost graph with zero compute hours for the idle period.

```bash
az containerapp show \
  --name med-core-api \
  --resource-group med-core-rg \
  --query "properties.template.scale.minReplicas"
# 0
```

The `az` query is now part of the deploy checklist — a revision is not
considered shipped until the console confirms the posture stuck.

## The cold-start budget

<Callout variant="warning" title="Cold starts are the tax">
  The first request after idle took 8.4s versus 320ms warm. The JWT key
  cache had dropped with the replica, adding a Key Vault round trip to
  startup. Scale-to-zero buys the cost line; the tax is latency on the
  first request after sleep.
</Callout>

The fix is not to abandon scale-to-zero — it is to budget for it. The cold
start is tracked as a first-class deploy metric, and the warm-up strategy
changed so health probes prime the JWT cache instead of waiting for request
traffic. An 8.4s first request is acceptable for an intake API behind a
queue; it would not be for a user-facing page, and the budget should be set
per workload, not globally.

## What to watch next

The full timeline — the deploy, the 02:00 zero-replica confirmation, and
the 09:12 KEDA warm-up — is in the scale-to-zero deployment log in the
journal, and the platform trade-off analysis lives in the architecture
vault's Azure Container Apps decision record. The next revision's open
question is whether the worker's scale-in delay should linger longer than
the API's, so the cold-start budget stays a queue-latency concern and never
becomes an API-latency one.]]></content:encoded>
  </item>
  <item>
    <title>First scale-to-zero verification on Azure Container Apps</title>
    <link>https://swbanga.com/journal/aca-scale-to-zero-deployment-log/</link>
    <guid isPermaLink="true">https://swbanga.com/journal/aca-scale-to-zero-deployment-log/</guid>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
    <description>Deployment log for the first verified scale-to-zero run on Azure Container Apps: zero replicas overnight, KEDA-triggered warm-up on the first request, and the cold-start latency budget observed before and after.</description>
    <category>deployment-log</category>
    <content:encoded><![CDATA[## Deploy

The shared Terraform module shipped `min_replicas = 0` for the intake
worker, and the revision rollout confirmed it:

```bash
az containerapp show \
  --name med-core-worker \
  --resource-group med-core-rg \
  --query "properties.template.scale.minReplicas"
# 0
```

## Verification

- 02:00 — worker at zero replicas, confirmed in the portal and the cost graph
  (zero compute hours for the idle window).
- 09:12 — first request after idle: KEDA scaled up within one polling
  interval, well inside the configured scale-in delay.
- 09:13 — first cold request took 8.4s versus 320ms warm: the JWT key cache
  had dropped on scale-to-zero, adding a Key Vault round trip to startup.

<Callout variant="success" title="Verified">
  Scale-to-zero works as the standard posture: zero idle cost, bounded
  cold-start budget, and a reproducible warm-up path. The cold-start metric
  is now part of the deploy checklist for every revision.
</Callout>

## Follow-ups

- Prime caches with a health-probe warm-up instead of waiting for request
  traffic.
- Tune the scale-in delay per workload; the worker wants a longer linger
  than the API.
- Distinguish cold-start latency from regressions in the dashboards (ADR-003
  open question).]]></content:encoded>
  </item>
  <item>
    <title>Azure Container Apps with Scale-to-Zero for Cloud-Native Microservices</title>
    <link>https://swbanga.com/architecture/adr-003/</link>
    <guid isPermaLink="true">https://swbanga.com/architecture/adr-003/</guid>
    <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
    <description>Adopt Azure Container Apps scale-to-zero as the standard deployment posture for background workers and non-critical APIs: minimum replicas of zero, HTTP-triggered scaling for APIs, and event-driven KEDA triggers for queue workers.</description>
    <category>med-core</category>
    <category>ledger-core</category>
    <category>azure</category>
    <category>container-apps</category>
    <category>scale-to-zero</category>
    <content:encoded><![CDATA[## Problem

The overnight cost report exposed it: the Med-Core intake worker stayed at
one replica all night because the first deployment shipped without scaling
rules. The platform needed an explicit, standard posture instead of a
per-service accident.

## Proposal

Standardize on scale-to-zero for workers and non-critical APIs, expressed in
the shared Terraform module:

```hcl
resource "azurerm_container_app" "worker" {
  ...
  template {
    min_replicas = 0
    max_replicas = 5
    scale {
      min_replicas = 0
      max_replicas = 5
      rule {
        name = "redis-queue-depth"
        custom { type = "keda" } # queue-based triggers
      }
    }
  }
}
```

<Callout variant="warning" title="Cold-start trade-off">
  First request after idle pays for dropped in-memory caches — the JWT key
  cache adds a Key Vault round trip on startup. Warm-up probes and cache
  priming must be part of the same change, or the latency budget moves.
</Callout>

## Open questions

- Scale-in delay: how long should idle replicas linger before the KEDA
  trigger releases them, and how is that tuned per workload?
- Which paths are latency-critical enough to opt out of scale-to-zero?
  Ledger settlement is the early candidate.
- What monitoring signal distinguishes a cold start from a regression,
  given that first-request latency is now a first-class metric?]]></content:encoded>
  </item>
  <item>
    <title>Redis idempotency keys — tuning the reconciliation retry storm</title>
    <link>https://swbanga.com/journal/redis-idempotency-tuning/</link>
    <guid isPermaLink="true">https://swbanga.com/journal/redis-idempotency-tuning/</guid>
    <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
    <description>Post-mortem on a retry storm that pushed 12 duplicate outbox deliveries through Redis: the TTL mismatch, the idempotency-key fix, and the monitoring gap that hid it for three hours.</description>
    <category>postmortem</category>
    <content:encoded><![CDATA[## What happened

A bad deploy retried a batch of outbox deliveries 12 times in 20 minutes. The
retry TTL had been set longer than the idempotency key TTL, so keys expired
mid-storm and duplicates reached the ledger worker. The rate limiter rejected
most of them — but it ran before the idempotency check, so the rejection
reason was wrong and no alert fired for three hours.

## The fix

Two ordering rules, now encoded in tests:

1. Idempotency key TTL always outlives the longest retry window.
2. The pipeline checks idempotency before rate limiting, so every rejection
   carries the true cause.

<Callout variant="warning" title="Monitoring gap">
  Duplicate-key rejections are now a first-class alert metric. The storm was
  visible in the event log, but nobody was watching the log.
</Callout>]]></content:encoded>
  </item>
  <item>
    <title>JWT race condition — scope checked after the middleware</title>
    <link>https://swbanga.com/journal/med-core-jwt-race-condition/</link>
    <guid isPermaLink="true">https://swbanga.com/journal/med-core-jwt-race-condition/</guid>
    <pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
    <description>Post-mortem on a race condition where concurrent requests replayed an un-scoped JWT through the middleware, and the fix that moved scope validation into the request pipeline where it belongs.</description>
    <category>postmortem</category>
    <content:encoded><![CDATA[## What happened

Two concurrent requests with the same un-scoped token: one handler checked
the scope claim and raised 403, but a second request had already passed the
client wrapper — where the scope check lived — before the middleware re-check
ran. The token was never re-validated between the wrapper and the handler, so
the second request sailed through with a scope it was never granted.

The sequential test suite could not see the window: every request in the old
tests completed its check before the next one started.

## The fix

Scope validation moved out of the client wrapper and into the dependency
chain, next to authentication, so every request — regardless of caller —
passes through it:

```python
@app.post("/intake", dependencies=[Depends(require_scope("intake:write"))])
async def ingest(payload: IntakePayload) -> Receipt:
    ...
```

The regression test uses an `asyncio.Barrier` to line up both requests inside
the middleware, so the race fails deterministically if it ever returns:

```python
await asyncio.gather(issue_request(token, barrier), issue_request(token, barrier))
```

<Callout variant="error" title="Ticket gap">
  The scope-in-middleware gap was flagged in the sprint review but never
  filed, so the fix shipped a sprint late. Review findings now get a ticket
  before the sprint closes — no exceptions.
</Callout>]]></content:encoded>
  </item>
  <item>
    <title>Med-Core Sprint 01 — intake pipeline with PHI encryption</title>
    <link>https://swbanga.com/journal/med-core-sprint-01/</link>
    <guid isPermaLink="true">https://swbanga.com/journal/med-core-sprint-01/</guid>
    <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
    <description>Retrospective on the first sprint of Med-Core: the PHI boundary, what the OAuth2/JWT flow taught us, and the three lessons that will shape Sprint 02.</description>
    <category>retrospective</category>
    <content:encoded><![CDATA[## What shipped

The intake pipeline runs end-to-end against synthetic payloads:

```bash
swb-cli generate journal --input intake-notes.txt
```

Field-level PHI encryption works at the boundary — nothing downstream in the
data plane sees plaintext, and the rate-limit test passed before the Redis
integration was wired in.

<Callout variant="success" title="Worth repeating">
  Reviewing the Terraform diff in the same PR as the app code caught a public
  egress rule before it ever reached Azure.
</Callout>

## What broke

The first deployment to Azure Container Apps missed the scale-to-zero
configuration, so the intake worker stayed hot overnight. Worse, JWT scope
validation was enforced in the calling client instead of the request
middleware — a client could mint scoped tokens the API never re-checked. Both
were fixed in the same week, but only the container fix had a ticket.]]></content:encoded>
  </item>
  <item>
    <title>FastAPI for Med-Core Async Healthcare PHI Processing</title>
    <link>https://swbanga.com/architecture/adr-002/</link>
    <guid isPermaLink="true">https://swbanga.com/architecture/adr-002/</guid>
    <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
    <description>Build Med-Core on FastAPI with async request handlers. Pydantic models validate payloads before encryption, dependency injection enforces scope authorization in the pipeline, and the async runtime keeps PHI payloads out of blocking I/O paths.</description>
    <category>med-core</category>
    <category>fastapi</category>
    <category>python</category>
    <category>phi</category>
    <content:encoded><![CDATA[## The security boundary

The pipeline is ordered so that unauthenticated or unauthorized traffic never
reaches the encryption layer: authentication, then scope authorization, then
rate limiting, then schema validation, then field-level PHI encryption. Each
step is a dependency in the handler chain, so the boundary is reviewable in a
single file:

```python
@app.post("/intake", dependencies=[Depends(require_scope("intake:write"))])
async def ingest(
    payload: IntakePayload,
    limiter: RateLimiter = Depends(get_limiter),
) -> Receipt:
    await limiter.acquire("intake", key=payload.patient_id)
    encrypted = encrypt_phi(payload.model_dump())
    return await store.record(encrypted)
```

<Callout variant="warning" title="PHI boundary">
  Plaintext PHI exists only inside the request handler. Anything that leaves
  the handler — queue messages, audit events, logs — carries the encrypted
  payload, never the model.
</Callout>

## Async under load

Synthetic intake bursts previously serialized on thread-pool scheduling.
Async handlers keep the event loop free during Redis round trips and
Postgres commits, and the rate limiter throttles per patient instead of per
connection, which is what the burst tests actually exercise.]]></content:encoded>
  </item>
  <item>
    <title>Ledger-Core Sprint 01 — first reconciliation pass</title>
    <link>https://swbanga.com/journal/ledger-core-sprint-01/</link>
    <guid isPermaLink="true">https://swbanga.com/journal/ledger-core-sprint-01/</guid>
    <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
    <description>Retrospective on the first sprint of Ledger-Core: what shipped, what broke, and the three lessons that will shape Sprint 02.</description>
    <category>retrospective</category>
    <content:encoded><![CDATA[## What shipped

The first reconciliation pass runs end-to-end against real ledgers:

```bash
swb-cli generate journal --input reconciliation-notes.txt
```

It matched 98.4% of transactions on the first run — the remaining 1.6% are all
multi-currency imports.

<Callout variant="success" title="Worth repeating">
  Writing the integration test before the engine kept every step of the
  sprint anchored to a measurable target.
</Callout>

## What broke

The CSV importer assumed one currency per account. Three accounts in the
legacy data violated that, and the failure surfaced only at the boundary
tests. The fix was a schema guard — the same lesson as this site's content
pipeline.]]></content:encoded>
  </item>
  <item>
    <title>PostgreSQL over SQL Server for Ledger-Core Immutability Engine</title>
    <link>https://swbanga.com/architecture/adr-001/</link>
    <guid isPermaLink="true">https://swbanga.com/architecture/adr-001/</guid>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
    <description>Adopt PostgreSQL 16 as the Ledger-Core immutable store. Rows are never updated or deleted after settlement; mutations append versioned records, and the write path is wrapped in serializable transactions so reconciliation can replay the ledger from a single snapshot.</description>
    <category>ledger-core</category>
    <category>postgresql</category>
    <category>database</category>
    <category>immutability</category>
    <content:encoded><![CDATA[<Callout variant="warning" title="Superseded at v1.0">
  Superseded at Ledger-Core v1.0: the ledger store and outbox run on Azure
  SQL Edge / SQL Server with EF Core 10 — the PostgreSQL prototypes were
  replaced after managed-SQL Edge parity testing. See the related Ledger-Core
  case study for the current architecture; this record documents the
  superseded decision.
</Callout>

## Why not SQL Server

SQL Server is a strong operational database, but the Ledger-Core constraints
worked against it: the engine must run on Linux containers without a licensing
surface, the append-only write pattern maps poorly to row-level locking and
page-fill habits, and the reconciliation pipeline needs raw visibility into
transaction and WAL semantics. The legacy-license argument lost to the
container-first deployment posture.

## The immutable write path

Rows are versioned, never overwritten. A check constraint guards the version
sequence so a concurrent writer cannot silently clobber a settled entry:

```sql
CREATE TABLE ledger_entries (
  id            BIGSERIAL PRIMARY KEY,
  version       INT NOT NULL CHECK (version > 0),
  account_id    UUID NOT NULL,
  amount        NUMERIC(19, 4) NOT NULL,
  settled_at    TIMESTAMPTZ,
  audit_json    JSONB,
  UNIQUE (account_id, version)
);
```

<Callout variant="info" title="Append-only invariant">
  Reconciliation replays the ledger from a single snapshot: every mutation
  appends a new version instead of touching prior rows, so the replay is a
  linear scan with no history table to maintain.
</Callout>

## Replay and audit

Because the store is append-only, the auditor's view is a pure function of
the WAL and the versioned table — no temporal extensions, no triggers, no
shadow tables. The cost is that all replay logic is application-owned: the
serializable-transaction write path is the sole writer, and anything that
bypasses it breaks the audit chain.]]></content:encoded>
  </item>
  <item>
    <title>Why your content pipeline should validate at build time</title>
    <link>https://swbanga.com/blog/build-time-validation/</link>
    <guid isPermaLink="true">https://swbanga.com/blog/build-time-validation/</guid>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
    <description>Frontmatter validation at build time catches authoring errors before they reach production — here is the pattern and its guarantees.</description>
    <category>ssg</category>
    <category>nextjs</category>
    <content:encoded><![CDATA[## The failure mode

A content site without validation has two failure modes: silently dropped
fields (Zod's default is to *strip* unknown keys) and typo'd field names that
render empty sections. Both ship to production unnoticed.

## The fix

Every `.mdx` file under `/content/` is parsed with `gray-matter` and validated
with a strict Zod schema during static generation:

```ts
const parsed = schema.safeParse(file.data);
if (!parsed.success) {
  throw new Error(
    `Invalid frontmatter in ${filePath}:\n${formatZodIssues(parsed.error)}`,
  );
}
```

<Callout variant="warning" title="Guarantee boundary">
  Build-time validation protects *frontmatter*, not the rendered page. It only
  runs during `next build` — nothing is validated at runtime in a static site.
</Callout>

## What you get

- A build that fails loudly with the file path and the offending field
- A single canonical field contract for every content type
- No runtime cost: the validator executes once, at build time]]></content:encoded>
  </item>
  <item>
    <title>Adopt Git-backed MDX with strict Zod validation</title>
    <link>https://swbanga.com/architecture/adr-000/</link>
    <guid isPermaLink="true">https://swbanga.com/architecture/adr-000/</guid>
    <pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
    <description>Store all content as MDX files under /content with YAML frontmatter validated by strict Zod schemas at build time. Invalid or missing fields fail the static export with an explicit error naming the file and field.</description>
    <category>mdx</category>
    <category>zod</category>
    <category>static-export</category>
    <content:encoded><![CDATA[## The parser

Every route on swbanga.com that renders long-form content — ADRs, engineering
journals, case studies — needs a single ingestion path. The architecture
invariant is strict static export, so the parser must run during the build.

<Callout variant="info" title="V1 boundary">
  The FastAPI backend and dynamic AI APIs are deferred to V2. Everything in V1
  compiles to static HTML at build time.
</Callout>

Adopt `next-mdx-remote` for MDX compilation and `zod` for frontmatter
validation. The parser lives in `src/lib/mdx.ts`:

```ts
const parsed = CONTENT_SCHEMAS[contentType].safeParse(file.data);
if (!parsed.success) {
  throw new Error(
    `Invalid frontmatter in ${filePath}:\n${formatZodIssues(parsed.error)}`,
  );
}
```

## Enforcement

The build becomes the enforcement point: a missing `summary` or a mistyped
`publishedat` aborts the export before it reaches Cloudflare.]]></content:encoded>
  </item>
  </channel>
</rss>
