Skip to content

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-servicewait 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)
Email 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.