Skip to content

Say what deploying to Azure actually took - #37

Merged
Badri-Rajendran merged 1 commit into
mainfrom
feature_azure_deployment
Sep 25, 2026
Merged

Badri-Rajendran merged 1 commit into
mainfrom
feature_azure_deployment

Conversation

@Badri-Rajendran

Copy link
Copy Markdown
Owner

Problem

PolicyPal is now deployed to Azure. Two documents describe that deployment and
both were wrong.

docs/runbooks/deploy.md would have failed four times before reaching a
running app:

  1. --public-access None (deploy.md:33) while step 6 restores the seed
    from a laptop — and a consumption Container Apps environment has no VNet
    path to a private server either. As written, nothing could reach the
    database.
  2. A :bootstrap image (deploy.md:52, :58) that the runbook never builds.
    Both az containerapp create and job create fail on it.
  3. No AcrPull grant. The app and job get system-assigned identities and
    no permission to pull, so the revision never starts.
  4. The Static Web App is never created, yet its deployment token is
    required in step 5 and by cd.yml.

ADR 0023 reasoned its way to scale-to-zero, a private database, a dedicated
environment and an unnamed region. The subscription permitted none of those.

Implementation

Documentation only — no code changes.

docs/runbooks/deploy.md rewritten from the commands that actually ran.
It now opens with the four Free Trial limits that shape the whole topology,
each found by hitting it:

Limit Symptom
Postgres restricted in eastus, eastus2, westus2, southcentralus "Provisioning is restricted in this region"
One Container Apps environment per subscription, globally MaxNumberOfGlobalEnvironmentsInSubExceeded
ACR Tasks not permitted TasksOperationsNotAllowed — az acr build unusable
Spending limit ON credit exhaustion deallocates rather than bills

Plus the two client-version traps: pg_dump 15 refuses a 16 server, and an
Apple Silicon docker build produces arm64 that Container Apps rejects.

ADR 0023 keeps its original reasoning and gains a What deploying it
actually changed
section covering the four forced decisions, with the reason
each was forced. Its SBC chunk count is corrected 31,359 → 35,756; the old
figure predated the ADR 0020 robots.txt refresh.

CHANGELOG gains the deployment and the seed under Added, and these
corrections under Changed.

Validation evidence

The estate, verified with real requests rather than assumed:

Check Result
/api/counties?zip=33101 200 in 0.43s, Miami-Dade FL
unauthenticated /api/auth/me 401 (not 500 — the PR #30 fix, in production)
Full RAG answer 13s, with citations
Migration job, run by hand Succeeded
Browser end-to-end signed up and asked a question on the live UI; answer cited Hcg Glossary Coinsurance 67% match
CORS with Origin header access-control-allow-origin matches exactly
Image platform linux/amd64, 639 MB compressed, built in 270s

The cited answer is the load-bearing check: it can only succeed if both models
loaded from the image under HF_HUB_OFFLINE=1 and the BM25 index loaded
from Postgres (ADR 0021).

Restore, every count matching source, zero errors:

chunks 1568 | sbc_chunks 35756 | plans 3276 | zip_counties 56280
search_indexes: bm25 | 1568     alembic_version: f6e50c4aec26

uv run ruff check . passes.

Security / DB / API impact

  • ADR 0016 — verified, not asserted. data/ checksummed before and after
    the whole deployment: a498f84b… both times, 2,247 files, 1,593 PDFs. Only
    the database crossed the wire; no PDF was uploaded anywhere.
  • No secrets in this diff. Checked by exact-string comparison against the
    live database password, the production JWT secret and the OpenAI key — all
    absent. The runbook shows shell variables ("$PGPASS"), never values. No
    .env staged; .env remains gitignored.
  • Production secrets live as Container Apps secrets referenced by
    secretref:. The JWT secret is freshly generated, not the dev value. The
    registry's admin user stays disabled; the app pulls via managed identity.
  • The database is public behind two firewall rules rather than private.
    This is a genuine weakening versus ADR 0023's intent, forced by the
    environment limit, and is documented as such rather than quietly adopted.
    TLS is enforced by Azure.
  • DB/API behaviour unchanged — no code in this PR.

Note for the reader

cd.yml has never run. The estate was built by hand, so the workflow's own
correctness is still unproven; the migration job was executed manually to
de-risk the step most likely to fail. Wiring up the GitHub Environment,
federated credentials and variables remains to be done, and the runbook lists
exactly what they are.

PolicyPal now runs on Azure. The runbook that was meant to guide that would
have failed four times over, so it is rewritten from what worked, and ADR 0023
records the four choices the subscription forced rather than the ones it
originally reasoned its way to.

The runbook's gaps: --public-access None while the seed restores from a
laptop, an app and job created from a :bootstrap image nothing builds, no
AcrPull for the managed identity, and a Static Web App whose token it needs
but never creates. It now also names the Free Trial limits that shape the
topology and the two client-version traps that cost real time.

ADR 0023 keeps its reasoning and gains an amendment: Central US because
Postgres is restricted in four regions, a shared Container Apps environment
because a Free Trial allows one per subscription, a public endpoint behind a
firewall because a consumption environment has no VNet path to a private
server, and one always-on replica instead of scale-to-zero at about $70 a
month. Its SBC chunk count is corrected to 35,756; the old figure predated the
ADR 0020 refresh.

Verified rather than asserted: counties in 0.43s, 401 unauthenticated, a cited
answer in 13s, the migration job green, and data/ byte-identical before and
after at 2,247 files and 1,593 PDFs.
Copilot AI lite review requested due to automatic review settings September 24, 2026 05:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread docs/runbooks/deploy.md
Comment on lines +77 to +83

**The server is public, behind a firewall — not `--public-access None`.** An
earlier version of this runbook said otherwise and was self-contradictory: the
seed in step 5 restores from your laptop, and a consumption Container Apps
environment has no VNet path to a private server. Public plus two firewall
rules is what actually works. Azure enforces TLS regardless.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR correctly documents that the production database is now public behind a firewall (also see ADR 0023's "database is public behind a firewall, not private"), but README.md#L363-L364 still says:

"Migrations run as a Container Apps job on the exact image digest being deployed, so the database needs no public opening."

That's now factually wrong and contradicts this runbook. CLAUDE.md requires: "Update README.md whenever setup, usage, architecture, or workflows change." Please update that README sentence to match the corrected architecture.

Comment thread docs/runbooks/deploy.md
Comment on lines +104 to +110
```

Measured on an M-series Mac under QEMU: **270s total** — `uv sync` 118s, both
models baked 56s, push 158s. Emulation overhead was far smaller than expected,
because the build is dominated by network and disk rather than CPU. The result
is **639 MB compressed** in the registry. Confirm the platform before
deploying — an arm64 image fails at pull time with an unhelpful message:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build-time breakdown doesn't add up: uv sync 118s + models 56s + push 158s = 332s, not the stated 270s total. Either the total or one of the components is wrong. The same 270s figure is repeated in ADR 0023's "Two smaller things worth knowing" section, so whichever number is correct should be fixed in both places.

Comment thread docs/runbooks/deploy.md
Comment on lines 64 to +72
az postgres flexible-server parameter set \
--resource-group policypal-rg --server-name policypal-db \
--name azure.extensions --value VECTOR

az postgres flexible-server firewall-rule create \
--resource-group policypal-rg --server-name policypal-db \
--name AllowAzureServices \
--start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0 rule is Azure's documented "Allow public access from Azure services" special case — it admits connections from any Azure-hosted resource in any subscription or tenant, not just this project's own resources. Here and in ADR 0023 this is described as just "Azure services," which reads narrower than it is. Since only the admin password then stands between the database and anyone who can run code on Azure, it's worth stating the actual scope explicitly (and considering scoping to the Container Apps environment's outbound IP instead).

@Badri-Rajendran
Badri-Rajendran merged commit ffced43 into main Sep 25, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants