vault

16 - AWS Deployment

docs/16-AWS-Deployment.mdtype: operationsupdated: 2026-05-19

AWS Deployment

This is the MVP AWS dev path for Newdax. It deliberately keeps runtime simple: one Graviton EC2 instance for the app, one small Graviton RDS Postgres instance, no autoscaling, Docker images in ECR, runtime configuration in AWS Secrets Manager, and GitHub Actions deployment after tests pass on master.

Region Policy

The MVP default AWS region is eu-north-1 (Stockholm), not us-east-1. In AWS terminology this is a region; individual availability zones inside it are named like eu-north-1a.

Application infrastructure runs in eu-north-1: VPC, ALB, EC2, ECR, RDS, Secrets Manager, SSM, WAF, and the app-domain ACM certificate.

There is one AWS-mandated exception in the default self-hosted-login stack:

  • AWS Budgets alert: account-level Budgets API through the dedicated billing provider alias. This does not change the MVP application region.

What Terraform Creates

Layer Resource
Network VPC, two public subnets, two private DB subnets, internet gateway, route table
Edge Public Application Load Balancer, Route 53 app records for daxcha.in, my.daxcha.in, auth.daxcha.in, and admin.daxcha.in, DNS-validated ACM certificate, HTTPS listener with HTTP redirect, optional API Gateway HTTPS fallback, AWS WAF rate-based rule
Compute One ARM64 Amazon Linux 2023 EC2 instance, default t4g.small, managed by SSM
Containers Docker web container and optional worker container on the same instance
Registry ECR repository with scan-on-push and image retention
Database RDS Postgres, default db.t4g.small, private subnet group, encrypted storage
Secrets App env secret in Secrets Manager, RDS-managed master password secret
Auth Auth.js sessions with Postgres-backed one-time magic-link challenges
Email Resend domain verification, DKIM and SPF/bounce DNS records when Route 53 DNS is managed
Deploy state SSM parameter containing the current image tag
IAM EC2 runtime role, optional GitHub Actions OIDC deploy role
Logs CloudWatch log group for Docker awslogs output
Cost control Optional AWS Budgets monthly cost alert with email notifications

The EC2 instance is in a public subnet so it can pull from ECR and Secrets Manager without a NAT gateway. Inbound traffic is restricted to the ALB security group on the app port. SSH is not opened; use AWS Systems Manager Session Manager if you need shell access.

First-Time Setup

  1. Use the committed Terraform variables file:
cd infra/terraform/dev

dev.tfvars is safe to commit and should contain only non-secret dev configuration, for example:

github_repository = "OWNER/REPOSITORY"
aws_region        = "eu-north-1"
environment       = "dev"

# Optional but recommended.
cost_alert_email_addresses    = ["ops@example.com"]
cost_budget_monthly_limit_usd = 120

github_repository must exactly match the GitHub owner/repository slug used by Actions. For this repo, use bullder/dax. If it does not match, AWS denies OIDC with Not authorized to perform sts:AssumeRoleWithWebIdentity.

  1. Initialize Terraform:
terraform init

From the repository root on PowerShell, use the helper instead:

pnpm infra:tf init

Dev now targets the registered daxcha.in domain directly. The default vars use aws_region = "eu-north-1", an existing public Route 53 hosted zone for daxcha.in, an ACM certificate in eu-north-1, and ALB aliases for daxcha.in, my.daxcha.in, auth.daxcha.in, and admin.daxcha.in:

aws_region             = "eu-north-1"
domain_name            = "daxcha.in"
app_domain_name        = "daxcha.in"
manage_route53_dns     = true
create_route53_zone    = false
create_acm_certificate = true

From the repository root on PowerShell:

pnpm infra:tf plan
pnpm infra:tf apply

If Terraform reports No valid credential sources found, authenticate AWS first. The helpers use AWS profile dax by default:

aws sso login --profile dax
pnpm infra:tf plan

Pass -Profile only when using a different profile:

aws sso login --profile <profile-name>
pnpm infra:tf -Profile <profile-name> plan

If you call Terraform directly from PowerShell, prefer -var-file dev.tfvars instead of -var-file=dev.tfvars; the equals form can be parsed awkwardly by native Windows argument handling.

