Authenticating...
Skip to main content

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.

Starting a brand-new environment instead?

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​

  1. 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 in targeted-api#928.
  2. Change-set permissions in production. The import is a hand-run change set, so you need cloudformation:CreateChangeSet and ExecuteChangeSet on the stack. If your role doesn't have them, see If you'd rather not run this by hand.
  3. 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.
  4. Every environment listed. Include worker tiers and, if you set blueGreenDeploymentVersion, the green environment. Each is its own resource to retain, orphan and import.
  5. 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}'
  1. 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);
}
Why first, why alone

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
The gate

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.

Neither step 2 nor step 4 can be reverted

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​

WindowMergesWhy
Before step 2 deploysNormalRetain is metadata only
Step 2 deployed, before the importAllowed, at a costMerges can't touch the orphaned environment, but they change the deployed template, so you rebuild the import template
Import executed, until step 4 deploysFreeze anything that runs deploy-iac. Disarm auto-mergeMain 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​

  1. The environment id and CNAME match what you recorded.
  2. The deployed template has PlatformArn, no SolutionStackName, and Retain on both resources.
  3. cdk diff against production is empty.
  4. The option-settings diff against before.json shows no changes.

Do not change these​

These three, specifically
  • DeletionPolicy: Retain on 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​

  1. The orphan diff reads destroy. Step 1 isn't deployed. Don't merge step 2.
  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.
  3. The logical id doesn't match. The restore deploy fails on the duplicate environment name.
  4. You imported the target version instead of the current one. You've recorded drift no deploy will correct. Import what describe-environments reports.
  5. An infra deploy lands during the freeze. The environment is orphaned again. Redo the import.
  6. The app deploy and the infra deploy disagree. deploy-eb and deploy-iac run 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 the release: published event.

If you'd rather not run this by hand​

  • A pipeline job using your existing deploy role. Whatever role already runs cdk deploy already 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​

PathWhy 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 replaceMoves 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 changeNot 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​


Questions or a walkthrough: Dakota or Fabian, platform team.