Deployment parity — Azure · AWS · Local¶
How the three reference installations compare, as of 2026-08-16 (both clouds deployed from the same commit). The design intent (ADR 0010 / 0028) is that every difference lives in deploy-time configuration — there are zero cloud-conditional code paths, and the same container images run everywhere.
Hosting¶
| Concern | Azure (primary reference) | AWS (second reference) | Local (compose) |
|---|---|---|---|
| Compute | Container Apps: api / worker / frontend + migrate job | ECS Fargate: same three services + migrate run-task | docker-compose services |
| Public surface | Frontend Container App (sole public ingress; api internal-only) | CloudFront → ALB → frontend (the ALB security group admits only the CloudFront managed prefix list; nginx re-verifies an origin-secret header) | localhost |
| Images | One generic GHCR image set, tag <sha> |
Same images, tag aws-<sha> |
Same images |
| Database | PostgreSQL (dedicated database + least-privilege app role) | RDS PostgreSQL | Postgres container |
| Queue / cache | Redis (password auth) | ElastiCache Redis (TLS rediss://) |
Redis container |
| Secret store | Key Vault (SECRET_STORE=azure_key_vault, managed identity) |
Secrets Manager (SECRET_STORE=aws_secrets_manager, task IAM role) |
OpenBao (openbao), or env for host-only dev |
| Identity — humans | Entra ID through the generic OIDC contract | Cognito through the same contract | Email OTP (bundled Mailpit) by default |
| Identity — machines | PATs (dq_live_) — identical everywhere |
← same | ← same |
| Observability | App Insights via OTel | ADOT collector sidecar → X-Ray + CloudWatch | Off by default; Jaeger via --profile telemetry |
| Email transport | Any SMTP relay | SES (note: a sandboxed SES account can only mail verified addresses) | Mailpit catcher |
| IaC | deploy/terraform/azure/ |
deploy/terraform/aws/ |
scripts/setup.sh + compose |
Features¶
The application feature set — suites/checks across all five datasources, every monitor kind, assets/lineage/incidents, alerting, scheduling, the 47-tool MCP server, PATs, rate limiting — is the same code everywhere. Where the installations genuinely differ:
| Capability | Azure | AWS | Local |
|---|---|---|---|
| Datasource runs | live-verified: Snowflake, Unity Catalog, ADLS, S3/S3-compatible, Iceberg | live-verified: Snowflake, Unity Catalog, native S3 | flat files, MinIO, Unity Catalog (Free Edition), Iceberg; no live warehouse required |
| Assets / lineage / incidents / DQ scorecard | live-verified (Snowflake full-tier lineage, UC dbt lineage, inventory sync) | deployed — same code; warehouse-lineage sweep enabled but not yet exercised against that account's grants | ✅ (Marquez reference consumer via --profile lineage) |
| Alerting — Teams / Slack | live-verified | deployed, unexercised | pointable anywhere |
| Alerting — email | configured | live-verified (SES) | Mailpit |
| MCP (48 tools) | E2E-verified | E2E-verified (PAT) | works; PAT-only under OTP (no IdP ⇒ no bearer) |
| Rate limiting | on (RATE_LIMIT_XFF_TRUSTED_HOPS=3, verified against a live XFF) |
on (same setting; verified through CloudFront — a spoofed X-Forwarded-For cannot escape the bucket, since hops=3 selects the edge-appended viewer IP) |
off by design |
| Browser security headers (CSP/HSTS/nosniff/frame-ancestors) | on — connect-src narrowed to the Azure AD origin |
on — connect-src narrowed to both Cognito origins (issuer + hosted UI) |
on, permissive connect-src default |
| Edge rate limiting (WAF) | none — no Front Door/WAF in front of the Container App; the in-app limiter is the only layer | on — CloudFront WAF per-IP ceiling in front of the in-app limiter | n/a |
| Edge caching | n/a | fingerprinted /assets/*.<ext> cached at the edge; /api, /mcp, index.html always reach the origin |
n/a |
| Sign-up gating | invite-only by nature (AAD tenant) | Cognito pool is admin-create-only + OIDC_ALLOWED_EMAILS |
OTP allowlist (mandatory) |
| Orchestration — ADF | ✅ (Azure-only by nature) | n/a | n/a |
| Orchestration — Airflow / dbt | live-verified | deployed but never fired — the receivers are live, but no event source exists on that stack | local Airflow |
The Airflow/dbt row is the one real capability gap: the code is identical and deployed, but the orchestration slice has never been exercised against the AWS installation. The cheapest closure is pointing an external Airflow's callbacks at the AWS public URL.
The edge rate-limiting row now runs the other way: AWS has a WAF ceiling in front of the
application and Azure has nothing equivalent. The in-app limiter (identical on both) fails
open when its Redis store is unwell, so on Azure a flood that also stresses Redis meets no
limiter at all. Front Door + WAF is the analogous Azure change and has not been made. Neither
deployment autoscales: desired_count = 1 / max_replicas = 3.
Deploying: Azure vs AWS¶
Both deploy workflows are workflow_dispatch-only with the same shape — backend job first
(build → migrate, gated on success → roll api + worker), then the frontend job gated on
the backend; a blank image_tag input defaults to the immutable commit SHA. The real
differences:
Azure deploy.yml |
AWS deploy-aws.yml |
|
|---|---|---|
| Cloud login | Azure OIDC federated credential | AWS OIDC role, trusting repo:…:environment:production |
| Tag namespace | <sha> |
aws-<sha> (parallel deploys from one registry never collide) |
| Migrations | Container Apps job start + poll until Succeeded |
Register a migrate task-def revision + run-task with the api service's live network config, gated on container exit 0 |
| Roll + verify | az containerapp update per app + revision verify |
Shared scripts/ci/ecs_roll.sh: register → update-service → wait services-stable → verify the primary deployment's image, so an ECS circuit-breaker rollback fails the run instead of reporting success |
| In-workflow smoke | none (the deploy README checklists are run manually) | final public-surface smoke against the AWS_FRONTEND_URL repo variable |
| Sidecars | n/a | the roll script swaps/verifies the essential container (api/worker task defs also carry the ADOT sidecar) |
| Rollout gotcha | — | task definitions are under ignore_changes[container_definitions]: env/sidecar changes need a targeted tofu apply -replace + update-service; the workflow only ever moves the image |
Configuration: what actually differs¶
Most backend env is identical in shape on both clouds (ENVIRONMENT, DATABASE_URL,
REDIS_URL, WORKSPACE_ADMIN_EMAILS,
RATE_LIMIT_XFF_TRUSTED_HOPS=3, CORS_ALLOW_ORIGINS="", PUBLIC_BASE_URL, the
*_WEBHOOK_SECRET_NAMEs, the EMAIL_* block, WAREHOUSE_LINEAGE_ENABLED). The genuine
differences:
Backend (api + worker)¶
| Concern | Azure | AWS |
|---|---|---|
| Secret store | SECRET_STORE=azure_key_vault + AZURE_KEY_VAULT_URL + AZURE_CLIENT_ID (the user-assigned identity must be selected — without it every Key Vault read fails) |
SECRET_STORE=aws_secrets_manager + AWS_SECRETS_MANAGER_PREFIX — no credential env at all; the task IAM role carries access |
| Token validation | Azure-native validator: AZURE_TENANT_ID, AZURE_API_CLIENT_ID, AZURE_SPA_CLIENT_ID, AZURE_API_SCOPE, AZURE_ALLOW_GUEST_USERS |
Generic OidcBearerScheme: just OIDC_ISSUER + OIDC_AUDIENCE. The user profile resolves via the userinfo endpoint, because Cognito access tokens carry neither email nor aud |
| Secret injection | Container Apps secret refs | ECS valueFrom Secrets Manager ARNs — start-time snapshots, so a rotated infra secret needs a service bounce |
| Telemetry | APPLICATIONINSIGHTS_CONNECTION_STRING |
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 (loopback into the ADOT sidecar) |
| Any SMTP relay + tfvars-sourced addresses | SES SMTP host + an IAM-derived SMTP password secret + alert_email / alert_email_to variables |
Frontend (runtime DATAQ_* contract — one nginx image, envsubst at startup)¶
Both set DATAQ_API_UPSTREAM, DATAQ_AUTH_MODE=oidc, DATAQ_AUTH_AUTHORITY,
DATAQ_AUTH_CLIENT_ID. The differences:
| Azure | AWS | |
|---|---|---|
| Scope | DATAQ_AUTH_API_SCOPE (Entra wants the API scope requested) |
DATAQ_AUTH_SCOPE="openid email profile" |
| Sign-out | standard RP-initiated logout | DATAQ_AUTH_LOGOUT_STYLE=cognito (Cognito's /logout wants client_id + logout_uri) |
| Origin guard | unset — open (Container Apps ingress is the only route in) | DATAQ_ORIGIN_SECRET (Secrets-Manager-injected; nginx 403s any request without the CloudFront-stamped header, /_alb-health exempt) |
For the local stack's configuration, see Running DataQ without Azure — the same seams, pointed at compose services.