If Terraform reports that no matching Route 53 hosted zone exists, set create_route53_zone = true, apply once, copy the route53_name_servers output to the domain registrar for daxcha.in, wait for delegation, then run the full apply again so ACM DNS validation can complete.

The first EC2 boot can be healthy from an infrastructure perspective while the app target is still empty. That is expected before the first image is pushed to ECR.

  1. Add app runtime secrets. Keep local secrets in the ignored file infra/terraform/dev/dev.secrets.json:
{
  "appEnv": {
    "APP_URL": "terraform-output-app-url",
    "AUTH_URL": "terraform-output-auth-app-url",
    "AUTH_COOKIE_DOMAIN": ".daxcha.in",
    "AUTH_SECRET": "replace-with-openssl-rand-base64-32",
    "AUTH_MAGIC_LINK_TTL_MINUTES": "10",
    "PROVIDER_MODE": "mock",
    "EMAIL_PROVIDER": "resend",
    "MAIL_FROM": "robot@daxcha.in",
    "RESEND_API_KEY": "",
    "SUMSUB_APP_TOKEN": "",
    "SUMSUB_SECRET_KEY": "",
    "betterstack_telemetry_api_token": "",
    "betterstack_uptime_api_token": ""
  },
  "terraform": {}
}

The appEnv object is uploaded to AWS Secrets Manager for the app containers. pnpm infra:tf also reads the Better Stack API tokens from appEnv and passes them to Terraform as TF_VAR_* values, so those sensitive inputs do not live in committed dev.tfvars.

Pull the current app env secret from AWS Secrets Manager into appEnv:

pnpm infra:secret:pull

Then edit dev.secrets.json locally and upload appEnv when needed:

pnpm infra:secret:push

Raw terraform output ... commands read the current directory's state. From the repository root, use pnpm infra:tf output ...; if you call Terraform directly, use terraform -chdir=infra/terraform/dev output ....

Do not put DATABASE_URL in this secret. The EC2 deployment script builds it from the RDS-managed secret so the database password is not stored in Terraform state or GitHub.

  1. Store local Terraform state in AWS before switching machines or environments:
pnpm infra:state bootstrap
pnpm infra:state push

The state helper creates or hardens an account-scoped S3 bucket with public access blocked, versioning enabled, and server-side encryption enabled. This lightweight state sync does not configure Terraform locking, so avoid running multiple Terraform operations against this environment at the same time. To pull state on another machine, run:

pnpm infra:state pull -Yes

Domain And HTTPS

The dev Terraform stack uses https://daxcha.in:

aws_region                            = "eu-north-1"
domain_name                           = "daxcha.in"
app_domain_name                       = "daxcha.in"
manage_route53_dns                    = true
create_route53_zone                   = false
create_acm_certificate                = true

After apply, use these outputs for public runtime URLs in the app secret:

pnpm infra:tf output -raw app_url
pnpm infra:tf output -raw my_app_url
pnpm infra:tf output -raw auth_app_url
pnpm infra:tf output -raw admin_app_url
pnpm infra:tf output -raw app_egress_ip

The app_egress_ip output is the stable Elastic IP used by outbound requests from the app EC2 instance. Give this address to external providers that require IP allowlisting. It does not replace the ALB or the application domain names for inbound traffic.

These URLs are the custom HTTPS domains.

APP_URL is the only origin you configure. Every other surface is a hard-coded subdomain derived from it in lib/config/app-urls.ts:

Subdomain Serves
my. User / merchant workspace
auth. Login, register, magic-link verification
admin. Admin console
pay. Public payment pages
docs. API documentation
v2. In-progress UI rewrite
prd. Public PRD vault

Two related settings:

  • AUTH_COOKIE_DOMAIN — set to .daxcha.in so a session created on auth.* is visible on my.* and admin.*.
  • AUTH_URL — optional; it only overrides NextAuth's own base URL. The direct ALB HTTP URL remains available as alb_http_url for low-level diagnostics, and the API Gateway execute-api URL can remain available as a fallback or diagnostic endpoint.

Login starts and stays on auth.*. Next.js creates a hashed one-time challenge in Postgres and emails auth.<APP_URL host>/api/auth/magic-link/verify through the configured app email provider (Resend in deployed environments).

