Deployment Map
This is the authoritative reference for which service hosts which domain and how to deploy each one. Confusing these is the most common source of "I deployed but nothing changed" incidents.
Domain → Hosting Service
| Domain | Hosting | Stack | Region | How deployed |
|---|---|---|---|---|
hashpass.tech | Source CloudFront + Route53 | Static site from target-account S3 origin | global / us-east-1 | Auto — the target web pipeline publishes the origin; source Route53 aliases the apex to CloudFront |
dev.hashpass.tech | Source CloudFront + Route53 | Static site from target-account S3 origin | global / us-east-1 | Auto — the development pipeline publishes the dev origin; the source front door keeps the hostname HTTPS-only |
api.hashpass.tech | AWS Lambda + API Gateway | Expo Router API routes | us-east-1 | Auto — target web pipeline deploys hashpass-prod-expo-router-api and verifies /api/config/versions |
api-dev.hashpass.tech | AWS Lambda + API Gateway | Expo Router API routes | us-east-1 | Auto — target dev web pipeline deploys hashpass-dev-expo-router-api and verifies /api/config/versions |
bsl.hashpass.tech | Hybrid (cut over 2026-07-29): source-account CloudFront (unchanged, E2FCDJB1JCS7TW) fronting a plain target-account S3 bucket. Source-account pipeline deleted. | Static (Expo web export) | us-east-2 | Auto — bsl-hashpass-prod pipeline (target account, bsl-target stack) on push to main, running build-bsl-static-site.sh (no SST) |
bsl-dev.hashpass.tech | Hybrid (cut over 2026-07-28): source-account CloudFront (unchanged, E279RW9PP52TC0) fronting a plain target-account S3 bucket | Static (Expo web export) | us-east-2 | Auto — bsl-hashpass-dev pipeline (target account, bsl-target stack) on push to develop, running build-bsl-static-site.sh (no SST) |
hashpass.club | GitHub Pages | Next.js static | CDN | Auto — deploy-club-docs.yml on push to main |
hashpass.link, hpass.id, hashp.link | AWS Lambda + API Gateway (one shared prod API/Lambda, three custom domain mappings) | hashpass-links-api (QR/short-link redirect + HashPass Auth) | us-east-1 | Manual terraform apply — see below. hpass.id is primary, hashpass.link is the trust-oriented alias, hashp.link is a defensive alias; no domain-based analytics split |
Account split: what's on the source account vs. the target account
Two AWS accounts are in play: the source account (<source-account-id>)
and the target account (<target-account-id>). The target account owns
the active compute and the destination Route 53 zones. The former
2026-07-28 decision to retain DNS indefinitely in the source account is
explicitly superseded by the active AWS account and DNS cutover runbook.
As of 2026-08-16, registrar delegation has moved for hashpass.tech,
hashpass.club, and hashpass.lat; hashpass.info is pending. Retain the
source zones throughout the rollback window. Mail-provider ownership does not
change with DNS ownership: .tech uses Hostinger Email, .club and .info
use TLAO, and .lat has no mailbox routing.
Two things found live in AWS during that audit but not documented anywhere in this file before now:
bitacora.hashpass.tech— a CloudFront distribution exists for this hostname (source account, SST-placeholder origin, same shape as BSL) but nothing in this repo's docs, scripts, or workflows references it. Purpose unconfirmed (possibly a changelog/audit-log site — "bitácora" is Spanish for "logbook"). Needs identification before anyone can say how to deploy or maintain it.- Legacy Amplify app
bsl2025.hashpass.tech(source account,us-east-2) — confirmed archival/no longer maintained (2026-07-28); fine to leave stale on the source account, no deploy path needed.
Critical: The front door, API, and BSL deploy paths are independent
The public surface is now split across independent deployment paths:
- The source-account CloudFront front door serves
hashpass.techanddev.hashpass.techand aliases both hostnames to the target-account static origins. - The target-account web pipeline publishes the
hashpass.techS3 origin and thedev.hashpass.techdevelopment origin. - The same target web deploy helper packages the Expo Router API, updates the matching Lambda, and fails if the public API version endpoint is stale.
- Both
bsl.hashpass.techandbsl-dev.hashpass.techare served by the same hybrid path (target-account CodePipeline + EC2 worker running a plain static build/S3-sync, fronted by the unchanged source-account CloudFront distribution) — see below. The source-account SST pipelines that used to serve both are deleted. If a live domain seems stuck on an old version despite a working client-side reload, check whether this pipeline is actually shipping — see bsl-pipeline-orphaned-worker-incident.md for the diagnostic commands and a real incident where the worker instance died silently and the pipeline sat stuck for hours with nothing to notice.
These are completely independent. A failure in one does not affect the other. Check the correct dashboard when debugging.
How to Deploy Each Target
hashpass.tech
The public front door is a source-account CloudFront distribution. If the origin changes, update the target web pipeline and then cut the source alias over to the new distribution:
# Inspect the source front door
terraform -chdir=packages/infra/terraform/stacks/aws plan -var-file=terraform.dev.tfvars -var='site_origin_domain_name=hashpass-production-site-<target-account-id>-us-east-2.s3-website.us-east-2.amazonaws.com'
The target web pipeline publishes the S3 origin that CloudFront serves.
dev.hashpass.tech
The development web surface uses the same front-door pattern as production. The target-account develop pipeline publishes the dev S3 origin, and the source-account CloudFront front door keeps the hostname HTTPS-only while the target stack remains the origin of truth.
api.hashpass.tech / api-dev.hashpass.tech
The API lives in the target-account Lambda + API Gateway stack, not Amplify. The active web deploy helper packages the API with packages/tools/scripts/package-lambda.sh, updates the Lambda code, waits for the update, and verifies the public version endpoint.
Patch releases also run packages/tools/scripts/deploy-api-lambda.sh from infra-deploy.yml after the SST static deploy attempt. That workflow switches from the source-account infra role to the target-account AWS_WEB_PIPELINE_ROLE_ARN, builds a fresh Expo API bundle if needed, and then updates Lambda. It is intentionally redundant with the target web pipeline so a green static deploy cannot hide a stale API Lambda. The BSL SST static deploy is best-effort in this workflow because bsl.hashpass.tech also deploys through SST Console; the API Lambda update and public version verification remain hard-failing.
That intentional redundancy has a real, recurring race: both infra-deploy.yml and the hashpass-production-site CodePipeline's own CodeBuild job call deploy-api-lambda.sh on every push to main, and both update the SAME hashpass-prod-expo-router-api Lambda around the same time. AWS rejects a second in-flight UpdateFunctionCode/UpdateFunctionConfiguration on one function with ResourceConflictException — confirmed in production 2026-08-17 (v1.9.11): the CodePipeline's attempt failed outright a few seconds after the GH Actions attempt won the race and succeeded. The static site itself still deployed fine (that step runs before the Lambda update in the same build), and the Lambda was still correctly updated by the winning attempt — so a BuildSite: Failed status on this pipeline is not automatically a real incident; check /api/config/versions and the S3 origin's LastModified before assuming otherwise. deploy-api-lambda.sh now retries both calls on ResourceConflictException with a short fixed backoff (LAMBDA_UPDATE_MAX_ATTEMPTS/LAMBDA_UPDATE_RETRY_DELAY_SECONDS, default 6 × 5s) instead of failing the deploy over a lock a concurrent, equally-valid deploy already holds — this should make the CodePipeline attempt succeed on retry going forward instead of failing outright.
Lambda names:
- Production:
hashpass-prod-expo-router-api(us-east-1) - Development:
hashpass-dev-expo-router-api(us-east-1)
Version guard:
- Production must return the release version from
https://api.hashpass.tech/api/config/versions. - Development must return the release version from
https://api-dev.hashpass.tech/api/config/versions. - A deploy that leaves either endpoint stale is failed and must not be reported as complete.
hashpass.link / hpass.id / hashp.link
All three domains front the exact same production Lambda/API Gateway
(hashpass-links-prod-*, packages/infra/terraform/stacks/hashpass-links-api)
— one qr_links table, one set of scan-analytics rows, no per-domain split.
GET /q/:slug and every other route in packages/hashpass-links-api/src/router.ts
are host-agnostic by construction; adding a domain here is infra-only, not an
app code change. hpass.id is the primary short-link/QR domain (what
NEXT_PUBLIC_LINKS_API_BASE_URL / EXPO_PUBLIC_LINKS_API_BASE_URL_PROD
point at), hashpass.link is kept as an explicit/trust-oriented alias,
hashp.link is a cheap defensive alias.
hashpass.link's custom domain is the stack's own first-classaws_expo_router_apidomain (enable_custom_domain+domain_name.prod).hpass.idandhashp.linkare each wired via the additivepackages/infra/terraform/modules/aws_apigatewayv2_extra_domainmodule (ownenable_hpass_id_domain/enable_hashp_link_domainflags), which attaches one more ACM-cert-backed custom domain onto the same API instead of creating a new one — keepsaws_expo_router_api(also used byhashpass-api-targetandhashpass-autodiscover) untouched.- Hosted zones for
hpass.idandhashp.linkare owned bypackages/infra/terraform/stacks/hashpass-dns(same pattern astech/lat/club/info).hashpass.link's zone predates that stack and was created out-of-band; it's still only read via adatalookup there. Both new domains are registered at Spaceship — delegating them requires a manual NS-record update at the registrar to the zone's ownname_serversoutput, which Terraform can't do for you. - No dev counterpart for
hpass.id/hashp.link— prod-only. Dev traffic still usesdev.hashpass.link(already live under the sameaws_expo_router_apidomain as prod'shashpass.link). - A bare
GET /on any of the three domains 302s tohashpass.clubinstead of a raw JSON 404 (router.ts) — real visitors landing on the bare domain are a plausible case; every other unmatched path still 404s. - See
packages/hashpass-links-api/README.md's "Multi-domain cutover" section for the full rollout runbook (zone creation → registrar NS cutover → per-domainenable_*flag →terraform apply) and the exact Terraform commands. - Newly created custom domains can 404 intermittently for a few minutes
right after
terraform apply(ACM/API Gateway edge propagation across the regional endpoint's IP fleet) — this is normal AWS eventual consistency, not a config error; retest a few minutes later before assuming something's wrong. Also:curl -IsendsHEAD, which this service's redirect logic intentionally doesn't handle (every route, including the bare-domain redirect, isGET-only) — use plaincurl(GET) when testing, not-I.
bsl.hashpass.tech / bsl-dev.hashpass.tech
Prod and dev now run genuinely different deploy paths — read carefully before touching either.
Original incident (2026-07-25 to 2026-07-28): both bsl-hashpass-prod/bsl-hashpass-dev CodePipelines (source account, <source-account-id>) had FullRepositoryId set to edcalderon/hashpass.tech (a personal fork) instead of the org repo. bsl-hashpass-prod silently went 3 days / ~14 releases stale (last real trigger 2026-07-25, v1.8.260) because nothing in the release automation ever pushes to that fork's main branch — this is what caused bsl.hashpass.tech to show v1.8.273 while hashpass.tech was already on v1.8.274. Fixed the repo wiring on both source pipelines the same day. Full incident writeup: .agents/active/task-aws-account-migration.md.
packages/infra/terraform/stacks/bsl-target (target account, <target-account-id>) provisions a dedicated EC2 build worker (same reusable module hashpass-web uses — EC2 instead of CodeBuild because the target account's CodeBuild concurrent-build quota turned out to be 0 for every environment type, pre-existing and account-wide, unrelated to BSL) and two CodePipelines, bsl-hashpass-prod and bsl-hashpass-dev, both correctly wired to hashpass-tech/hashpass.tech.
Blocker discovered building this out: the target account still can't create new CloudFront distributions — AccessDenied: Your account must be verified before you can add new CloudFront resources (confirmed via a real failed sst deploy). This is a normal AWS anti-fraud check for new/low-usage accounts, not specific to us; an AWS Support case was submitted 2026-07-28 requesting verification (framed as an internal business-unit migration, not fraud). hashpass-web's own enable_cloudfront = true setting for its target CloudFront has the identical problem — the target account currently has zero CloudFront distributions of any kind.
bsl-dev.hashpass.tech: cut over to a hybrid, live since 2026-07-28, rather than wait on that verification:
- The existing source-account CloudFront distribution (
E279RW9PP52TC0, already-issued ACM cert, no new domain validation needed) keeps serving the domain — untouched, still source-account. - Its origin was repointed from SST's
placeholder.sst.dev+ CloudFront-Function/KV routing to a plain target-account S3 bucket (aws_s3_bucket.bsl_dev_siteinbsl-target/main.tf), via a one-time manualupdate-distributioncall (this resource predates proper IaC ownership — a realterraform importis a documented follow-up, not yet done). bsl-target's dev CodePipeline runspackages/tools/scripts/build-bsl-static-site.sh— a plainexpo export+aws s3 sync, no SST/Pulumi involved at all, so it never touches the blockedCreateDistributioncodepath.- The now-redundant source-account
bsl-hashpass-devCodePipeline +bsl-hashpass-dev-buildCodeBuild project were deleted the same day — leaving them running risked SST reconciling the distribution back to its own desired state on the nextdeveloppush, undoing the hybrid. - Verified live:
https://bsl-dev.hashpass.tech/servesserver: AmazonS3, confirmed viaget-distributionthat the origin is genuinely the new bucket. - Known gap: the build script does not invalidate CloudFront (the distribution is in a different account than the worker's credentials, so
deploy-static-site.sh's invalidation lookup can't resolve it). HTML/manifest objects getno-cacheheaders, so staleness is bounded but not instant.
bsl.hashpass.tech (prod): cut over 2026-07-29, same shape as dev. E2FCDJB1JCS7TW's origin was repointed from SST's placeholder.sst.dev to the target-account bucket (bsl-hashpass-bsl-prod-site-<target-account-id>-us-east-2), FunctionAssociations cleared, and verified live (server: AmazonS3, matching version). The source-account bsl-hashpass-prod CodePipeline + CodeBuild project, plus every other orphaned source-account BSL resource (140GB artifact bucket, both SST-era web-asset buckets, both CloudFront Functions, all three BSL IAM roles), were deleted the same day — the source account now has zero BSL resources of any kind. Full writeup: .agents/done/task-aws-account-migration.md. The CloudFront distributions themselves (E2FCDJB1JCS7TW, E279RW9PP52TC0) deliberately stay on source, same permanent shape as hashpass.tech/dev.hashpass.tech — migrating them is tracked separately in .agents/pending/task-bsl-cloudfront-distribution-migration.md, blocked on AWS Support's target-account CloudFront verification.
Diagnosing a "slow" BSL build: the EC2 worker runs jobs one at a time, and a cancelled CodePipeline execution does not stop the worker's build process — an orphan can block every subsequent job indefinitely while showing a near-idle CPU. If a build looks abnormally long, check CPU utilization first (idle + InProgress = hang or queue, not a slow build), then ps/journalctl on the worker over SSM. A build_timeout_seconds guard now exists in the worker module to bound this automatically, but it only takes effect after an instance replacement. See the "EC2 pipeline worker: operational gotchas" section of the migration task for the full writeup and a copy-pasteable debugging recipe.
For a manual one-off SST deploy from a workstation with target-account credentials (prod only — dev no longer uses SST):
HASHPASS_INFRA_TARGET=bsl pnpm --filter @hashpass/infra run deploy:prod
Note: requires an IAM role with Route53, CloudFront, S3, and SSM permissions.
Manually triggering the GitHub Actions infra-deploy workflow
infra-deploy.yml triggers automatically on push to main/develop when infra or API files change. You can also trigger it manually:
gh workflow run infra-deploy.yml --repo hashpass-tech/hashpass.tech
The IAM role (hashpass-mobile-release-github-actions) has the hashpass-infra-deploy-sst inline policy covering: SSM, S3, Lambda, CloudFront (create/update/invalidate), Route53 (ListHostedZones, ChangeResourceRecordSets, GetChange), and ACM (certificate management).
CI/CD GitHub Actions Workflows
| Workflow | Trigger | Does what |
|---|---|---|
mobile-android-release.yml | Manual (gh workflow run ... --ref v<VERSION>) | EC2 → Fastlane → Play Store production or closed testing tracks (release_status=draft for the first alpha upload while the Play app is still draft) |
secret-scan.yml | Push to main/develop, PRs | gitleaks scan of committed files |
deploy-club-docs.yml | Push to main | Builds and publishes hashpass.club to GitHub Pages |
infra-deploy.yml | Push to main/develop (infra/api paths) + manual | Best-effort SST static deploy attempt (no longer BSL's live serving path since the hybrid cutover; API Lambda update + version verification remain the hard release gate) |
release-infra.yml | Manual | Version bump + infra deploy |
Native Android App — dev builds hit api-dev (intentional)
Android CI builds with --field environment=development embed EXPO_PUBLIC_SUPABASE_PROFILE=core-development into the JS bundle. At runtime, readBuildEnvironment() in lib/api-client.ts detects "development" as a substring and routes all API calls to api-dev.hashpass.tech. This is by design — the dev build tests against the dev Supabase project AND the dev Lambda together.
| CI field | Supabase profile | API Lambda |
|---|---|---|
environment=development | core-development | hashpass-dev-expo-router-api (us-east-1) |
environment=production | core-production | hashpass-prod-expo-router-api (us-east-1) |
Keep the Lambdas in sync: hashpass-dev-expo-router-api is updated through the target-account deploy path. Always merge main → develop and redeploy after every release so dev builds don't run stale server code.
If you need to fast-sync api-dev with api-prod without a full build (e.g. after a hotfix):
aws lambda get-function --function-name hashpass-prod-expo-router-api --region us-east-1 \
--query 'Code.Location' --output text | xargs curl -s -o /tmp/lambda-prod.zip
aws lambda update-function-code --function-name hashpass-dev-expo-router-api \
--region us-east-1 --zip-file fileb:///tmp/lambda-prod.zip
Lambda Environment Variables
Both hashpass-prod-expo-router-api and hashpass-dev-expo-router-api use hostnameFromRequest() to select a Supabase profile from the request's Origin / Referer / Host header. See apps/mobile-app/config/supabase-profiles.ts for the host→profile mapping:
api.hashpass.tech→core-productionapi-dev.hashpass.tech→core-development
All secrets (Supabase service keys, SMTP credentials, OAuth secrets) are configured directly in each Lambda's environment — not via SST at deploy time. To update Lambda env vars:
# Production
aws lambda update-function-configuration \
--function-name hashpass-prod-expo-router-api \
--region us-east-1 \
--environment "Variables={KEY=value,...}"
# Development
aws lambda update-function-configuration \
--function-name hashpass-dev-expo-router-api \
--region us-east-1 \
--environment "Variables={KEY=value,...}"
Or use the AWS Console → Lambda → select function → Configuration → Environment variables.
CloudFront Distributions
hashpass.tech and bsl-dev.hashpass.tech both use a source-account CloudFront distribution that fronts a target-account static origin (S3). Keep DNS and certificate validation changes in the source zone and origin changes in the target stack.
Both bsl.hashpass.tech (E2FCDJB1JCS7TW) and bsl-dev.hashpass.tech (E279RW9PP52TC0) are now out of SST's control (dev since 2026-07-28, prod since 2026-07-29) — neither pipeline runs SST anymore, so nothing will overwrite manual changes to either distribution. Both origins are plain S3 website endpoints now, safe to inspect/manage directly (ideally via Terraform import, not yet done — see .agents/pending/task-bsl-cloudfront-distribution-migration.md).
Both now use a custom cache policy (63f0a203-7003-40af-96b6-3bac93c0645d, hashpass-bsl-hpv-cache-key), applied 2026-08-01. They previously used the AWS-managed Managed-CachingOptimized policy, which ignores query strings for cache-key purposes — the same bug the hashpass.tech front door had (see CDN_CACHE_BUSTING_HPV.md), meaning the web app's ?_hpv=<timestamp> hard-reload cache-buster couldn't force a real CDN cache miss on BSL either. The custom policy is an exact clone except it whitelists _hpv as a query-string cache key. Applied via direct aws cloudfront update-distribution (not Terraform — see note above). Still open: BSL's deploy pipeline still doesn't invalidate CloudFront on deploy, so a stale edge object still only clears on natural TTL/revalidation, not proactively on push.
hashpass.tech's distribution (E2SQE7ZSNJ4MMI) whitelists _hpv as a query-string cache key (fixed 2026-07-31). The web app's forced-reload cache-buster (performHardReload() in apps/mobile-app/lib/version-checker.ts) appends ?_hpv=<timestamp> to force a real network fetch past the browser/service-worker layers, but the distribution previously had query_string = false, so CloudFront ignored the param and could still serve a stale edge-cached copy of index.html/the bundle regardless of the timestamp. packages/infra/terraform/stacks/aws/main.tf's default_cache_behavior now uses query_string = true + query_string_cache_keys = ["_hpv"] (whitelist mode) so _hpv varies the cache key while every other query string is still dropped, keeping cache efficiency for hashed static assets unaffected. Full writeup, including known unrelated pending Route53/GitHub Pages drift in this same stack: CDN_CACHE_BUSTING_HPV.md. The identical query_string = false pattern still exists, dormant (count = 0), on hashpass-web's and aws_static_site_pipeline's CloudFront resources — apply the same fix there before either goes live.