What it actually is
Elastic Beanstalk is a deployment orchestrator that sits on top of services you already know. You hand it a zip file (or a container image reference) and a platform name. It creates an EC2 Auto Scaling group, usually an Application Load Balancer, security groups, an S3 bucket for your source bundles, and CloudWatch alarms, then installs your code on the instances and keeps the health dashboard honest. It is not a runtime of its own. There is no "Beanstalk compute". Every instance it launches is a normal EC2 instance in your account that you can SSH into, inspect, and break.
That is the whole trick, and it is also the whole trade. Beanstalk removes the first week of wiring (VPC choices, launch templates, target groups, a deploy script) and leaves you with the operational surface of EC2. The AWS docs describe two modes today: Standard mode, which runs on EC2 and supports Windows, and Cluster mode, which runs your workload on EKS with faster deployments and managed OpenTelemetry. This article is about Standard mode, because that is what almost every production Beanstalk environment is.
Beanstalk itself costs nothing. The documentation is explicit: no additional charge for the service, you pay for the underlying resources.
The model
Four nouns carry the entire service.
An application is a folder-like container. It has no compute and no cost, it just groups the rest. An application version is an immutable pointer to a source bundle in S3 (a zip, a war, or a Dockerrun file). An environment is the running thing: one version of your code deployed on one platform, with its own CNAME like my-env.eba-xxxx.us-east-1.elasticbeanstalk.com. An environment is either a web server environment (behind a load balancer, or a single instance) or a worker environment (pulls jobs from an SQS queue). A platform is the runtime bundle: an operating system image, a language runtime, a web server, and the Beanstalk agent glue. Supported families are Go, Java, .NET, Node.js, PHP, Python, Ruby and Docker.
Three details explain most Beanstalk behavior.
First, the environment is a CloudFormation stack that you do not own. Beanstalk generates it, and if you edit the underlying resources by hand (resize the ASG, swap a security group) the next environment update can silently revert you. Configuration belongs in option settings: either in the console, in a saved configuration, or in files in your source bundle under .ebextensions/ or .platform/.
Second, platforms are versioned and they die. A platform branch is something like "Python 3.13 on Amazon Linux 2023", and a platform version is a build of that branch (the docs currently list version 4.13.9 for the Python AL2023 branches). AWS patches platform versions continuously and retires whole branches when the runtime or the OS goes end of life. On August 6, 2026, AWS retired every remaining Amazon Linux 2 branch: .NET Core, Corretto 8, 11 and 17, Docker, ECS and Go on AL2. Amazon Linux 2 itself reached end of life on June 30, 2026. Environments on retired branches keep running but receive no security patches. That one release note is the best summary of what "managed" means here: AWS manages the platform lifecycle, and you manage your own migration off it.
Third, health is a first-class citizen. With enhanced health enabled, instances report to the Beanstalk health service and the environment shows Ok, Warning, Degraded or Severe, based on request error rates, latency and instance state, not just a ping. This is the best part of the product and the reason deployments policies below can make real decisions.
When to use it, when not to
Beanstalk fits a specific shape: a conventional web app or API, one or a few services, a team that wants a load-balanced, auto-scaled, health-checked deployment without writing infrastructure code, and that is comfortable with instances as the unit of compute.
Question | Elastic Beanstalk | ECS (on Fargate) | Lightsail | Lambda |
|---|---|---|---|---|
Unit of compute | EC2 instance you can SSH into | Container task | Fixed-price VM or container service | Function invocation |
Setup effort | Low: zip plus one command | Medium: task definition, service, networking | Lowest | Low to medium |
Deploy strategies built in | All at once, rolling, rolling with batch, immutable, traffic splitting | Rolling, blue/green via CodeDeploy | Basic | Aliases, weighted routing |
Where it hurts | Platform retirement, EC2 patching, hidden CloudFormation | More moving parts | Ceiling on scale | Request model constraints |
Best for | Monolith or API that wants PaaS ergonomics on EC2 | Container-first teams | Small fixed-cost workloads | Event-driven and bursty |
Do not start a greenfield container-first system on Beanstalk's Docker platform in 2026. The Docker and ECS platform branches have been through two OS generations of churn, and at the point where you are already building images, ECS Fargate or ECS Express Mode gives you the same outcome with fewer layers. Do not use it for anything that needs fine-grained control over networking between many services either: you will fight the generated stack. And if you have a handful of Python or Node apps, some PHP, a .NET shop, and no platform team, it is still an honest, boring, productive choice.
What it costs
There is no Beanstalk charge, so the bill is the sum of what it creates. The dimensions that matter:
EC2 instances in the Auto Scaling group, by instance type, billed per second as usual. A load-balanced environment defaults to a minimum of more than one instance in a production-style config, so you pay for the redundancy you asked for.
The load balancer. An Application Load Balancer bills per hour plus per LCU. This is the line that surprises people: for a small app, the ALB can cost as much as the instance. Single-instance environments (
--single) skip the ALB entirely and are the right choice for dev and for this tutorial.Immutable and traffic-splitting deployments launch a full second set of instances for the duration of the deploy. The AWS docs mark their cost as very high relative to rolling, and they note that these policies cause EC2 burst balance loss on burstable instance types. Do not run them on a fleet of t-class instances without understanding CPU credits.
Data transfer, S3 for source bundles and logs, CloudWatch alarms and logs. Small, but they accumulate across forgotten environments.
Old application versions sit in S3 forever unless you configure a lifecycle policy for versions.
Estimated cost to follow along: a single t3.micro environment for about an hour, plus S3 and a short blue/green overlap with a second instance, is on the order of cents. Check the current EC2 and S3 pricing pages for your region before you start, and finish the cleanup section so no environment keeps billing overnight.
The limits that bite
From the AWS general reference, the default per-region quotas are: 75 applications, 1,000 application versions, 200 environments, 2,000 configuration templates, and 50 custom platform versions. All are adjustable through Service Quotas. The one you will actually hit is application versions: every deploy creates one, and CI pipelines that deploy on every commit reach 1,000 faster than anyone expects. Deploys then start failing with a quota error. The fix is the application version lifecycle policy, which deletes old versions automatically.
The non-numeric limits matter more.
Platform retirement dates are your real quota. The docs list current dates: for example, Node.js 22 on AL2023 and Ruby 3.3 on AL2023 retire July 31, 2027, and PHP 8.2 and .NET 8 and 9 on AL2023 retire March 31, 2027. Node.js 20, Python 3.9 and Ruby 3.2 on AL2023 were retired on August 13, 2026. Put these dates in a calendar. A branch retirement is a forced migration with a known deadline.
IMDSv1 is off by default on AL2023. Code that reads instance metadata the old way breaks. The AL2023 migration notes also list: Node.js version selection from package.json is not honored on AL2023 (platform default only), the awslogs agent is replaced by the unified CloudWatch agent, Apache gets stricter defaults, and Ruby Puma now honors Gemfile.lock.
Cloud config drift. Beanstalk owns the stack. Anything you change outside option settings is on borrowed time.
Deploys are instance-level. An in-place deployment runs your code on live instances, so a bad migration script runs on production hardware. Immutable and traffic-splitting policies exist precisely because of this.
Build it
The deliverable: a Flask app on the Python 3.13 AL2023 platform, running as a single-instance environment, updated with a rolling-policy configuration, then migrated to a second environment with a blue/green CNAME swap. We use boto3 so every API call is explicit, and I show the EB CLI equivalent at the end.
Prerequisites
An AWS account you can break, and credentials in your shell (
aws sts get-caller-identityworks).Python 3.10+ with
pip install boto3.Permissions: for a sandbox, an admin-level role is simplest. For least privilege, start from the Elastic Beanstalk managed user policies in the IAM docs and add
iam:CreateRole,iam:AttachRolePolicy,iam:CreateInstanceProfile,iam:AddRoleToInstanceProfileandiam:PassRolefor the roles below.Region: the examples use
us-east-1. Python 3.13 AL2023 must be available in your region; the script looks it up rather than assuming.
Step 1: the instance profile
Elastic Beanstalk no longer creates the EC2 instance profile for you. Per the docs, you create a role that EC2 can assume, attach the managed policies, and wrap it in an instance profile. For a web-tier app, AWSElasticBeanstalkWebTier is the one you need.
aws iam create-role --role-name aws-elasticbeanstalk-ec2-role \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name aws-elasticbeanstalk-ec2-role \
--policy-arn arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier
aws iam create-instance-profile --instance-profile-name aws-elasticbeanstalk-ec2-role
aws iam add-role-to-instance-profile \
--instance-profile-name aws-elasticbeanstalk-ec2-role \
--role-name aws-elasticbeanstalk-ec2-role
The AWS docs call these managed policies broad on purpose. For production, replace them with a custom policy scoped to your own buckets.
Step 2: the application
On the AL2023 Python platform, Beanstalk looks for a WSGI callable named application in application.py by default. Create a folder with three files.
# application.py
import os
from flask import Flask, jsonify
application = Flask(__name__)
VERSION = os.environ.get("APP_VERSION", "v1")
@application.route("/")
def index():
return jsonify(message="hello from Beanstalk", version=VERSION)
@application.route("/health")
def health():
return "ok", 200
# requirements.txt
flask==3.1.*
The third file is the deploy policy, kept in the bundle so it travels with the code:
# .ebextensions/01-deploy.config
option_settings:
aws:elasticbeanstalk:command:
DeploymentPolicy: Rolling
BatchSizeType: Percentage
BatchSize: 50
aws:elasticbeanstalk:application:environment:
APP_VERSION: v1
Rolling with a single instance behaves like all at once, since there is only one batch. The setting matters when you scale out, and I include it so the file shows where policy lives. The AWS docs list the same option names for the other policies (RollingWithAdditionalBatch, Immutable, TrafficSplitting, the last needing an Application Load Balancer).
Step 3: create, deploy and verify
# deploy.py
import io, sys, time, zipfile, urllib.request
import boto3
REGION = "us-east-1"
APP = "sl-demo-app"
eb = boto3.client("elasticbeanstalk", region_name=REGION)
s3 = boto3.client("s3", region_name=REGION)
def latest_python_stack():
stacks = eb.list_available_solution_stacks()["SolutionStacks"]
# e.g. "64bit Amazon Linux 2023 v4.x.y running Python 3.13"
cands = [s for s in stacks if "Amazon Linux 2023" in s and s.endswith("running Python 3.13")]
if not cands:
sys.exit("No Python 3.13 AL2023 stack in this region")
return cands[0]
def make_bundle(version_label):
files = {
"application.py": open("application.py").read(),
"requirements.txt": open("requirements.txt").read(),
".ebextensions/01-deploy.config": open(".ebextensions/01-deploy.config").read()
.replace("APP_VERSION: v1", f"APP_VERSION: {version_label}"),
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for name, body in files.items():
z.writestr(name, body)
return buf.getvalue()
def upload_version(label):
bucket = eb.create_storage_location()["S3Bucket"]
key = f"{APP}/{label}.zip"
s3.put_object(Bucket=bucket, Key=key, Body=make_bundle(label))
eb.create_application_version(
ApplicationName=APP, VersionLabel=label,
SourceBundle={"S3Bucket": bucket, "S3Key": key})
def wait_ready(env_name, timeout=900):
end = time.time() + timeout
while time.time() < end:
e = eb.describe_environments(EnvironmentNames=[env_name])["Environments"][0]
print(env_name, e["Status"], e.get("Health"))
if e["Status"] == "Ready":
return e
time.sleep(20)
sys.exit("timeout")
def create_env(env_name, label):
eb.create_environment(
ApplicationName=APP, EnvironmentName=env_name, VersionLabel=label,
SolutionStackName=latest_python_stack(),
OptionSettings=[
{"Namespace": "aws:autoscaling:launchconfiguration",
"OptionName": "IamInstanceProfile",
"Value": "aws-elasticbeanstalk-ec2-role"},
{"Namespace": "aws:elasticbeanstalk:environment",
"OptionName": "EnvironmentType", "Value": "SingleInstance"},
{"Namespace": "aws:ec2:instances",
"OptionName": "InstanceTypes", "Value": "t3.micro"},
])
return wait_ready(env_name)
if __name__ == "__main__":
try:
eb.create_application(ApplicationName=APP)
except eb.exceptions.ClientError as err:
print("create_application:", err)
upload_version("v1")
env = create_env("sl-demo-blue", "v1")
url = "http://" + env["CNAME"]
print("verify:", urllib.request.urlopen(url, timeout=15).read().decode())
Run it with python deploy.py. Expect roughly five to ten minutes: Beanstalk creates the stack, launches the instance, installs dependencies, and flips health to Green. The final line should print JSON containing "version":"v1". That is your verification: a real HTTP response from a real instance, served by the platform's default Gunicorn plus nginx setup.
If you like to see what Beanstalk built, look at the environment's resources in the console under its Resources tab, or call describe_environment_resources. You will find an Auto Scaling group with one instance, a security group, and the S3 bucket. No load balancer, because we chose SingleInstance.
Step 4: ship v2 in place
# update.py
from deploy import *
upload_version("v2")
eb.update_environment(EnvironmentName="sl-demo-blue", VersionLabel="v2")
env = wait_ready("sl-demo-blue")
print(urllib.request.urlopen("http://" + env["CNAME"], timeout=15).read().decode())
This is an in-place deploy: the code on the live instance is replaced. With a load-balanced environment and Rolling set as above, half the instances at a time leave the load balancer, update, and return. The output now says "version":"v2".
Step 5: the blue/green swap
In-place deploys are the Beanstalk default, and the Beanstalk docs recommend blue/green for risky changes such as platform branch upgrades. It is also the migration path from a retired AL2 branch to AL2023: create a new environment on the new platform, deploy the same version, test it, then swap CNAMEs. Here we simulate the pattern with a second environment running v3.
# bluegreen.py
from deploy import *
upload_version("v3")
green = create_env("sl-demo-green", "v3")
print("green direct:", urllib.request.urlopen("http://" + green["CNAME"], timeout=15).read().decode())
# Test green at its own URL first. When satisfied, swap.
eb.swap_environment_cnames(
SourceEnvironmentName="sl-demo-blue",
DestinationEnvironmentName="sl-demo-green")
time.sleep(60)
blue = eb.describe_environments(EnvironmentNames=["sl-demo-blue"])["Environments"][0]
print("blue CNAME now serves:", urllib.request.urlopen("http://" + blue["CNAME"], timeout=15).read().decode())
After the swap, the old blue hostname answers with v3 and green's old hostname answers with v2. Rollback is the same call in reverse, which is the entire appeal: the old fleet is still running and still warm. The cost is exactly what you would expect: two full environments for as long as you keep both.
The EB CLI equivalent
The same flow in the EB CLI, installed with pip install awsebcli --upgrade --user (verify with eb --version):
eb init sl-demo-app --platform python-3.13 --region us-east-1
eb create sl-demo-cli --single --instance-types t3.micro
eb deploy
eb swap sl-demo-cli --destination_name sl-demo-green
The --single flag builds an environment with no load balancer, and the docs recommend it for development and testing only. Run eb platform list to see the exact platform names your CLI version accepts, since they change with each platform release.
When it breaks
The environment goes Red right after create. Open the Events tab or run eb events. The most common cause is the instance profile: a missing or misnamed profile fails the launch within seconds. Next is a port or entry-point mismatch: the platform expects application.py exposing application, and a differently named callable gives you a 502 from nginx with a healthy instance. Check eb logs and read web.stdout.log and eb-engine.log.
"Deployment failed: command timed out". A rolling or immutable deploy waits for instances to pass health checks. A slow pip install or a heavy startup exceeds the command timeout. Raise the timeout option, or fix the startup. The docs list Timeout as an option for the immutable policy.
Health is Severe with 5xx on a healthy-looking instance. Enhanced health looks at the load balancer and application error rates. A broken new version does this immediately on an all-at-once deploy. This is the case for immutable or traffic splitting: the old instances are never touched when the new set fails its health check.
Application version limit reached. Add an application version lifecycle policy, or delete old versions with delete_application_version(DeleteSourceBundle=True).
Metadata calls fail on AL2023. IMDSv1 is disabled by default. Update your SDKs and any hand-rolled curl against 169.254.169.254 to use IMDSv2 tokens.
An update reverts something you changed by hand. You edited a resource the environment owns. Move the setting into .ebextensions or option settings.
Your platform branch is retired. The environment will not stop, but it will not be patched. Do the blue/green migration to the corresponding AL2023 branch, and read the AL2 to AL2023 migration page for your runtime's specific breaking changes.
Cleanup
Terminate both environments, delete the application and its versions, then the IAM objects. Termination takes a few minutes.
# cleanup.py
from deploy import *
for name in ["sl-demo-blue", "sl-demo-green"]:
try:
eb.terminate_environment(EnvironmentName=name)
except Exception as err:
print(name, err)
time.sleep(300)
eb.delete_application(ApplicationName=APP, TerminateEnvByForce=True)
aws iam remove-role-from-instance-profile --instance-profile-name aws-elasticbeanstalk-ec2-role --role-name aws-elasticbeanstalk-ec2-role
aws iam delete-instance-profile --instance-profile-name aws-elasticbeanstalk-ec2-role
aws iam detach-role-policy --role-name aws-elasticbeanstalk-ec2-role --policy-arn arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier
aws iam delete-role --role-name aws-elasticbeanstalk-ec2-role
Beanstalk also leaves an S3 bucket named elasticbeanstalk-<region>-<account-id> holding your source bundles. Empty it of the sl-demo-app/ prefix, and delete the bucket only if nothing else uses it. Confirm zero spend by checking describe_environments returns nothing active, EC2 shows no running instances tagged with the environment names, and Cost Explorer's daily view stops showing EC2 and S3 usage the next day.
The honest verdict
Beanstalk is a deployment product, not a platform. It earns its place when you want PaaS ergonomics and are fine owning EC2. It costs you when you ignore the retirement calendar, or when your needs outgrow a generated CloudFormation stack. If you are on an AL2 branch today, the deadline already passed in August 2026, so the blue/green swap above is your next task, not your next quarter.

