Deploying GUARDIAN Server to AWS ECS
This guide covers the current AWS deployment for Guardian. The AWS stack now uses Amazon RDS for PostgreSQL and no longer supports the legacy ECS-hosted Postgres runtime.
The deployment surface supports two stage profiles:
DEPLOY_STAGE=devkeeps the current low-cost, fixed-capacity behaviorDEPLOY_STAGE=prodenables ECS autoscaling, RDS storage autoscaling, RDS Proxy, larger default RDS sizing, and benchmark-oriented runtime defaults
Published Docker images
Prebuilt, versioned server images are published to the GitHub Container Registry
(GHCR) at ghcr.io/openzeppelin/guardian, so you can pull a known-good image
instead of building from source:
docker pull ghcr.io/openzeppelin/guardian:<version> # e.g. v1.2.3, or latest
Images are multi-architecture (linux/amd64 + linux/arm64) and fully
runtime-configurable — every setting and secret is supplied at run time, never
baked in (see docs/CONFIGURATION.md):
docker run --rm -p 3000:3000 -p 50051:50051 \
--env-file ./guardian.env \
ghcr.io/openzeppelin/guardian:<version>
To run the published image with a Postgres backend locally, use the registry compose file (no local build):
cp .env.registry.example .env.registry # then set POSTGRES_PASSWORD in .env.registry
GUARDIAN_VERSION=<version> docker compose --env-file .env.registry -f docker-compose.registry.yml up
The stack is driven entirely by the gitignored .env.registry (see
.env.registry.example): Compose reads POSTGRES_PASSWORD / GUARDIAN_VERSION from
it for interpolation (via --env-file), and the server container loads it for
runtime config. The shared repo .env (AWS/deploy config) is intentionally not
used here, so this example never mutates it. The repo's default
docker-compose.yml (and the docker-compose.postgres.yml override) instead
build the server from source for contributors; docker-compose.registry.yml pulls
the published image.
Maintainers publish a version by running the Docker Publish GitHub Actions workflow one of two ways:
- On a GitHub Release. Publishing a release auto-triggers the workflow: the
version and build ref come from the release tag, the build uses the
postgresfeature, and an existing tag is never overwritten. The build first waits for required-reviewer approval on thereleaseenvironment before it pushes — approve releases that should ship a server image, and decline ones that should not (e.g. SDK-only releases that share the samevX.Y.Ztag line). A release tag that does not matchvMAJOR.MINOR.PATCH[-prerelease]fails the workflow. Cutting the release as a draft (gh release create … --draft) does not trigger the workflow — review it first, then publish the draft to start the build. - Manual dispatch. Pick the branch/tag/commit to build from, the version to tag, the build features, and whether to overwrite an existing tag — for off-release or one-off builds.
In both cases a version containing - (e.g. v1.2.3-rc.1) is treated as a
pre-release and does not move the latest tag.
The AWS deploy below still builds and pushes to ECR via scripts/aws-deploy.sh;
consuming the published GHCR image from the deploy flow is a separate, later change.
Prerequisites
- Terraform >= 1.0
- AWS CLI configured with permissions for ECS, ECR, ELB, EC2, IAM, CloudWatch, RDS, and Secrets Manager
- Docker installed locally
jqinstalled locally when deploying withGUARDIAN_SERVER_FEATURES=postgres,evm
aws sts get-caller-identity
docker info
terraform version
Quick Start
aws sso login --profile <your-profile>
set -a && source .env && set +a
# Optional: build/deploy ARM64 instead of X86_64
# export CPU_ARCHITECTURE=ARM64
# Optional: pin the server to a specific Miden network
export GUARDIAN_NETWORK_TYPE=MidenDevnet
# Optional: allow dashboard operators and let Terraform create the secret
# export GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON='["0x<alice-falcon-public-key>","0x<bob-falcon-public-key>"]'
# Optional: use an existing dashboard operator public keys secret instead
# export GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN='arn:aws:secretsmanager:us-east-1:123456789012:secret:guardian/operators'
# Optional: enable EVM support from config/evm/chains.json
# export GUARDIAN_SERVER_FEATURES=postgres,evm
# export GUARDIAN_EVM_CHAIN_CONFIG_FILE=config/evm/chains.json
# export GUARDIAN_CORS_ALLOWED_ORIGINS=https://accounts.openzeppelin.com
# Optional: choose the deployment profile
export DEPLOY_STAGE=dev
# export DEPLOY_STAGE=prod
# Optional: override the stack base name or public hostname
export STACK_NAME=guardian
# export SUBDOMAIN=guardian-stg
aws sts get-caller-identity
./scripts/aws-deploy.sh deploy
./scripts/aws-deploy.sh status
For a reviewable deployment, split image publishing, planning, and applying:
./scripts/aws-deploy.sh build
./scripts/aws-deploy.sh plan
./scripts/aws-deploy.sh deploy --skip-build
./scripts/aws-deploy.sh status
This builds and pushes ${ECR_REPO_NAME}:latest, plans Terraform against the immutable digest currently behind that tag, then applies using the existing ECR image without rebuilding. If you push a new image after plan, rerun plan before deploy --skip-build.
Terraform Variables
If you need to override defaults, use infra/terraform.tfvars:
aws_region = "us-east-1"
# Optional: ECS/image architecture
# cpu_architecture = "X86_64"
# cpu_architecture = "ARM64"
# Optional: derive resource names from a base stack name
# stack_name = "guardian"
# Only set this when bypassing scripts/aws-deploy.sh. The deploy script resolves
# ECR latest to an immutable digest and passes server_image_uri via -var.
# server_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/guardian-server@sha256:<digest>"
# Optional: Postgres credentials (defaults derive from stack_name)
# postgres_db = "guardian"
# postgres_user = "guardian"
# postgres_password = "guardian_dev_password"
# Optional: managed database sizing overrides
# Stage defaults:
# - dev -> db.t3.micro, 20 GiB allocated, no storage autoscaling ceiling
# - prod -> db.t3.medium, 50 GiB allocated, 200 GiB max allocated
# rds_instance_class = "db.t3.medium"
# rds_allocated_storage = 50
# rds_max_allocated_storage = 200
# Optional: Miden network for the server runtime
# server_network_type = "MidenDevnet"
# Optional: dashboard operator Falcon public keys managed by Terraform
# guardian_operator_public_keys = [
# "0x<alice-falcon-public-key>",
# "0x<bob-falcon-public-key>",
# ]
# Optional: existing dashboard operator Falcon public keys secret
# guardian_operator_public_keys_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:guardian/operators"
# Optional: hosted ECDSA ACK signer backed by AWS KMS.
# Setting this is all that is required: Terraform grants the ECS task role
# kms:Sign + kms:GetPublicKey on the key and injects GUARDIAN_ACK_ECDSA_BACKEND
# and GUARDIAN_ACK_ECDSA_KMS_KEY_ID into the server runtime. The key must be
# ECC_SECG_P256K1 / SIGN_VERIFY. On this path the ECDSA Secrets Manager secret is
# not needed; the Falcon ACK secret bootstrap is unchanged and still required in
# prod.
# guardian_ack_ecdsa_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/<key-id>"
# Optional: EVM runtime configuration
# guardian_evm_allowed_chain_ids = "1,11155111"
# guardian_evm_rpc_urls = "1=https://ethereum-rpc.publicnode.com,11155111=https://ethereum-sepolia-rpc.publicnode.com"
# guardian_evm_entrypoint_address = "0x433709009b8330fda32311df1c2afa402ed8d009"
# guardian_cors_allowed_origins = "https://accounts.openzeppelin.com"
# Optional: stage/runtime capacity overrides
# deployment_stage = "prod"
# server_desired_count = 2
# server_autoscaling_enabled = true
# server_autoscaling_min_capacity = 2
# server_autoscaling_max_capacity = 6
# server_autoscaling_cpu_target = 65
# server_autoscaling_memory_target = 75
# rds_proxy_enabled = true
# rds_proxy_subnet_ids = ["subnet-xxxxxxxx", "subnet-yyyyyyyy"]
# In us-east-1, avoid subnets in us-east-1e/use1-az3 for RDS Proxy.
# guardian_rate_limit_enabled = false
# guardian_rate_burst_per_sec = 200
# guardian_rate_per_min = 5000
# guardian_db_pool_max_size = 32
# guardian_metadata_db_pool_max_size = 32
# Optional: Route 53 hosted zone ID
# route53_zone_id = "Z1234567890ABC"
# Optional: Cloudflare DNS management
# cloudflare_zone_id = "..."
# cloudflare_api_token = "..."
Database TLS verification
By default the server DATABASE_URL uses sslmode=require — the connection is
encrypted but the RDS certificate is not verified. To authenticate the
database, provide a CA bundle via Secrets Manager and set the
rds_ca_bundle_secret_arn Terraform variable.
Migrating an already-deployed stack? Follow the staged, fail-safe procedure in
runbooks/enable-db-tls.md(image-first, staging-before-prod, RDS-Proxy caveat, rollback). The rest of this section is the mechanism reference. The deployment then switchesDATABASE_URLtosslmode=verify-full&sslrootcert=<mounted path>, and both the migration (libpq) and runtime (rustls) connections verify the certificate chain and hostname.
How delivery works (image stays CA-free). The published image ships no CA
bundle. When rds_ca_bundle_secret_arn is set, Terraform adds a small
rds-ca-initializer init container to the task: it reads the secret, writes
it to a shared in-task volume, sets permissions, and exits; Fargate won't start
the Guardian container until it succeeds (dependsOn: SUCCESS). The Guardian
container mounts the same volume read-only and reads the bundle as a plain file.
The app never calls Secrets Manager and nothing is baked into the image.
Combined CA bundle (required for RDS). Production routes DATABASE_URL
through the RDS Proxy endpoint, which presents an AWS Certificate Manager
certificate that chains to Amazon Trust Services roots (specifically Amazon
Root CA 1) — not the Amazon RDS CA roots used by a direct instance. The secret
MUST therefore contain both root sets so verify-full succeeds against either
endpoint.
Size limit — do NOT use the global RDS bundle. Secrets Manager caps a secret value at 64 KiB. The
global-bundle.pem(~165 KB) exceeds that andcreate-secretwill reject it. Use your region-specific RDS bundle (a few KB) plus just Amazon Root CA 1, which keeps the combined bundle well under the cap. The Rust loader already supports multiple roots in one PEM.
Build it (mind the newline between files so the PEM blocks don't merge) and store it verbatim — plain PEM text, no encoding:
# Region-specific RDS bundle (replace us-east-1), ~4-5 KB
curl -sS https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem -o rds.pem
# All Amazon Trust Services roots (~6 KB total) — more rotation-tolerant than
# pinning only the one the RDS Proxy chains to today; still well under 64 KiB.
: > ats.pem
for ca in AmazonRootCA1 AmazonRootCA2 AmazonRootCA3 AmazonRootCA4 SFSRootCAG2; do
curl -sS "https://www.amazontrust.com/repository/${ca}.pem" >> ats.pem; echo >> ats.pem
done
{ cat rds.pem; echo; cat ats.pem; } > rds-combined-ca.pem
grep -c "BEGIN CERTIFICATE" rds-combined-ca.pem # sanity: total root count
test "$(wc -c < rds-combined-ca.pem)" -lt 65536 || echo "WARNING: bundle exceeds the 64 KiB Secrets Manager limit"
aws secretsmanager create-secret \
--name guardian-prod/server/rds-ca-bundle \
--secret-string file://rds-combined-ca.pem
Why Secrets Manager for a public cert? CA roots aren't confidential, so this is a consistency choice — it reuses the same secret-injection plumbing and IAM pattern as
DATABASE_URLand the ACK keys, with no new mechanism. The trade-offs are the 64 KiB cap above and ~$0.40/secret/month. If a future bundle must exceed 64 KiB, the next step is an S3 object (no size cap) or an EFS access point fetched by the same init container — the Rust/app side would not change.
Then set the ARN and apply:
rds_ca_bundle_secret_arn = "arn:aws:secretsmanager:REGION:ACCOUNT:secret:guardian-prod/server/rds-ca-bundle-XXXXXX"
The execution role is granted secretsmanager:GetSecretValue on that ARN
automatically. If the bundle is missing or malformed, the init container or the
server preflight fails closed at startup rather than connecting insecurely.
Rotation. Update the secret's value (it may hold old and new roots together for overlap), then force a new deployment so the init container re-reads the secret and rewrites the file. Because the task definition still points at the same secret ARN, changing only the secret value does not roll tasks on its own — force it explicitly:
aws ecs update-service --cluster <cluster> --service <service> --force-new-deployment
No image or code change is required; ensure the new roots are present before they become the only trusted ones.
Deploy
Script Commands
| Command | Purpose |
|---|---|
./scripts/aws-deploy.sh build | Build the Guardian server image and push it to ECR as latest. Does not run Terraform. |
./scripts/aws-deploy.sh plan | Run terraform plan using the immutable digest currently behind ECR latest. Does not build, push, or apply. |
./scripts/aws-deploy.sh deploy | Build and push the image, resolve ECR latest to an immutable digest, and run terraform apply. |
./scripts/aws-deploy.sh deploy --skip-build | Resolve the existing ECR latest image to an immutable digest and run terraform apply without rebuilding. |
./scripts/aws-deploy.sh bootstrap-ack-keys | Create the prod ACK key secrets in Secrets Manager. Refuses to overwrite existing secrets. With TF_VAR_guardian_ack_ecdsa_kms_key_arn set, creates only the Falcon secret (ECDSA is KMS-backed). |
./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key | Create the KMS ECDSA ACK signing key (ECC_SECG_P256K1 / SIGN_VERIFY) and an alias/${STACK_NAME}-ack-ecdsa alias, then print the ARN to set. Refuses to overwrite an existing alias. |
./scripts/aws-deploy.sh status | Print Terraform outputs for the active STACK_NAME and DEPLOY_STAGE. |
./scripts/aws-deploy.sh logs | Tail the deployed server's CloudWatch log group. |
./scripts/aws-deploy.sh cleanup | Run Terraform destroy for the active STACK_NAME and DEPLOY_STAGE. |
--skip-build is meaningful for deploy; plan never builds or pushes an image. Use build before plan for a new stack or whenever ECR does not yet contain ${ECR_REPO_NAME}:latest.
One-Step Deploy
./scripts/aws-deploy.sh deploy
The deploy script resolves the ECR latest tag to an immutable digest before calling Terraform, so image pushes always produce a real ECS task-definition revision instead of relying on tag reuse.
It also keeps separate local Terraform state files per STACK_NAME and DEPLOY_STAGE, using infra/terraform.<stack>.<stage>.tfstate by default.
AWS deployments must include the postgres server feature. The script defaults GUARDIAN_SERVER_FEATURES to postgres; set GUARDIAN_SERVER_FEATURES=postgres,evm only when deploying the optional EVM API surface.
Reviewable Build, Plan, Apply
Use this flow when you want to inspect Terraform changes before applying them:
./scripts/aws-deploy.sh build
./scripts/aws-deploy.sh plan
./scripts/aws-deploy.sh deploy --skip-build
build creates the ECR repository if needed and pushes ${ECR_REPO_NAME}:latest. Both plan and deploy --skip-build resolve that tag to an immutable digest before invoking Terraform. Do not rebuild or push a new latest between plan and deploy --skip-build unless you intend to apply a different image; rerun plan after any rebuild.
For DEPLOY_STAGE=prod, bootstrap the ACK secrets once before the first deploy:
DEPLOY_STAGE=prod ./scripts/aws-deploy.sh bootstrap-ack-keys
The normal deploy path does not create or rotate ACK keys. It expects the prod Secrets Manager entries to already exist, and the server reads them directly at startup before importing them into the filesystem keystore.
Secret names default to ${STACK_NAME}/server/ack-{falcon,ecdsa}-secret-key, so distinct stacks (e.g. guardian-prod, guardian-prod-eu) automatically resolve to distinct secrets and multiple Guardian deployments can coexist in the same AWS account. Override per stack by setting GUARDIAN_ACK_FALCON_SECRET_NAME / GUARDIAN_ACK_ECDSA_SECRET_NAME before bootstrap-ack-keys and deploy; they flow into Terraform variables and the ECS task definition's GUARDIAN_ACK_FALCON_SECRET_ID / GUARDIAN_ACK_ECDSA_SECRET_ID env vars.
Prod with a KMS-backed ECDSA signer
To keep the ECDSA private key in AWS KMS (never resident in the server process) while Falcon stays in Secrets Manager, create the key first and export its ARN before the rest of the flow. The script keys off TF_VAR_guardian_ack_ecdsa_kms_key_arn (the env var, not terraform.tfvars) to skip the ECDSA Secrets Manager secret, so it must be exported before bootstrap-ack-keys and deploy:
export DEPLOY_STAGE=prod STACK_NAME=<stack>
./scripts/aws-deploy.sh bootstrap-kms-ecdsa-key # creates the key, prints the ARN
export TF_VAR_guardian_ack_ecdsa_kms_key_arn="arn:aws:kms:...:key/<key-id>"
./scripts/aws-deploy.sh bootstrap-ack-keys # Falcon only; skips ECDSA
./scripts/aws-deploy.sh deploy
Terraform then grants the ECS task role kms:Sign + kms:GetPublicKey and injects GUARDIAN_ACK_ECDSA_BACKEND=aws-kms / GUARDIAN_ACK_ECDSA_KMS_KEY_ID. See runbooks/secrets.md for key lifecycle, the immutable-spec caveat, and migrating an existing deployment (a new keypair, so a SwitchGuardian identity change).
Dashboard operator public keys use a separate optional secret. The easiest deployment path is to pass the public keys to Terraform and let it create the stack-scoped secret:
export GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON='["0x<alice-falcon-public-key>","0x<bob-falcon-public-key>"]'
or in terraform.tfvars:
guardian_operator_public_keys = [
"0x<alice-falcon-public-key>",
"0x<bob-falcon-public-key>"
]
If you already manage the secret outside this stack, pass its ARN through
GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ARN or
guardian_operator_public_keys_secret_arn. An explicit secret ARN takes
precedence over the Terraform-managed public key list.
The ECS task role is granted read access only to the configured secret ARN. The server rereads that secret during operator auth checks, so adding or removing a key in the existing secret takes effect without an application restart. When Terraform manages the secret, update the public key list and rerun deploy.
EVM deployments need the evm server feature plus server-owned chain config.
By default, scripts/aws-deploy.sh derives allowed chain IDs, RPC URLs, and the
shared EntryPoint address from config/evm/chains.json. It passes RPC URLs to
Terraform as a stack-scoped Secrets Manager secret and the EntryPoint address as
a normal ECS environment variable. To use an alternate JSON file, set
GUARDIAN_EVM_CHAIN_CONFIG_FILE.
You can still override the derived values by setting
GUARDIAN_EVM_ALLOWED_CHAIN_IDS, GUARDIAN_EVM_RPC_URLS, or
GUARDIAN_EVM_ENTRYPOINT_ADDRESS directly, or by passing existing secret ARNs
through GUARDIAN_EVM_ALLOWED_CHAIN_IDS_SECRET_ARN and
GUARDIAN_EVM_RPC_URLS_SECRET_ARN.
When an EVM UI runs on a different origin, set
GUARDIAN_CORS_ALLOWED_ORIGINS to a comma-separated list of exact origins.
Wildcard origins are rejected. When this value is configured, the server enables
credentialed CORS so browsers can include the host-only, HttpOnly
guardian_evm_session cookie.
If you still have an older local state file at infra/terraform.tfstate, move it manually before using the split-state workflow:
cp infra/terraform.tfstate infra/terraform.guardian.dev.tfstate
cp infra/terraform.tfstate.backup infra/terraform.guardian.dev.tfstate.backup 2>/dev/null || true
Use --skip-build when the image already exists in ECR and you only need infra/runtime changes, or when you are applying immediately after a reviewed plan:
./scripts/aws-deploy.sh deploy --skip-build
For benchmark-oriented production deploys, prefer explicit overrides rather than changing the base prod profile in code. A typical starting point is:
set -a && source .env && set +a
export DEPLOY_STAGE=prod
export STACK_NAME=guardian-prod
export TF_VAR_server_cpu=2048
export TF_VAR_server_memory=4096
export TF_VAR_server_desired_count=3
export TF_VAR_server_autoscaling_min_capacity=3
export TF_VAR_server_autoscaling_max_capacity=10
export TF_VAR_rds_instance_class=db.r6g.large
export TF_VAR_rds_allocated_storage=100
export TF_VAR_rds_max_allocated_storage=400
export TF_VAR_rds_proxy_subnet_ids='["subnet-25c1722b","subnet-4d0eca6c"]'
export TF_VAR_guardian_db_pool_max_size=64
export TF_VAR_guardian_metadata_db_pool_max_size=64
export TF_VAR_guardian_rate_limit_enabled=false
./scripts/aws-deploy.sh deploy --skip-build
Validate
./scripts/aws-deploy.sh status
curl https://guardian.openzeppelin.com/pubkey
grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian.openzeppelin.com:443 guardian.Guardian/GetPubkey
Operations
Logs
./scripts/aws-deploy.sh logs
Status
./scripts/aws-deploy.sh status
The script reads the state file for the active STACK_NAME and DEPLOY_STAGE. The current default path is:
infra/terraform.<stack>.<stage>.tfstate
You can override that with TF_STATE_PATH if needed.
Destroy
./scripts/aws-deploy.sh cleanup
ECR repositories are not managed by Terraform:
aws ecr delete-repository --repository-name guardian-server --force --region us-east-1
Resources Created
| Resource | Description |
|---|---|
| ECS Cluster | Fargate cluster derived from stack_name |
| ECS Service | Guardian server service |
| Application Load Balancer | Internet-facing ALB derived from stack_name |
| Target Groups | HTTP target group for port 3000 and gRPC target group for port 50051 |
| RDS | Managed PostgreSQL instance and subnet group |
| RDS Proxy | Managed PostgreSQL proxy in the production profile |
| Secrets Manager | Secret containing DATABASE_URL for the server task |
| Secrets Manager | Optional operator public keys secret for dashboard auth |
| Secrets Manager | Optional EVM allowed chain IDs and RPC URLs secrets |
| Secrets Manager | Secrets containing the Falcon and ECDSA ack private keys used to seed the server keystore in prod |
| Security Groups | ALB, server, and database security groups |
| CloudWatch Log Groups | Cluster execute-command logs and server logs |
| IAM Role | ECS task execution and runtime roles |
Outputs
| Output | Description |
|---|---|
alb_dns_name | ALB DNS name |
alb_url | Full ALB URL |
custom_domain_url | Custom domain URL when configured |
grpc_endpoint | Public gRPC endpoint when HTTPS is enabled |
database_endpoint | RDS endpoint used by the server |
rds_proxy_endpoint | RDS Proxy endpoint when enabled |
rds_instance_class | Effective RDS instance class |
rds_allocated_storage | Effective allocated RDS storage in GiB |
database_url_secret_arn | Secrets Manager ARN for the server DATABASE_URL |
operator_public_keys_secret_arn | Secrets Manager ARN used for dashboard operator public keys |
operator_public_keys_secret_name | Terraform-managed operator public keys secret name, when created |
guardian_evm_allowed_chain_ids_secret_arn | Secrets Manager ARN used for EVM allowed chain IDs |
guardian_evm_rpc_urls_secret_arn | Secrets Manager ARN used for EVM RPC URLs |
guardian_evm_entrypoint_address | Shared EVM EntryPoint address configured for the server |
guardian_cors_allowed_origins | Explicit CORS origins configured for the server |
ack_falcon_secret_name | Secrets Manager name for the Falcon ack key |
ack_ecdsa_secret_name | Secrets Manager name for the ECDSA ack key |
ecs_cluster_arn | ECS cluster ARN |
server_service_arn | Server ECS service ARN |
Stage Profiles
Dev
- single ECS task
- no ECS autoscaling
- direct ECS to RDS connection
- no RDS Proxy
- conservative Guardian runtime limits
Prod
- higher ECS desired count
- ECS service autoscaling
- larger default RDS instance class and base storage
- RDS storage autoscaling
- RDS Proxy between ECS and RDS
- higher Guardian runtime rate-limit and DB-pool defaults for benchmark traffic
HTTPS And gRPC
HTTPS is enabled when acm_certificate_arn is set. DNS can be managed through Cloudflare, Route 53, or both depending on which variables are provided.
When HTTPS is enabled, the ALB routes standard HTTPS requests to the server HTTP port 3000 and gRPC requests for /guardian.Guardian/* to the server gRPC port 50051. The public gRPC endpoint uses the same hostname on port 443.
On Apple Silicon hosts, CPU_ARCHITECTURE=X86_64 builds are slower because Docker builds linux/amd64 images under emulation. Switching to ARM64 avoids that local emulation cost, but it also changes the ECS task runtime architecture.
Migrating An Existing ECS-Postgres Stack
The current Terraform configuration is RDS-only. There is no supported dual-mode deployment that keeps the old ECS Postgres service alive after apply.
Use this cutover flow for an existing stack:
- Capture the current stack state:
./scripts/aws-deploy.sh status - Create a logical PostgreSQL backup from the existing ECS-hosted database before applying the updated stack.
- Apply the updated RDS-backed Terraform stack:
./scripts/aws-deploy.sh deploy --skip-build - Restore the backup into the new RDS database.
- Validate the public service:
./scripts/aws-deploy.sh status
curl https://<host>/pubkey
grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' <host>:443 guardian.Guardian/GetPubkey - Confirm the old Postgres ECS service and Cloud Map database-discovery resources are gone from AWS before treating the cutover as complete.
Troubleshooting
- If the server task fails during startup, check
./scripts/aws-deploy.sh logsfirst and confirm the reporteddatabase_endpointmatches the expected RDS host. - If a prod deploy fails before Terraform starts, confirm the fixed prod ACK secrets exist by running
./scripts/aws-deploy.sh bootstrap-ack-keysonce and then retrying the deploy. - If RDS subnet-group creation fails, verify the selected subnets cover at least two subnets for the database deployment.
- If gRPC works against the ALB directly but fails on the public hostname, check Cloudflare gRPC settings on the zone.
Legacy Script
The legacy deployment logic has been replaced by the Terraform-backed scripts/aws-deploy.sh.