Skip to main content
Version: 0.15 (unstable)

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=dev keeps the current low-cost, fixed-capacity behavior
  • DEPLOY_STAGE=prod enables 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 postgres feature, and an existing tag is never overwritten. The build first waits for required-reviewer approval on the release environment 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 same vX.Y.Z tag line). A release tag that does not match vMAJOR.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
  • jq installed locally when deploying with GUARDIAN_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 switches DATABASE_URL to sslmode=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 and create-secret will 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_URL and 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

CommandPurpose
./scripts/aws-deploy.sh buildBuild the Guardian server image and push it to ECR as latest. Does not run Terraform.
./scripts/aws-deploy.sh planRun terraform plan using the immutable digest currently behind ECR latest. Does not build, push, or apply.
./scripts/aws-deploy.sh deployBuild and push the image, resolve ECR latest to an immutable digest, and run terraform apply.
./scripts/aws-deploy.sh deploy --skip-buildResolve the existing ECR latest image to an immutable digest and run terraform apply without rebuilding.
./scripts/aws-deploy.sh bootstrap-ack-keysCreate 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-keyCreate 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 statusPrint Terraform outputs for the active STACK_NAME and DEPLOY_STAGE.
./scripts/aws-deploy.sh logsTail the deployed server's CloudWatch log group.
./scripts/aws-deploy.sh cleanupRun 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

ResourceDescription
ECS ClusterFargate cluster derived from stack_name
ECS ServiceGuardian server service
Application Load BalancerInternet-facing ALB derived from stack_name
Target GroupsHTTP target group for port 3000 and gRPC target group for port 50051
RDSManaged PostgreSQL instance and subnet group
RDS ProxyManaged PostgreSQL proxy in the production profile
Secrets ManagerSecret containing DATABASE_URL for the server task
Secrets ManagerOptional operator public keys secret for dashboard auth
Secrets ManagerOptional EVM allowed chain IDs and RPC URLs secrets
Secrets ManagerSecrets containing the Falcon and ECDSA ack private keys used to seed the server keystore in prod
Security GroupsALB, server, and database security groups
CloudWatch Log GroupsCluster execute-command logs and server logs
IAM RoleECS task execution and runtime roles

Outputs

OutputDescription
alb_dns_nameALB DNS name
alb_urlFull ALB URL
custom_domain_urlCustom domain URL when configured
grpc_endpointPublic gRPC endpoint when HTTPS is enabled
database_endpointRDS endpoint used by the server
rds_proxy_endpointRDS Proxy endpoint when enabled
rds_instance_classEffective RDS instance class
rds_allocated_storageEffective allocated RDS storage in GiB
database_url_secret_arnSecrets Manager ARN for the server DATABASE_URL
operator_public_keys_secret_arnSecrets Manager ARN used for dashboard operator public keys
operator_public_keys_secret_nameTerraform-managed operator public keys secret name, when created
guardian_evm_allowed_chain_ids_secret_arnSecrets Manager ARN used for EVM allowed chain IDs
guardian_evm_rpc_urls_secret_arnSecrets Manager ARN used for EVM RPC URLs
guardian_evm_entrypoint_addressShared EVM EntryPoint address configured for the server
guardian_cors_allowed_originsExplicit CORS origins configured for the server
ack_falcon_secret_nameSecrets Manager name for the Falcon ack key
ack_ecdsa_secret_nameSecrets Manager name for the ECDSA ack key
ecs_cluster_arnECS cluster ARN
server_service_arnServer 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:

  1. Capture the current stack state:
    ./scripts/aws-deploy.sh status
  2. Create a logical PostgreSQL backup from the existing ECS-hosted database before applying the updated stack.
  3. Apply the updated RDS-backed Terraform stack:
    ./scripts/aws-deploy.sh deploy --skip-build
  4. Restore the backup into the new RDS database.
  5. 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
  6. 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 logs first and confirm the reported database_endpoint matches 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-keys once 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.