Skip to content

0024 — Cloud portability & provider abstraction ​

Status: Accepted (2026-06-18 — owner confirmed Postgres + container; supersedes the ADR 0004 compute & database choices)

Date: 2026-06-18

ADO work item: AB#3154

Deciders: Kristopher Turner (platform owner)


Context ​

ADR 0004 chose Azure as the initial platform: Azure Functions (Consumption) for API compute, Azure SQL Serverless for the relational database, Azure Blob Storage for file/media, and Key Vault for secrets. That stack is cost-effective for a ~200-member community and aligned with the owner's Azure expertise and the prospect of Microsoft Nonprofits credits.

However, the platform's API-first design (ADR 0008) and the "decide before build" gate both call for examining lock-in risks before Phase 1 construction begins. Three observations motivate this ADR:

  1. Provider SDKs can bleed into business logic. Without deliberate seams, Azure SDK imports (Storage, SQL, Key Vault, Functions host bindings) accumulate inside route handlers and service classes, making the codebase progressively harder to relocate without a partial rewrite.

  2. The database choice carries the highest long-term lock-in risk. Azure SQL Serverless uses the SQL Server dialect (T-SQL: TOP, GETDATE(), JSON path syntax, etc.) rather than standard SQL or Postgres dialect. Migrating away later requires schema translation, query rewrites, and ORM reconfiguration — not just an adapter swap. By contrast, Postgres runs identically on Azure Database for PostgreSQL, AWS RDS, GCP Cloud SQL, Supabase, and self-hosted servers, with no dialect translation between providers.

  3. The ADR 0004 compute choice (Azure Functions) is host-specific. Azure Functions bindings (context.bindings, @azure/functions trigger decorators) are not portable. A containerized API (Docker) runs identically on Azure Container Apps, AWS Fargate/ECS, GCP Cloud Run, or a VPS — with no host-specific binding code.

Auth (Clerk, ADR 0003) and CI/CD (GitHub Actions, ADR 0004) are already provider-neutral. The four remaining lock-in seams are compute, database, storage, and secrets.

This ADR does not propose migrating away from Azure. Azure remains the chosen provider. It proposes that the four lock-in seams be wrapped behind thin interfaces so that moving becomes an adapter swap rather than a rewrite — and that the database choice (Azure SQL vs Postgres) be revisited now, before schema work begins, because it is the hardest to change later.

This is a planning/architecture decision. It introduces no code.

Decision ​

We will adopt a portable-by-design posture by abstracting the four cloud-provider lock-in primitives — compute, database, storage, and secrets — behind interfaces that keep all business logic in plain TypeScript with no provider SDK imports. We will containerize the API with Docker so it runs identically on any OCI-compliant host. We will remain on Azure as the active provider but structure the codebase so that relocating to another provider is an adapter swap, not a rewrite. The database choice is now decided: Postgres (running on Azure Database for PostgreSQL Flexible Server as the active managed host), chosen over Azure SQL Serverless on portability grounds — the identical Postgres dialect runs unchanged on AWS RDS, GCP Cloud SQL, Supabase, or a self-hosted/on-prem server, so relocating the database becomes a connection-string swap with no dialect translation. This supersedes the ADR 0004 database choice.

The four abstraction seams ​

SeamInterfaceAzure adapterPortable alternatives
ComputeOCI container (Docker)Azure Container AppsAWS Fargate/ECS, GCP Cloud Run, self-hosted
DatabaseORM + repository layer (no raw dialect SQL in business logic)Azure DB for PostgreSQL Flexible Server (Postgres — decided)AWS RDS, GCP Cloud SQL, Supabase, self-hosted Postgres
StorageStorageProvider interface — upload(), delete(), signedUrl() (shared with ADR 0010)Azure Blob Storage adapterAWS S3, GCP Cloud Storage, any S3-compatible store
SecretsSecretsProvider interface — getSecret(name)Azure Key Vault adapterAWS Secrets Manager, GCP Secret Manager, env-based fallback

Provider SDK imports (@azure/storage-blob, @azure/keyvault-secrets, DB client) are permitted only inside their adapter module. No SDK import may appear in a route handler, service class, or domain entity. This boundary is enforced at code review; a lint rule (import/no-restricted-paths) will formalize it once the adapter directories are established.

Compute: container over Functions host bindings ​

ADR 0004 chose Azure Functions Consumption. This ADR supersedes that compute choice. A containerized Node.js/Express (or Fastify) API server is the portable alternative:

  • No @azure/functions trigger decorators or context.bindings in route code.
  • The same Docker image runs locally (development), on Azure Container Apps (production), and on any other OCI host without modification.
  • Azure Container Apps free tier covers low-traffic usage; the Functions Consumption free tier (1 M executions/month) was the primary cost argument — Container Apps free tier is comparable for a ~200-member community with light API traffic.

ADR 0004 is updated by this decision on the compute question. The GitHub Actions CI/CD, Azure SWA for the web app (heritageva.app — no public/marketing site exists), Azure Blob Storage, and Key Vault choices from ADR 0004 remain unaffected.

Database: DECIDED — Postgres (Azure Database for PostgreSQL Flexible Server) ​

Decision (owner-confirmed 2026-06-18): the platform database is Postgres, hosted on Azure Database for PostgreSQL Flexible Server as the active managed instance. This supersedes the Azure SQL Serverless choice in ADR 0004.

Why Postgres over the prior Azure SQL Serverless choice:

Azure SQL Serverless (prior ADR 0004 choice)Postgres (chosen)
DialectSQL Server / T-SQL — provider-specificStandard Postgres — identical everywhere
PortabilityAzure-only; no equivalent T-SQL host elsewhere without licensingRuns unchanged on Azure DB for PostgreSQL, AWS RDS, GCP Cloud SQL, Supabase, self-hosted/on-prem
Relocation costSchema + query + ORM rewrite (dialect translation)Connection-string swap; no dialect translation
ORM fitPrisma supported but abstracts only some T-SQL differencesPrisma supports Postgres natively
Free/cheap fitFree tier, pauses on idleFlexible Server burstable (B1ms) free trial / low-cost tier; Nonprofits credits cover production

The decisive factor is the owner's explicit requirement: the platform must be deployable to GCP, AWS, or on-prem if needed. Managed Postgres in Azure today satisfies that without giving up the exit — the same database engine and dialect move with the application. Azure SQL's T-SQL dialect would have made the database the single hardest seam to relocate.

Phase 1 defines the schema as a Prisma schema file with provider = "postgresql". The live instance is provisioned on Azure Database for PostgreSQL Flexible Server during Phase 1 infrastructure work (AB#3074).

What stays provider-neutral today (no change needed) ​

  • Auth — Clerk (ADR 0003): SaaS, no provider coupling.
  • CI/CD — GitHub Actions (ADR 0004): no change.
  • Web app hosting — Azure SWA Free (ADR 0004): confirmed as the definitive host for apps/web; custom domain heritageva.app; installable PWA; no public/marketing site exists.
  • Packages and shared types — pure TypeScript, no provider imports by definition.

Alternatives considered ​

OptionProsConsWhy not chosen
Portable-by-design: abstract the four seams, containerize, stay on Azure (chosen)Relocation becomes adapter swap; business logic stays clean; modest upfront overheadSmall design cost for interfaces + container build step; Container Apps free tier must be verified against Functions free tier— chosen
Full Azure-native lock-in (no interfaces, Functions bindings, Azure SQL dialect)Least upfront work; simplest local dev without Docker; Functions Consumption 1M/month is well-understoodEvery provider touch point is in business logic; migration later requires partial rewrite; SQL dialect lock-in is the hardest to reverseRejected — the "decide before build" gate exists precisely to avoid this; rewrite cost grows with every feature added
Build a full multi-cloud abstraction immediately (abstract storage, compute, DB, secrets and build infrastructure-as-code for two providers now)Maximum future flexibilitySignificant over-engineering for a ~200-member community with a single active provider; delays Phase 1 by weeksRejected — we abstract the seams, not everything; actual multi-provider IaC is deferred until there is a concrete reason to operate on more than one provider simultaneously

Consequences ​

Positive ​

  • Moving off Azure becomes an adapter swap (update four adapter modules + connection strings) rather than a rewrite touching every route handler and service class.
  • Business logic is easier to read and test: no Azure SDK types or async patterns mixed into domain code; adapters can be mocked with simple interface fakes.
  • Containerizing the API enables identical local, CI, and production environments — eliminates the "works on Functions locally but behaves differently in cloud" class of issues.
  • Choosing Postgres now (if confirmed) removes the hardest migration risk entirely and opens the Supabase path without any schema translation if the owner later wants it.
  • The StorageProvider interface is already anticipated by ADR 0010 (Sermons & Music Hub media storage); defining it here makes ADR 0010 implementation straightforward.

Negative / trade-offs ​

  • Interface overhead. Each abstraction layer (four interfaces + four initial Azure adapters) is a small amount of code that must be written and maintained. At ~200 members, it is unlikely this code will ever be exercised against a second provider — the abstraction exists for optionality, not for immediate use.
  • Container build step. Adding Docker to the CI pipeline increases build time slightly and requires Docker in the local development environment. The Azure Functions Consumption in-process model has no container requirement and is simpler for local dev.
  • Azure Container Apps vs Functions Consumption cost. Functions Consumption 1M free executions/month is a well-documented ceiling. Azure Container Apps free tier (180,000 vCPU-seconds + 360,000 GiB-seconds/month) requires verification against actual traffic patterns before committing. Must be validated before Phase 1 compute provisioning.
  • Postgres re-evaluation. Reopening a decided item in ADR 0004 creates brief planning overhead. However, the database is the highest-lock-in seam and the decision is cheapest to reverse before any schema is written.

Risks ​

  • Leaky abstractions — a future developer imports an Azure SDK directly into a service class, eroding the seam. Mitigation: import/no-restricted-paths lint rule; documented in the contribution guide and enforced at code review.
  • Interface granularity mismatch — the StorageProvider interface designed here does not cover a capability a future feature needs (e.g. streaming multipart upload, lifecycle policies). Mitigation: extend the interface when the need arises; the Azure adapter is updated first, then other adapters catch up only when the provider changes.
  • Container Apps free tier shortfall — if actual API traffic exceeds the Container Apps free allotment, the cost model changes. Mitigation: verify the free tier ceiling in the Phase 1 infrastructure research task before provisioning; retain the option to add Azure Nonprofits credits if needed.
  • Postgres operational familiarity — the team's prior database experience is Azure SQL / T-SQL; Postgres on Azure DB for PostgreSQL Flexible Server introduces a different managed-service surface (extensions, connection pooling via PgBouncer, backup model). Mitigation: Flexible Server is a fully managed PaaS; Prisma abstracts day-to-day query work; the portability gain is judged worth the modest learning curve. (The earlier "reversal to Azure SQL" risk no longer applies — Postgres is decided.)

References ​

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.