Migrating an Elastic Beanstalk Environment to PlatformArn
If your team's Elastic Beanstalk environment is still described by SolutionStackName,
this is how you move it to PlatformArn so every future platform or PHP bump becomes a
one-line change instead of a blocked deploy. Once you're done, the upgrades themselves
are covered in
Upgrading PHP and Laravel on Elastic Beanstalk.
When to use this: your app is built on the LaravelBeanstalkApplication construct
from @adaction/aws-cdk-constructs,
production passes applicationSolutionStackName (or neither platform prop), and a PHP or
platform bump fails with the error below.
Where the IaC lives: your app's provision CDK app, in the stack that creates the
LaravelBeanstalkApplication.
None of this applies to you. Set applicationPlatformArn in your construct props, on
constructs 6.5.0 or later, and every future platform bump is a one-line change from day
one. Set it explicitly: if you pass neither platform prop, the construct falls back to a
default SolutionStackName (PHP 8.2 on v4.3.1) and you've signed up for this runbook.
Why you're blocked
Your environment is described in code by applicationSolutionStackName, a single string
naming the entire platform bundle: OS, language runtime and web server, all versioned
together. The resource schema lists it in createOnlyProperties, so CloudFormation can't
edit it, only replace the whole resource. The construct gives every environment a fixed
name (application-stage), and CloudFormation refuses to replace a custom-named
resource:
CloudFormation cannot update a stack when a custom-named resource requires replacing.
The obvious workaround doesn't work either. Swapping straight to applicationPlatformArn,
the mutable property that does the same job, fails identically, because removing
SolutionStackName is itself the create-only change. You're blocked going in and blocked
going out.
It fails safely. CloudFormation rejects the update before touching anything and rolls back in seconds, so trying it never takes production down.
How the import gets around it
Stop editing the record and write a new one. A CloudFormation resource import creates
a fresh record for a resource that already exists, and because it's created rather than
edited, create-only never applies. The new record describes the environment by
PlatformArn, which updates in place.
aws-cdk-constructs#279
(v6.5.0) added the applicationPlatformArn prop that lets your code describe it that way.
The construct emits exactly one of the two properties and throws at synth if you set both.
Nothing in the migration rebuilds the environment or changes its settings. On AdSuite the import changed zero of 184 live option settings, and production served every request throughout.
Prerequisites
- Constructs 6.5.0 or later. Going from v5 to v6 replaces the plain ACM certificate in the SSL
certificate stack with a Cloudflare-validated custom resource, and deploying that stack
as is tries to delete the live certificate. Retain the certificate first, then pass
existingCertificateArn, as its own change before anything below. Prism did this intargeted-api#928. - Change-set permissions in production. The import is a hand-run change set, so you need
cloudformation:CreateChangeSetandExecuteChangeSeton the stack. If your role doesn't have them, see If you'd rather not run this by hand. - An app deploy that doesn't go through the stack. Check that your app deploy (
deploy-eb) reaches Elastic Beanstalk by environment name through the EB API. That's what keeps app releases shipping while the environment is orphaned. - Every environment listed. Include worker tiers and, if you set
blueGreenDeploymentVersion, the green environment. Each is its own resource to retain, orphan and import. - The current platform ARN, environment id and CNAME recorded. Use the ARN Elastic Beanstalk reports, never a hand-built one.
aws elasticbeanstalk describe-environments --region <REGION> \
--environment-names <ENVIRONMENT_NAME> \
--query 'Environments[].{Id:EnvironmentId,CNAME:CNAME,Arn:PlatformArn,Health:Health}'
- A snapshot of the option settings. You'll diff against it after each step. It's more precise than drift detection and doesn't need drift permissions.
aws elasticbeanstalk describe-configuration-settings --region <REGION> \
--application-name <APPLICATION_NAME> --environment-name <ENVIRONMENT_NAME> \
| jq -S '.ConfigurationSettings[0].OptionSettings | sort_by(.Namespace, .OptionName)' > before.json
The sequence, and why the order is load-bearing
1. Retain: ships alone, first
Add DeletionPolicy: Retain and UpdateReplacePolicy: Retain to both the EB
environment and the EB application, and deploy that by itself. The construct's
applicationRemovalPolicy prop is accepted but never applied, so use the exposed L1
resources:
if (stage === 'production') {
const beanstalk = app.applicationLayer!.phpElasticBeanstalkConstruct;
beanstalk.cfnEnv.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
beanstalk.cfnApp?.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
}
When a resource disappears from a template, CloudFormation honors the policy in the
currently deployed template, not the new one. Combine this with the next step and you
delete production. Both resources need it: with Retain on the environment alone, a
later stack delete fails on the application instead.
Check the deployed template, not just a green deploy, and pin it with a unit test.
2. Orphan: drop it from the template
One line, shipped only to production:
beanstalk.node.tryRemoveChild('BeanstalkEnvironment');
cdk diff should show one row, your environment, with the action orphan. CDK takes that
label from the deployed template, so if it reads destroy, step 1 isn't deployed. Stop.
Worker and green environments have their own construct ids (the Offer API's worker is
WorkerBeanstalkEnvironment).
CloudFormation reports DELETE_SKIPPED and lets go. The environment keeps serving.
Orphaning is pure bookkeeping with no Elastic Beanstalk API call, so expect a fast deploy
(AdSuite's took 11 seconds). If it takes as long as a normal release, something else is
wrong.
3. Import: re-adopt it as PlatformArn
Write the restore PR (step 4) before this step and get it reviewed and green. The import template is built from it, and having it ready keeps the merge freeze to minutes.
Build the import template from your currently deployed template, adding only the
environment resource taken from a production synth of the restore branch. Never use a
fresh synth on its own: it differs in CDKMetadata and can reorder things, and import
rejects any modification to an existing resource.
aws cloudformation get-template --region <REGION> --stack-name <STACK_NAME> \
--template-stage Original --query TemplateBody --output json > deployed.json
# on the restore branch, synthesized for production
jq '.Resources["<LOGICAL_ID>"]' cdk.out/<STACK_NAME>.template.json > env.json
jq --slurpfile env env.json '.Resources["<LOGICAL_ID>"] = $env[0]' deployed.json > import-template.json
Use the logical id from your deployed template, exactly. The environment block must
carry PlatformArn at the platform version production is actually running, never the
version you're about to bump to, plus DeletionPolicy: Retain and no SolutionStackName.
Import only records the template; it never calls update. Import as a newer version than
reality and you've written drift that CloudFormation can't detect and no later deploy will
fix. If the template is over 51,200 bytes, stage it in S3 and pass --template-url.
# 1. resource identifier file: one entry per environment
cat > import.json <<'EOF'
[
{
"ResourceType": "AWS::ElasticBeanstalk::Environment",
"LogicalResourceId": "<LOGICAL_ID>",
"ResourceIdentifier": { "EnvironmentName": "<ENVIRONMENT_NAME>" }
}
]
EOF
# 2. create the change set: computes a plan, changes nothing yet
aws cloudformation create-change-set --region <REGION> \
--stack-name <STACK_NAME> --change-set-name eb-env-import-1 \
--change-set-type IMPORT --resources-to-import file://import.json \
--template-url <S3_URL_OF_IMPORT_TEMPLATE> \
--capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM
aws cloudformation wait change-set-create-complete --region <REGION> \
--stack-name <STACK_NAME> --change-set-name eb-env-import-1
# 3. the gate: read the plan before executing anything
aws cloudformation describe-change-set --region <REGION> \
--stack-name <STACK_NAME> --change-set-name eb-env-import-1 \
--query 'Changes[].ResourceChange.{Action:Action,LogicalId:LogicalResourceId,Replacement:Replacement}' \
--output table
Expect exactly one row per environment: Action=Import, your logical id, no replacement.
Anything else (an extra row, any other action, any replacement value) means stop and
delete the change set. Nothing has happened yet, so nothing needs undoing.
# 4. execute: only once the gate shows what you expect
aws cloudformation execute-change-set --region <REGION> \
--stack-name <STACK_NAME> --change-set-name eb-env-import-1
aws cloudformation wait stack-import-complete --region <REGION> --stack-name <STACK_NAME>
The import takes about a second. CloudFormation then spends a minute or two applying stack-level tags to the imported resource, which shows in Elastic Beanstalk as a configuration update. That's expected.
4. Restore: put the environment back in code, described by PlatformArn
The restore PR removes tryRemoveChild, keeps both Retain calls permanently, and
switches production to applicationPlatformArn at the version you just imported. Merge
it the moment the import completes. Before merging, cdk diff against production on main
plus this branch should show no change on the environment. That empty diff is the proof
that what CloudFormation adopted matches your code. A CI diff comment computed before the
import will show changes; ignore it.
Reverting the orphan after it deploys makes CloudFormation try to create an
environment whose name already exists, and the deploy fails into a rollback. Reverting
the restore puts SolutionStackName back, which is the blocked replacement again. If
something looks wrong, leave the environment orphaned (it serves traffic either way) or
fix forward.
When to stop merging
| Window | Merges | Why |
|---|---|---|
| Before step 2 deploys | Normal | Retain is metadata only |
| Step 2 deployed, before the import | Allowed, at a cost | Merges can't touch the orphaned environment, but they change the deployed template, so you rebuild the import template |
| Import executed, until step 4 deploys | Freeze anything that runs deploy-iac. Disarm auto-merge | Main still carries tryRemoveChild, so any infra deploy orphans the environment straight back out and undoes the import |
App releases are fine throughout, because they reach Elastic Beanstalk by name. A stray
infra deploy in the freeze fails safe, since Retain keeps the environment, but it costs
you the import. Check whether your dependency bot's commits cut a release before deciding
whether to pause it.
Verify
- The environment id and CNAME match what you recorded.
- The deployed template has
PlatformArn, noSolutionStackName, andRetainon both resources. cdk diffagainst production is empty.- The option-settings diff against
before.jsonshows no changes.
Do not change these
DeletionPolicy: Retainon the imported resource. It's what stands between a routine dependency bump and deleting a live environment during the freeze window.- The import version. Import at the platform version you're actually running, not the one you're about to bump to.
- The logical id. It must match your deployed template exactly. Get it wrong and the restore deploy tries to create a second environment and fails on the duplicate name.
What can go wrong
- The orphan diff reads
destroy. Step 1 isn't deployed. Don't merge step 2. - The change set shows a modification or an extra row. The import template doesn't match the
deployed template. Rebuild it from
get-template, not from a synth. - The logical id doesn't match. The restore deploy fails on the duplicate environment name.
- You imported the target version instead of the current one. You've recorded drift no deploy
will correct. Import what
describe-environmentsreports. - An infra deploy lands during the freeze. The environment is orphaned again. Redo the import.
- The app deploy and the infra deploy disagree.
deploy-ebanddeploy-iacrun independently on the same release, so one can land without the other. If a run gets stuck during a GitHub Actions outage and won't cancel or rerun, setting the release to draft and publishing it again re-fires therelease: publishedevent.
If you'd rather not run this by hand
- A pipeline job using your existing deploy role. Whatever role already runs
cdk deployalready creates and executes change sets, because that's how CDK deploys work. Wiring the import into a one-off pipeline job needs no new grants, and the create/execute split maps onto any environment-protection rules you already have. This is the better answer if your org will do this more than once. - Granting change-set permissions to a human role. Faster to get moving, but it widens that role's production blast radius for a one-time migration. Treat it as a last resort and revoke it once you're done.
Other paths, and why not
| Path | Why not |
|---|---|
EB-native update (update-environment --platform-arn) | Works in about 2 minutes, but the stack keeps the old SolutionStackName. Code no longer describes reality, and anyone who later "fixes" the stale value triggers the blocked replacement. Fine for environments with no IaC at all |
| Rename and replace | Moves your CNAME and needs a Cloudflare repoint. The overlap between environments isn't under your control, the replacement comes up with no app version, and the DNS custom resource can time out before a cold environment is ready |
| Blue/green for the PHP change | Not needed. Elastic Beanstalk moves a running environment across platform versions and PHP branches in place |
Keeping Retain has one cost: the stack can't be deleted without terminating the
environment by hand first. For production that's the right trade-off, but make it on
purpose.
References
aws-cdk-constructs#279: addsapplicationPlatformArn.- AdSuite's migration, one PR per step: retain,
orphan,
restore. Prism (
AdGem/targeted-api) and the Offer API (AdGem/offer-api, web and worker together) followed the same sequence. AWS::ElasticBeanstalk::Environment: which properties need replacement.- Importing AWS resources into a CloudFormation stack
- Setting up a Sandbox AWS Account, to rehearse first.
Questions or a walkthrough: Dakota or Fabian, platform team.