When resend_dns_verification_enabled and manage_route53_dns = true, Terraform manages the Resend verification TXT, DKIM CNAME, SPF and bounce-MX records. If DNS is external, create them from the resend_* outputs. Note that Resend's SPF and bounce-MX values point at amazonses.com — that is Resend's own sending infrastructure, not an SES identity of ours.

For a brand-new AWS-managed hosted zone, set create_route53_zone = true, apply the hosted zone first, and delegate DNS before enabling the full HTTPS stack:

pnpm infra:tf apply -target='aws_route53_zone.domain[0]'
pnpm infra:tf output route53_name_servers

Then set those name servers at the domain registrar for daxcha.in, wait for DNS delegation to propagate, and run the full plan/apply. This order lets ACM DNS validation complete during the full apply.

If the public hosted zone already exists in the same AWS account, set create_route53_zone = false or set route53_zone_id to the existing hosted zone ID.

If DNS is managed outside Route 53, set app_domain_name, keep manage_route53_dns = false, create an ACM certificate in eu-north-1 manually, pass its ARN as certificate_arn, and create DNS records pointing to pnpm infra:tf output -raw alb_dns_name. The certificate must cover app_domain_name plus my.<app_domain_name>, auth.<app_domain_name>, and admin.<app_domain_name>.

  1. Add GitHub repository variables from Terraform outputs:
GitHub variable Value
AWS_REGION The Terraform aws_region value, for example eu-north-1
AWS_ROLE_ARN pnpm infra:tf output -raw github_actions_role_arn
ECR_REPOSITORY pnpm infra:tf output -raw ecr_repository_name
IMAGE_TAG_PARAMETER pnpm infra:tf output -raw image_tag_parameter_name
SSM_TARGET_TAG_KEY pnpm infra:tf output -raw ssm_target_tag_key
SSM_TARGET_TAG_VALUE pnpm infra:tf output -raw ssm_target_tag_value
NEXT_SERVER_ACTION_ALLOWED_ORIGINS Optional comma-separated extra hosts for Server Actions, for example daxcha.in,my.daxcha.in,auth.daxcha.in,admin.daxcha.in

With GitHub CLI:

gh variable set AWS_REGION --body "eu-north-1"
gh variable set AWS_ROLE_ARN --body "$(pnpm infra:tf output -raw github_actions_role_arn)"
gh variable set ECR_REPOSITORY --body "$(pnpm infra:tf output -raw ecr_repository_name)"
gh variable set IMAGE_TAG_PARAMETER --body "$(pnpm infra:tf output -raw image_tag_parameter_name)"
gh variable set SSM_TARGET_TAG_KEY --body "$(pnpm infra:tf output -raw ssm_target_tag_key)"
gh variable set SSM_TARGET_TAG_VALUE --body "$(pnpm infra:tf output -raw ssm_target_tag_value)"
gh variable set NEXT_SERVER_ACTION_ALLOWED_ORIGINS --body "daxcha.in,my.daxcha.in,auth.daxcha.in,admin.daxcha.in"
  1. Run the first deployment by merging to master or manually running the CI and deploy workflow from the master branch.

Deployment Flow

On pull requests, GitHub Actions runs:

install -> migrate CI Postgres -> typecheck -> unit tests -> production build

On master after the same test job succeeds, GitHub Actions:

  1. Assumes the Terraform-created AWS role via GitHub OIDC.
  2. Builds a linux/arm64 Docker image for Graviton on a native GitHub-hosted arm64 runner.
  3. Pushes the image to ECR with tags GITHUB_SHA and latest.
  4. Writes GITHUB_SHA to the SSM image tag parameter.
  5. Sends an SSM command to the EC2 instance.
  6. The instance pulls the image, refreshes env from Secrets Manager, runs pnpm db:migrate, restarts the web container, and restarts the worker container when enabled.

Runtime Env Rules

Server-only variables can live in the app env secret and are read at container start. Current examples:

APP_URL
AUTH_URL
AUTH_SECRET
AUTH_COOKIE_DOMAIN
PROVIDER_MODE
EMAIL_PROVIDER
MAIL_FROM
RESEND_API_KEY
AUTH_MAGIC_LINK_TTL_MINUTES

