Runbook: migrate Copilot app my-app to Terraform (adopt in place)¶
Generated by ecsodus 0.2.1 for account 123456789012, region us-west-2, from an inventory captured at 2026-10-09T03:00:15+00:00.
Verified on real AWS (2026-09-30). The full runbook ran end to end against a Copilot v1.34.1 app (env + Load Balanced Web Service + DynamoDB/S3 addons):
DeletionPolicy: Retainstopped every custom-resource Delete handler, and no data or traffic was lost (docs/e2e/2026-09-30-aws-e2e.md). Not yet exercised on real AWS: custom domains and ACM certificates, Aurora addons, private placement with NAT, and partial migrations. If your app uses one of these, run the steps on a non-production copy first.
Run every block from the directory that holds the generated Terraform (.). Each block runs in a fail-fast subshell and stops at the first failing check. Nothing here is run by ecsodus. The generated *.tf files contain plaintext task-definition environment values copied from your stacks: review them before committing.
1. Freeze¶
- Stop all
copilot deploy,copilot env deploy,copilot app upgradeand pipeline runs for this app until the runbook is finished. - Record each stack's last update time:
(
set -euo pipefail
umask 077
aws cloudformation describe-stacks --stack-name my-app-test --query 'Stacks[0].LastUpdatedTime' --output text
aws cloudformation describe-stacks --stack-name my-app-test-dogworker --query 'Stacks[0].LastUpdatedTime' --output text
aws cloudformation describe-stacks --stack-name my-app-test-fe --query 'Stacks[0].LastUpdatedTime' --output text
aws cloudformation describe-stacks --stack-name my-app-test-job --query 'Stacks[0].LastUpdatedTime' --output text
)
2. Protect¶
Take backups, test a restore, and turn on deletion protection where it is off. This creates CloudFormation drift on purpose. The retain patch in step 3 does not revert it, because the template property is unchanged.
(
set -euo pipefail
umask 077
# EFS fs-0a1b2c3d4e5f60081: confirm an AWS Backup recovery point exists and restores
aws rds create-db-cluster-snapshot --db-cluster-identifier my-app-test-fe-addonsstack-1-dbdbcluster-1a2b3c4d5e6f --db-cluster-snapshot-identifier my-app-test-fe-addonsstack-1-dbdbcluster-1a2b3c4d5e6f-ecsodus
aws rds wait db-cluster-snapshot-available --db-cluster-snapshot-identifier my-app-test-fe-addonsstack-1-dbdbcluster-1a2b3c4d5e6f-ecsodus
aws rds modify-db-cluster --db-cluster-identifier my-app-test-fe-addonsstack-1-dbdbcluster-1a2b3c4d5e6f --deletion-protection --apply-immediately
)
Then refresh the inventory (same selection) and regenerate, so the Terraform matches:
(
set -euo pipefail
umask 077
ecsodus inventory --app my-app --region us-west-2 -o inventory.json
ecsodus generate inventory.json --patch-bucket my-app-artifacts-bucket --out .
)
3. Retain patches¶
Every resource in every handed-off stack gets DeletionPolicy: Retain and UpdateReplacePolicy: Retain, with no exceptions. The patched templates are in retain-patches/. Nested stacks are patched through their parent's TemplateURL. After this step, no stack delete can remove or empty anything.
Upload the patched templates:
(
set -euo pipefail
umask 077
aws s3 cp retain-patches/my-app-test.yml s3://my-app-artifacts-bucket/ecsodus/retain-patches/my-app-test/8077b3786b301188f87227c4a20b8d08a4a366d8b65ebaedafcff44556fd2b19.yml
aws s3 cp retain-patches/my-app-test-dogworker.yml s3://my-app-artifacts-bucket/ecsodus/retain-patches/my-app-test-dogworker/c7f6fd33d1b8e43e630c4fea6125881e6d0a1b8d711cd9b1380b194bb12b059d.yml
aws s3 cp retain-patches/my-app-test-fe.yml s3://my-app-artifacts-bucket/ecsodus/retain-patches/my-app-test-fe/f727bbe38c1345a7d944f46b70e3fbda6293460142259b1d71dc1607ec4496ba.yml
aws s3 cp retain-patches/my-app-test-fe-AddonsStack-1ABCDEFGHIJKL.yml s3://my-app-artifacts-bucket/ecsodus/retain-patches/my-app-test-fe-AddonsStack-1ABCDEFGHIJKL/0e81bcd0bd529e7def3d551e30125b4b04820d9452dd43154fad871cb752688f.yml
aws s3 cp retain-patches/my-app-test-job.yml s3://my-app-artifacts-bucket/ecsodus/retain-patches/my-app-test-job/5c50fb0bb6492c11697138dd35b660bc07de44194904929155d0190162490094.yml
)
3b. Stacks¶
For each stack below, in order. A stack marked already retained is skipped once verify-retain confirms it.
my-app-test (env)¶
(
set -euo pipefail
umask 077
rm -f cs-my-app-test.json cs-my-app-test.nested-*.json
cs="ecsodus-retain-8077b3786b30-$(date +%s)"
aws cloudformation create-change-set --stack-name my-app-test --change-set-name "$cs" --template-url https://my-app-artifacts-bucket.s3.us-west-2.amazonaws.com/ecsodus/retain-patches/my-app-test/8077b3786b301188f87227c4a20b8d08a4a366d8b65ebaedafcff44556fd2b19.yml --include-nested-stacks --parameters ParameterKey=ALBWorkloads,UsePreviousValue=true ParameterKey=Aliases,UsePreviousValue=true ParameterKey=AppDNSDelegationRole,UsePreviousValue=true ParameterKey=AppDNSName,UsePreviousValue=true ParameterKey=AppName,UsePreviousValue=true ParameterKey=AppRunnerPrivateWorkloads,UsePreviousValue=true ParameterKey=CreateHTTPSListener,UsePreviousValue=true ParameterKey=CreateInternalHTTPSListener,UsePreviousValue=true ParameterKey=EFSWorkloads,UsePreviousValue=true ParameterKey=EnvironmentName,UsePreviousValue=true ParameterKey=InternalALBWorkloads,UsePreviousValue=true ParameterKey=NATWorkloads,UsePreviousValue=true ParameterKey=ServiceDiscoveryEndpoint,UsePreviousValue=true ParameterKey=ToolsAccountPrincipalARN,UsePreviousValue=true --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM
aws cloudformation wait change-set-create-complete --stack-name my-app-test --change-set-name "$cs" || true # an empty change set ends FAILED; check decides
aws cloudformation describe-change-set --stack-name my-app-test --change-set-name "$cs" --include-property-values > cs-my-app-test.json
ecsodus check --changeset cs-my-app-test.json --manifest ecsodus-manifest.json --stack my-app-test
aws cloudformation execute-change-set --stack-name my-app-test --change-set-name "$cs"
aws cloudformation wait stack-update-complete --stack-name my-app-test
ecsodus verify-retain --app my-app --stack my-app-test --manifest ecsodus-manifest.json
)
If check --changeset exits with 3 (empty): the stack may already be retained (verify-retain passes: skip it), or CloudFormation treated the policy-only change as a no-op. In that case regenerate with --metadata-fallback and add --allow-metadata-key to the check. The check also fails if any nested change set is missing, so a failed describe-change-set in the loop can never pass silently.
my-app-test-dogworker (workload)¶
(
set -euo pipefail
umask 077
rm -f cs-my-app-test-dogworker.json cs-my-app-test-dogworker.nested-*.json
cs="ecsodus-retain-c7f6fd33d1b8-$(date +%s)"
aws cloudformation create-change-set --stack-name my-app-test-dogworker --change-set-name "$cs" --template-url https://my-app-artifacts-bucket.s3.us-west-2.amazonaws.com/ecsodus/retain-patches/my-app-test-dogworker/c7f6fd33d1b8e43e630c4fea6125881e6d0a1b8d711cd9b1380b194bb12b059d.yml --include-nested-stacks --parameters ParameterKey=AddonsTemplateURL,UsePreviousValue=true ParameterKey=AppName,UsePreviousValue=true ParameterKey=ArtifactKeyARN,UsePreviousValue=true ParameterKey=ContainerImage,UsePreviousValue=true ParameterKey=EnvFileARN,UsePreviousValue=true ParameterKey=EnvName,UsePreviousValue=true ParameterKey=LogRetention,UsePreviousValue=true ParameterKey=TaskCPU,UsePreviousValue=true ParameterKey=TaskCount,UsePreviousValue=true ParameterKey=TaskMemory,UsePreviousValue=true ParameterKey=WorkloadName,UsePreviousValue=true --capabilities CAPABILITY_IAM
aws cloudformation wait change-set-create-complete --stack-name my-app-test-dogworker --change-set-name "$cs" || true # an empty change set ends FAILED; check decides
aws cloudformation describe-change-set --stack-name my-app-test-dogworker --change-set-name "$cs" --include-property-values > cs-my-app-test-dogworker.json
ecsodus check --changeset cs-my-app-test-dogworker.json --manifest ecsodus-manifest.json --stack my-app-test-dogworker
aws cloudformation execute-change-set --stack-name my-app-test-dogworker --change-set-name "$cs"
aws cloudformation wait stack-update-complete --stack-name my-app-test-dogworker
ecsodus verify-retain --app my-app --stack my-app-test-dogworker --manifest ecsodus-manifest.json
)
If check --changeset exits with 3 (empty): the stack may already be retained (verify-retain passes: skip it), or CloudFormation treated the policy-only change as a no-op. In that case regenerate with --metadata-fallback and add --allow-metadata-key to the check. The check also fails if any nested change set is missing, so a failed describe-change-set in the loop can never pass silently.
my-app-test-fe (workload)¶
(
set -euo pipefail
umask 077
rm -f cs-my-app-test-fe.json cs-my-app-test-fe.nested-*.json
cs="ecsodus-retain-f727bbe38c13-$(date +%s)"
aws cloudformation create-change-set --stack-name my-app-test-fe --change-set-name "$cs" --template-url https://my-app-artifacts-bucket.s3.us-west-2.amazonaws.com/ecsodus/retain-patches/my-app-test-fe/f727bbe38c1345a7d944f46b70e3fbda6293460142259b1d71dc1607ec4496ba.yml --include-nested-stacks --parameters ParameterKey=AddonsTemplateURL,UsePreviousValue=true ParameterKey=AppName,UsePreviousValue=true ParameterKey=ArtifactKeyARN,UsePreviousValue=true ParameterKey=ContainerImage,UsePreviousValue=true ParameterKey=ContainerPort,UsePreviousValue=true ParameterKey=DNSDelegated,UsePreviousValue=true ParameterKey=EnvFileARN,UsePreviousValue=true ParameterKey=EnvName,UsePreviousValue=true ParameterKey=HTTPSEnabled,UsePreviousValue=true ParameterKey=LogRetention,UsePreviousValue=true ParameterKey=RulePath,UsePreviousValue=true ParameterKey=TargetContainer,UsePreviousValue=true ParameterKey=TargetPort,UsePreviousValue=true ParameterKey=TaskCPU,UsePreviousValue=true ParameterKey=TaskCount,UsePreviousValue=true ParameterKey=TaskMemory,UsePreviousValue=true ParameterKey=WorkloadName,UsePreviousValue=true --capabilities CAPABILITY_IAM
aws cloudformation wait change-set-create-complete --stack-name my-app-test-fe --change-set-name "$cs" || true # an empty change set ends FAILED; check decides
aws cloudformation describe-change-set --stack-name my-app-test-fe --change-set-name "$cs" --include-property-values > cs-my-app-test-fe.json
for id in $(jq -r '.Changes[].ResourceChange | select(.ResourceType=="AWS::CloudFormation::Stack") | .ChangeSetId // empty' cs-my-app-test-fe.json); do
aws cloudformation describe-change-set --change-set-name "$id" --include-property-values > "cs-my-app-test-fe.nested-${id##*/}.json"
done
ecsodus check --changeset cs-my-app-test-fe.json cs-my-app-test-fe.nested-*.json --manifest ecsodus-manifest.json --stack my-app-test-fe
aws cloudformation execute-change-set --stack-name my-app-test-fe --change-set-name "$cs"
aws cloudformation wait stack-update-complete --stack-name my-app-test-fe
ecsodus verify-retain --app my-app --stack my-app-test-fe --manifest ecsodus-manifest.json
)
If check --changeset exits with 3 (empty): the stack may already be retained (verify-retain passes: skip it), or CloudFormation treated the policy-only change as a no-op. In that case regenerate with --metadata-fallback and add --allow-metadata-key to the check. The check also fails if any nested change set is missing, so a failed describe-change-set in the loop can never pass silently.
my-app-test-job (workload)¶
(
set -euo pipefail
umask 077
rm -f cs-my-app-test-job.json cs-my-app-test-job.nested-*.json
cs="ecsodus-retain-5c50fb0bb649-$(date +%s)"
aws cloudformation create-change-set --stack-name my-app-test-job --change-set-name "$cs" --template-url https://my-app-artifacts-bucket.s3.us-west-2.amazonaws.com/ecsodus/retain-patches/my-app-test-job/5c50fb0bb6492c11697138dd35b660bc07de44194904929155d0190162490094.yml --include-nested-stacks --parameters ParameterKey=AddonsTemplateURL,UsePreviousValue=true ParameterKey=AppName,UsePreviousValue=true ParameterKey=ArtifactKeyARN,UsePreviousValue=true ParameterKey=ContainerImage,UsePreviousValue=true ParameterKey=EnvFileARN,UsePreviousValue=true ParameterKey=EnvFileARNFornginx,UsePreviousValue=true ParameterKey=EnvName,UsePreviousValue=true ParameterKey=LogRetention,UsePreviousValue=true ParameterKey=Schedule,UsePreviousValue=true ParameterKey=TaskCPU,UsePreviousValue=true ParameterKey=TaskCount,UsePreviousValue=true ParameterKey=TaskMemory,UsePreviousValue=true ParameterKey=WorkloadName,UsePreviousValue=true --capabilities CAPABILITY_IAM
aws cloudformation wait change-set-create-complete --stack-name my-app-test-job --change-set-name "$cs" || true # an empty change set ends FAILED; check decides
aws cloudformation describe-change-set --stack-name my-app-test-job --change-set-name "$cs" --include-property-values > cs-my-app-test-job.json
ecsodus check --changeset cs-my-app-test-job.json --manifest ecsodus-manifest.json --stack my-app-test-job
aws cloudformation execute-change-set --stack-name my-app-test-job --change-set-name "$cs"
aws cloudformation wait stack-update-complete --stack-name my-app-test-job
ecsodus verify-retain --app my-app --stack my-app-test-job --manifest ecsodus-manifest.json
)
If check --changeset exits with 3 (empty): the stack may already be retained (verify-retain passes: skip it), or CloudFormation treated the policy-only change as a no-op. In that case regenerate with --metadata-fallback and add --allow-metadata-key to the check. The check also fails if any nested change set is missing, so a failed describe-change-set in the loop can never pass silently.
Finally, re-inventory (same selection) and regenerate. The patch changed every stack's LastUpdatedTime, and regeneration must produce the same Terraform (PLAN §13.9):
(
set -euo pipefail
umask 077
ecsodus inventory --app my-app --region us-west-2 -o inventory.json
regen=$(mktemp -d ./regen.XXXXXX)
ecsodus generate inventory.json --patch-bucket my-app-artifacts-bucket --out "$regen"
for f in "$regen"/*.tf; do diff -u "$(basename "$f")" "$f"; done
for f in $(grep -l "^# Generated by ecsodus" ./*.tf); do test -f "$regen/$f"; done
cp "$regen"/ecsodus-manifest.json ecsodus-manifest.json
)
4. Import into Terraform¶
Edit the bucket name in backend.hcl first (cp backend.hcl.example backend.hcl).
(
set -euo pipefail
umask 077
ecsodus verify-fresh --manifest ecsodus-manifest.json
terraform init -backend-config=backend.hcl
terraform plan -out tf-import.plan
terraform show -json tf-import.plan > plan-import.json
ecsodus check plan-import.json --manifest ecsodus-manifest.json --phase import
terraform apply tf-import.plan
terraform state list > state.txt
ecsodus check --state state.txt --manifest ecsodus-manifest.json
terraform plan -out tf-steady.plan
terraform show -json tf-steady.plan > plan-steady.json
ecsodus check plan-steady.json --manifest ecsodus-manifest.json --phase steady
terraform state pull > checkpoint.tfstate
)
4b. Harden import-unread arguments (after the steady check passes)¶
The provider does not read these arguments back on import, so they are under ignore_changes for the import. Once the steady check passes, remove each from ignore_changes, run a plan, and confirm that it shows only in-place updates of exactly these arguments (state-only; no AWS change). Then apply:
aws_rds_cluster.fe_addons_db_dbcluster: skip_final_snapshot, final_snapshot_identifieraws_sns_topic_subscription.dogworker_dogsvcgivesdogs_snstopic_subscription: confirmation_timeout_in_minutes, endpoint_auto_confirmsaws_sns_topic_subscription.dogworker_dogsvcgiveshuskies_snstopic_subscription: confirmation_timeout_in_minutes, endpoint_auto_confirmsaws_sns_topic_subscription.dogworker_mytopicmytopicfifo_snstopic_subscription: confirmation_timeout_in_minutes, endpoint_auto_confirmsaws_sns_topic_subscription.dogworker_yourtopicyourtopicfifo_snstopic_subscription: confirmation_timeout_in_minutes, endpoint_auto_confirmsaws_sns_topic_subscription.dogworker_nonfifotopicnonfifotopic_snstopic_subscription: confirmation_timeout_in_minutes, endpoint_auto_confirmsaws_sfn_state_machine.job_state_machine: publish
Rollback before step 5: terraform state rm each imported address (listed in ecsodus-manifest.json). CloudFormation still owns everything, and the retain patches are harmless. After step 5 there is no rollback to Copilot.
After the migration, image rollouts belong to your deploy tool (ecspresso, or CI that registers task-definition revisions). Terraform ignores task_definition and desired_count on services. When a new revision replaces the imported one, delete the aws_ecs_task_definition resource block and its import block in the same change that adds the removed block below. Gate that change with ecsodus check plan.json --manifest ecsodus-manifest.json --phase steady --forgotten <address>:
removed {
from = aws_ecs_task_definition.fe_task_definition
lifecycle {
destroy = false
}
}
removed {
from = aws_ecs_task_definition.dogworker_task_definition
lifecycle {
destroy = false
}
}
removed {
from = aws_ecs_task_definition.job_task_definition
lifecycle {
destroy = false
}
}
5. Teardown of Copilot stacks¶
Delete only handed-off stacks, in this order: workloads, orphaned addons, environments, StackSet instances in this account and region, the StackSet (only if it has no other instances), then the app stack. Before each delete, verify-retain must pass.
(
set -euo pipefail
umask 077
ecsodus verify-retain --app my-app --stack my-app-test-dogworker
aws cloudformation delete-stack --stack-name my-app-test-dogworker
aws cloudformation wait stack-delete-complete --stack-name my-app-test-dogworker
ecsodus verify-retain --app my-app --stack my-app-test-fe
aws cloudformation delete-stack --stack-name my-app-test-fe
aws cloudformation wait stack-delete-complete --stack-name my-app-test-fe
ecsodus verify-retain --app my-app --stack my-app-test-job
aws cloudformation delete-stack --stack-name my-app-test-job
aws cloudformation wait stack-delete-complete --stack-name my-app-test-job
# my-app-test-fe-AddonsStack-1ABCDEFGHIJKL was orphaned when its parent was deleted (wrapper retained)
ecsodus verify-retain --app my-app --stack my-app-test-fe-AddonsStack-1ABCDEFGHIJKL
aws cloudformation delete-stack --stack-name my-app-test-fe-AddonsStack-1ABCDEFGHIJKL
aws cloudformation wait stack-delete-complete --stack-name my-app-test-fe-AddonsStack-1ABCDEFGHIJKL
ecsodus verify-retain --app my-app --stack my-app-test
aws cloudformation delete-stack --stack-name my-app-test
aws cloudformation wait stack-delete-complete --stack-name my-app-test
)
6. Verify¶
(
set -euo pipefail
umask 077
terraform plan -out tf-final.plan
terraform show -json tf-final.plan > plan-final.json
ecsodus check plan-final.json --manifest ecsodus-manifest.json --phase steady
)
A zero-change plan after refresh, covering every imported address, proves that every imported resource still exists and matches. Then read your sentinel data back.
After step 5 has completed, delete the Copilot-internal leftovers. The patch retained them, and they are now unowned:
AWS::Lambda::Functionmy-app-test-CertificateValidationFunction-AbC1(frommy-app-test/CertificateValidationFunction)AWS::Lambda::Functionmy-app-test-CustomDomainFunction-DeF2(frommy-app-test/CustomDomainFunction)AWS::Lambda::Functionmy-app-test-DNSDelegationFunction-GhI3(frommy-app-test/DNSDelegationFunction)AWS::Lambda::Functionmy-app-test-fe-EnvControllerFunction-M3N4(frommy-app-test-fe/EnvControllerFunction)AWS::Lambda::Functionmy-app-test-fe-RulePriorityFunction-U1V2(frommy-app-test-fe/RulePriorityFunction)AWS::SecretsManager::SecretTargetAttachmentarn:aws:secretsmanager:us-west-2:123456789012:secret:dbAuroraSecret-AbCdEfGhIjKl-q1W2e3(frommy-app-test-fe-AddonsStack-1ABCDEFGHIJKL/dbSecretAuroraClusterAttachment)AWS::Lambda::Functionmy-app-test-dogworker-DynamicDesiredCountFunct-Mn8Bv4(frommy-app-test-dogworker/DynamicDesiredCountFunction)AWS::Lambda::Functionmy-app-test-dogworker-EnvControllerFunction-Pq1Ws3(frommy-app-test-dogworker/EnvControllerFunction)AWS::Lambda::Functionmy-app-test-job-EnvControllerFunction-Zx9Cv8(frommy-app-test-job/EnvControllerFunction)
Custom-resource handles (Custom::*, AWS::CloudFormation::CustomResource) are not AWS resources and are not listed: they disappear with their stack. Never delete anything by a handle's physical ID. HTTPSCert's is the ARN of the certificate Terraform now owns.
Retained resources keep their aws:cloudformation:* tags. The AWS provider ignores aws: tags, so they cause no drift, and you may leave them.
7. Never, after migrating¶
- Never run
copilot app delete,copilot env delete,copilot env deployorcopilot app upgradeagainst a retained or migrated stack. They re-render templates without the Retain policies and can modify imported resources. - After a partial migration, only
copilot svc deployof unmigrated workloads is allowed. Afterwards, re-runecsodus verify-retain.