Guardian Domain Migration
This runbook is only for the one-time OpenZeppelin hostname migration in
issue #341. Normal
Guardian deployments should leave ALIAS_SUBDOMAIN unset.
The migration keeps each legacy hostname available on the same ALB while the new canonical hostname is verified. Both names route directly; there is no hostname redirect.
| Deployment | Network | Canonical SUBDOMAIN | Temporary ALIAS_SUBDOMAIN |
|---|---|---|---|
| Devnet | MidenDevnet | guardian-devnet | guardian-stg |
| Testnet | MidenTestnet | guardian-testnet | guardian |
The ACM certificate configured through ACM_CERTIFICATE_ARN must cover both
names, or the legacy hostname's certificate must be supplied separately through
ALIAS_ACM_CERTIFICATE_ARN for SNI.
Select the stack explicitly
Load shared credentials and deployment settings from .env, then override every
value that identifies the stack. Do not rely on the current values in .env
when selecting a state file manually. The GUARDIAN_NETWORK_TYPE exports pin
the network each stack already runs (the hosted devnet stack runs MidenDevnet,
the testnet stack MidenTestnet) so a stale .env value cannot switch the
server's network inside this domain-only apply. Both hosted records are already
Cloudflare-proxied, so the export blocks also pin CLOUDFLARE_PROXIED=true to
prevent this migration from changing their proxy mode.
For testnet:
set -a && source .env && set +a
export AWS_REGION=us-east-1
export STACK_NAME=guardian-prod
export DEPLOY_STAGE=prod
export GUARDIAN_NETWORK_TYPE=MidenTestnet
export DOMAIN_NAME=openzeppelin.com
export SUBDOMAIN=guardian-testnet
export ALIAS_SUBDOMAIN=guardian
export CLOUDFLARE_PROXIED=true
export ACM_CERTIFICATE_ARN="arn:aws:acm:us-east-1:<account-id>:certificate/<canonical-cert-id>"
export TF_STATE_PATH="$(pwd)/infra/terraform.guardian-prod.prod.tfstate"
For devnet:
set -a && source .env && set +a
export AWS_REGION=us-east-1
export STACK_NAME=guardian
export DEPLOY_STAGE=dev
export GUARDIAN_NETWORK_TYPE=MidenDevnet
export DOMAIN_NAME=openzeppelin.com
export SUBDOMAIN=guardian-devnet
export ALIAS_SUBDOMAIN=guardian-stg
export CLOUDFLARE_PROXIED=true
export ACM_CERTIFICATE_ARN="arn:aws:acm:us-east-1:<account-id>:certificate/<canonical-cert-id>"
export TF_STATE_PATH="$(pwd)/infra/terraform.guardian.dev.tfstate"
ACM_CERTIFICATE_ARN must point at an issued certificate covering the canonical
hostname. If that certificate does not also cover the legacy hostname, also
export ALIAS_ACM_CERTIFICATE_ARN with the legacy hostname's existing
certificate. Both certificates must be in the ALB's AWS region.
Inspect the certificate status and subject alternative names before changing state or planning the deployment:
CANONICAL_FQDN="${SUBDOMAIN}.${DOMAIN_NAME}"
LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
aws acm describe-certificate \
--region "$AWS_REGION" \
--certificate-arn "$ACM_CERTIFICATE_ARN" \
--query 'Certificate.{Status:Status,SANs:SubjectAlternativeNames}' \
--output json
if [ -n "${ALIAS_ACM_CERTIFICATE_ARN:-}" ]; then
aws acm describe-certificate \
--region "$AWS_REGION" \
--certificate-arn "$ALIAS_ACM_CERTIFICATE_ARN" \
--query 'Certificate.{Status:Status,SANs:SubjectAlternativeNames}' \
--output json
fi
Stop unless every displayed certificate is ISSUED. With one certificate, its
SAN list must cover both $CANONICAL_FQDN and $LEGACY_FQDN, either explicitly
or through a matching wildcard. With split certificates, the canonical
certificate must cover $CANONICAL_FQDN and the alias certificate must cover
$LEGACY_FQDN.
With a split-certificate setup, the apply swaps the listener's default
certificate to the canonical one before it creates the SNI attachment for the
legacy certificate, so TLS on the live legacy hostname can fail for a brief
window during the apply. Prefer a single certificate covering both names; when
ALIAS_ACM_CERTIFICATE_ARN is unavoidable, schedule the apply for a
low-traffic window.
Before continuing, authenticate and confirm that the selected state belongs to the intended deployment:
aws sts get-caller-identity
terraform -chdir=infra output -state="$TF_STATE_PATH" deployment_stage
terraform -chdir=infra output -state="$TF_STATE_PATH" ecs_cluster_name
terraform -chdir=infra output -state="$TF_STATE_PATH" custom_domain_url
Stop if these outputs do not match the selected stack, stage, and legacy hostname.
Determine whether a state move is needed
A state move is not part of a normal hostname addition and is unnecessary in most deployments. It is needed only when all of the following are true:
- the legacy hostname is already managed by this Terraform state;
- it is tracked at the primary DNS resource address; and
- the same apply must make the new hostname primary while retaining the legacy hostname as the secondary record.
This is the expected shape of the existing OpenZeppelin devnet and testnet stacks. The move changes only Terraform's local ownership address; it does not modify live DNS.
Inspect the selected state:
terraform -chdir=infra state list -state="$TF_STATE_PATH" |
rg '^(cloudflare_dns_record\.service|aws_route53_record\.service_alias)'
Skip the state move when the legacy record is not present at either primary address below. Examples include a fresh stack, DNS managed outside Terraform, or a state that has already been migrated. Do not import or move an unfamiliar record without first reconciling who owns it.
If a primary address is present, back up the state:
chmod 600 "$TF_STATE_PATH"
install -m 600 "$TF_STATE_PATH" "${TF_STATE_PATH}.before-domain-migration"
Move only the record type shown by state list; move both only if the stack
actually manages both providers. Run each matching block separately. The
subshell keeps a failed guard from terminating the operator's shell, and each
block fails before the move unless the tracked record is the expected legacy
hostname.
For Cloudflare-managed DNS, provider state may store name as either the
relative record name or the full hostname:
(
EXPECTED_LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
CURRENT_LEGACY_NAME=$(terraform -chdir=infra state show -state="$TF_STATE_PATH" \
'cloudflare_dns_record.service[0]' |
sed -nE 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p')
case "$CURRENT_LEGACY_NAME" in
"$ALIAS_SUBDOMAIN"|"$EXPECTED_LEGACY_FQDN") ;;
*)
echo "Refusing state move: expected ${ALIAS_SUBDOMAIN} or ${EXPECTED_LEGACY_FQDN}, found ${CURRENT_LEGACY_NAME:-<empty>}" >&2
exit 1
;;
esac
terraform -chdir=infra state mv -state="$TF_STATE_PATH" \
'cloudflare_dns_record.service[0]' \
'cloudflare_dns_record.service_secondary[0]'
)
For Route 53-managed DNS, the record's alias block holds a second name
attribute for the ALB, so the guard reads the top-level fqdn instead:
(
EXPECTED_LEGACY_FQDN="${ALIAS_SUBDOMAIN}.${DOMAIN_NAME}"
CURRENT_LEGACY_FQDN=$(terraform -chdir=infra state show -state="$TF_STATE_PATH" \
'aws_route53_record.service_alias[0]' |
sed -nE 's/^[[:space:]]*fqdn[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p')
test "$CURRENT_LEGACY_FQDN" = "$EXPECTED_LEGACY_FQDN" || {
echo "Refusing state move: expected ${EXPECTED_LEGACY_FQDN}, found ${CURRENT_LEGACY_FQDN:-<empty>}" >&2
exit 1
}
terraform -chdir=infra state mv -state="$TF_STATE_PATH" \
'aws_route53_record.service_alias[0]' \
'aws_route53_record.service_secondary[0]'
)
The hosted devnet and testnet states currently use Cloudflare rather than Route
53, but always trust terraform state list for the selected state. After a
move, run it again and confirm the record appears only at the corresponding
service_secondary[0] address.
When DNS is managed outside Terraform, skip both state-move blocks and create or retain the canonical and legacy records with that provider. Terraform still attaches the required ACM certificates to the ALB.
Plan, deploy, and verify
Run the plan with the same STACK_NAME, DEPLOY_STAGE, SUBDOMAIN, and
ALIAS_SUBDOMAIN used for the state move:
./scripts/aws-deploy.sh plan
For Terraform-managed DNS, the plan must retain the legacy secondary record and
create the new canonical record. On the legacy Cloudflare record, only the
in-place comment update is expected; stop if proxied, name, content, or
any other behavior-affecting field changes. With external DNS, the plan should
not add DNS resources; confirm both records separately with that provider.
Certificate and output changes are expected in either case. Do not apply if the
plan destroys or replaces the legacy DNS record, ALB, ECS service, or database.
A wrong .env value surfaces as an in-place aws_ecs_task_definition update
rather than a destroy or replace, so inspect that diff too. This domain-only
apply must not change the task definition at all: an environment diff (network
type, CORS origins, operator keys) means per-stack configuration leaked from
.env, and an image diff means the ECR latest tag has moved since the
stack's last deploy. Stop and pin the correct values before applying. After
reviewing the plan:
./scripts/aws-deploy.sh deploy --skip-build
Verify HTTP and gRPC on both names; the curl and grpcurl calls each verify
the certificate chain and hostname as part of the TLS handshake. This example
shows testnet; repeat it for guardian-devnet and guardian-stg:
curl --fail-with-body https://guardian-testnet.openzeppelin.com/pubkey
curl --fail-with-body https://guardian.openzeppelin.com/pubkey
grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian-testnet.openzeppelin.com:443 guardian.Guardian/GetPubkey
grpcurl -import-path crates/server/proto -proto guardian.proto -d '{}' guardian.openzeppelin.com:443 guardian.Guardian/GetPubkey
Keep SDK, smoke-test, benchmark, and operational defaults on the legacy names during the observation period. Switch consumers in a follow-up change only after both canonical hostnames have been confirmed stable.
Remove the legacy hostname
After consumers have moved and monitoring shows no required traffic on the
legacy hostname, unset ALIAS_SUBDOMAIN and ALIAS_ACM_CERTIFICATE_ARN while
keeping all canonical stack values pinned as above:
unset ALIAS_SUBDOMAIN ALIAS_ACM_CERTIFICATE_ARN
./scripts/aws-deploy.sh plan
The cleanup plan must delete only the legacy DNS record, the optional secondary
listener certificate attachment, and migration-only outputs. Apply it with
./scripts/aws-deploy.sh deploy --skip-build, then verify the canonical HTTP and
gRPC endpoints again.