For the MVP AWS deployment, keep PROVIDER_MODE=mock until each money-moving provider is live, but set EMAIL_PROVIDER=resend with RESEND_API_KEY in the app env secret so queued transactional emails and auth magic links are delivered from the worker.

Any future NEXT_PUBLIC_* values are build-time client values in Next.js. Add them to the Docker build args or workflow environment before docker/build-push-action, not only to Secrets Manager.

NEXT_SERVER_ACTION_ALLOWED_ORIGINS is also build-time. Use it for comma-separated custom hostnames that proxy to the app, for example daxcha.in,my.daxcha.in,auth.daxcha.in,admin.daxcha.in. When APP_URL is present at build time the app also derives those subdomain hosts automatically. The image always allows AWS execute-api hostnames in eu-north-1 for the dev HTTPS proxy.

The Terraform environment name remains dev for AWS resource names and tags, but the EC2 runtime writes APP_ENV=development for that stack. The app's dev-only payment simulation and /dev_login gates check the literal development value.

Rate Limiting

Terraform attaches a regional AWS WAF web ACL to the ALB when enable_waf = true. The default waf_rate_limit_per_5_minutes = 2000 blocks a single source IP after 2,000 requests in a five-minute WAF evaluation window.

Rate limiting is handled in-application against the Postgres rate_limits table, so it needs no additional infrastructure or environment variables. Adjust the WAF limit in dev.tfvars for the MVP traffic profile, and add narrower WAF rules later if specific auth or API endpoints need different limits.

Cost Alerts

Terraform can create a monthly AWS Budgets cost alert. It is enabled only when cost_alert_email_addresses contains at least one address:

enable_cost_budget                       = true
cost_alert_email_addresses               = ["ops@example.com"]
cost_budget_monthly_limit_usd            = 120
cost_budget_actual_threshold_percent     = 80
cost_budget_forecasted_threshold_percent = 100

This sends one notification when actual month-to-date spend crosses 80% of the budget and another when AWS forecasts that the month will cross 100%.

AWS Budgets is configured through a dedicated Terraform provider alias because it is an account-level billing service; the application infrastructure itself runs in eu-north-1.

Auth User Model

Auth.js owns browser sessions and Postgres owns the application identity model. The users table is the source of truth for email, role, merchant profile, MFA/passkey flags, blocked state, and admin authorization.

For normal users, start from /register. The app creates the users row, stores a hashed one-time token in auth_magic_link_challenges, and sends a magic link through the configured EMAIL_PROVIDER. When the user clicks the link, /api/auth/magic-link/verify consumes the DB challenge exactly once and signs the user into Auth.js.

To grant an existing user admin access, update their users.role row to admin, or use the admin console promotion action.

Rollback

Find a previous image tag in ECR, then point the instance at it:

$imageTagParameter = pnpm infra:tf output -raw image_tag_parameter_name
$ssmTargetTagKey = pnpm infra:tf output -raw ssm_target_tag_key
$ssmTargetTagValue = pnpm infra:tf output -raw ssm_target_tag_value

aws ssm put-parameter `
  --name "$imageTagParameter" `
  --type String `
  --value "<previous-git-sha>" `
  --overwrite

aws ssm send-command `
  --document-name "AWS-RunShellScript" `
  --targets "Key=tag:$ssmTargetTagKey,Values=$ssmTargetTagValue" `
  --parameters commands='["sudo /usr/local/bin/newdax-deploy"]'

If the previous image expects an older schema, rollback may also require a manual database migration plan. Forward-only migrations are the default assumption.

Production Hardening Backlog

  • Add Terraform state locking before more than one person applies it.
  • Add the production Route 53 records, ACM certificate, and HTTPS-only routing once the production hostname is chosen.
  • Move EC2 into private subnets with NAT or VPC endpoints when cost allows.
  • Expand WAF rules, and add CloudTrail, GuardDuty, alarms, backup alerts, and RDS Performance Insights.
  • Replace the single EC2 instance with an Auto Scaling Group across AZs when MVP traffic or availability requirements grow.
  • Split web and worker into separate services before scaling.