If you search "what is AWS App Runner" today, most of the results still read like it launched last week. The AWS documentation says otherwise, in its first line: App Runner is no longer open to new customers. That changes what a useful article about it looks like. This is not a tutorial for a service you cannot start using. It is a definition of what App Runner was, an honest account of what its replacement does and does not do, and a hands-on migration to Amazon ECS Express Mode that you can finish in about an hour.
What it actually is
App Runner was a fully managed way to run a containerized web service. You pointed it at a container image in a registry, or at a source repository, and it gave you back an HTTPS URL with load balancing, TLS, autoscaling and logging already wired. It launched in May 2021 as the "I do not want to learn ECS" option.
The mental model that matters: App Runner was not a new compute engine. It was a packaging of pieces you already know. Under the hood it was Fargate-class compute behind a load balancer with managed certificates and CloudWatch metrics, plus a source-to-image build step and a deployment trigger. The value was the packaging, not the parts.
The status today, per the AWS App Runner availability change page: AWS has closed App Runner to new customers. Existing customers can keep using it, including creating new services. AWS continues to invest in security and availability but will not add features. I could not find an end-of-support date on that page, so I will not invent one. Secondary coverage dates the closure to April 30, 2026, after the documentation appeared at the end of March; treat those dates as reporting, not as AWS commitments.
The model
App Runner had a short list of primitives. A service was the unit you deployed. A source was either an image in Amazon ECR (or ECR Public) or a code repository that App Runner built for you. An auto scaling configuration set concurrency and min and max instances. A VPC connector sent outbound traffic into your VPC. Custom domains and certificates sat on top.
What AWS operated: the load balancer, the TLS certificate, the scaling decisions, the build pipeline, the fleet. What you operated: the image and its port, environment variables, and the health check path.
The replacement keeps almost the same operating model with one important change in visibility. ECS Express Mode takes a container image, a task execution role and an infrastructure role, and provisions an ECS service on Fargate, an Application Load Balancer with HTTPS, security groups, an auto scaling policy, a CloudWatch log group and a certificate. The difference from App Runner is that every one of those resources lands in your account where you can see and touch it. Per the Express Mode resources page, services use a canary deployment strategy and a CloudWatch alarm watches for faulty deployments. Up to 25 Express Mode services in the same VPC can share one ALB, and unused ALBs are removed as services go away.
When to use it, when not to
You cannot start on App Runner now, so the real comparison is between the options a new project actually has.
App Runner (existing accounts only) | ECS Express Mode | ECS standard service | Lambda with a function URL | |
|---|---|---|---|---|
Open to new customers | No | Yes | Yes | Yes |
Input | Image or source repo | Image | Task definition, service, ALB you build | Zip or image |
Builds from source | Yes | No, bring an image | No | No |
Load balancer | Managed, hidden | ALB in your account, shared across up to 25 services | You create it | None needed |
Extra charge for the service itself | Yes, per its pricing page | No | No | No |
Use ECS Express Mode when you have a stateless HTTP service in a container and you want a URL, autoscaling and sane defaults without writing a cluster, task definition, service, target group and listener rule by hand. Do not use it when you need something outside its model. The same documentation says the deployment strategy and the load balancer configuration cannot be updated on an Express Mode service, and that the service name and cluster can only be set at creation. If you need blue/green with custom traffic shifting, or a non-HTTP workload, build a standard ECS service. If your traffic is spiky and mostly idle, a Lambda function URL may be cheaper than anything with a load balancer in front of it.
If you run App Runner today, the case for staying is real: nothing is being turned off, and migration is work with no new feature at the end of it. The case for moving is that a service with no feature roadmap and no new customers is a service you should have a plan for, and the plan should be written while nothing is on fire.
What it costs
App Runner billed on two modes. Per its pricing page, provisioned container instances cost $0.007 per GB-hour while idle and warm, and active instances cost $0.064 per vCPU-hour plus $0.007 per GB-hour while handling requests, billed per second with a one-minute minimum for vCPU. There were also charges for automatic deployments and for build minutes. A 2 GB service that sat idle all month cost about $10 in provisioned memory alone (0.014 dollars an hour times 730 hours), plus active time.
ECS Express Mode adds no charge of its own. You pay for what it creates: Fargate compute, the Application Load Balancer, CloudWatch logs and metrics, and data transfer. Using the current published numbers on the Fargate and Elastic Load Balancing pricing pages, Fargate Linux x86 in us-east-1 is $0.000011244 per vCPU-second and $0.000001235 per GB-second, which works out to roughly $0.0405 per vCPU-hour and $0.0044 per GB-hour. An ALB is $0.0225 per hour plus $0.008 per LCU-hour.
Here is the number that surprises people. A 1 vCPU, 2 GB task running all month is about $36 of Fargate. The ALB adds about $16 before any LCU charges. Call it $52 a month for one always-on service, against roughly $10 plus active charges for the same footprint idling on App Runner. App Runner hid the load balancer cost inside its rates and let you pay a small amount to keep an instance warm. Express Mode shows you the ALB line item. The mitigation is the thing Express Mode was designed around: put up to 25 services behind one ALB and the $16 amortizes. Public IPv4 addresses on the ALB and tasks are also billed separately, so check the VPC pricing page for the current rate.
The tutorial below uses 0.25 vCPU and 0.5 GB for about 30 minutes. That is roughly 1.2 cents of Fargate per hour plus 2.25 cents of ALB per hour, so a half hour costs a couple of cents. Budget ten cents to be safe.
The limits that bite
I will be specific, because these are the things that hurt after migration, not before.
Source deployments are gone. App Runner could build from a GitHub repository. Express Mode takes an image, so you need a Dockerfile, a registry and a build step. The migration guide says as much: if your service deploys from source, first add a build step that produces a container image. AWS provides an Amazon ECS Deploy Express Service GitHub Action for the deploy half.
The load balancer is fixed. You cannot change its configuration after creation, and the first service in a VPC decides whether the ALB is internet-facing or internal. Later services must match the availability zones.
Immutable names. Cluster and service name are creation-time decisions. Renaming means recreating.
The container must be called Main. If you hand Express Mode your own task definition, it requires a container named Main, a single TCP port mapping with a port name, and Fargate compatibility. Rename the container later and updates can break.
Defaults differ by interface. The resources page lists 1 vCPU and 2 GB as the defaults, while the boto3 reference for the same operation lists 256 CPU units and 512 MiB. When two official pages disagree, set the value explicitly. The code below does.
Build it
We will do the migration the way the AWS guide recommends: stand up the replacement next to the original, send a slice of traffic to it, and only then retire the old one. If you have no App Runner service (you cannot create one as a new customer), the build still works as a clean ECS Express Mode deployment and the weighted DNS step is the only part you skip.
Prerequisites: an AWS account with a default VPC that has public subnets, the AWS CLI v2 configured, Python 3.10 or newer with pip install boto3, and permissions to create IAM roles and ECS, ELB, EC2, CloudWatch and ACM resources. For a throwaway account, AdministratorAccess is simplest. In a real account, scope an identity to iam:CreateRole, iam:AttachRolePolicy, iam:PassRole, and the ecs:*ExpressGatewayService actions plus read access to ELB and EC2. I am keeping the exact least-privilege policy out of this article because the set of underlying calls is large and AWS manages it through the infrastructure role; start broad in a sandbox and narrow with CloudTrail.
Step 1 is the two roles. The task execution role lets ECS pull the image and write logs. The infrastructure role lets Express Mode create the load balancer and friends on your behalf. These are the commands from the getting started guide.
aws iam create-role --role-name ecsTaskExecutionRole \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam create-role --role-name ecsInfrastructureRoleForExpressServices \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam attach-role-policy --role-name ecsInfrastructureRoleForExpressServices \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServicesIf the roles already exist from a previous ECS project, the create calls will fail with EntityAlreadyExists, which is fine. Wait about a minute for IAM to propagate before the next step.
Step 2 is the service. We use a public nginx image so there is nothing to build, and we set CPU and memory explicitly because of the default mismatch above. Save this as create_service.py.
import boto3, json, sys
ecs = boto3.client("ecs", region_name="us-east-1")
sts = boto3.client("sts")
acct = sts.get_caller_identity()["Account"]
resp = ecs.create_express_gateway_service(
serviceName="sl109-demo",
executionRoleArn=f"arn:aws:iam::{acct}:role/ecsTaskExecutionRole",
infrastructureRoleArn=f"arn:aws:iam::{acct}:role/ecsInfrastructureRoleForExpressServices",
primaryContainer={
"image": "public.ecr.aws/nginx/nginx:latest",
"containerPort": 80,
"environment": [{"name": "APP_ENV", "value": "migration-test"}],
},
cpu="256",
memory="512",
cpuArchitecture="X86_64",
healthCheckPath="/",
scalingTarget={"minTaskCount": 1, "maxTaskCount": 2,
"autoScalingMetric": "AVERAGE_CPU",
"autoScalingTargetValue": 60},
)
svc = resp["service"]
print(svc["serviceArn"])
print(svc["status"]["statusCode"])The CLI equivalent is aws ecs create-express-gateway-service with --primary-container, --cpu 256 --memory 512, and --monitor-resources if you want a live view of resources appearing. The guide says provisioning takes three to five minutes, which is mostly the ALB and the certificate.
Step 3 is the part that replaces App Runner's hidden health logic: waiting for the service to be ready and reading its endpoint. The response shape in the boto3 reference puts the URL under activeConfigurations[].ingressPaths[].endpoint. Save this as wait_and_probe.py.
import boto3, time, urllib.request, sys
ecs = boto3.client("ecs", region_name="us-east-1")
arn = sys.argv[1]
for _ in range(60):
svc = ecs.describe_express_gateway_service(serviceArn=arn)["service"]
code = svc["status"]["statusCode"]
cfgs = svc.get("activeConfigurations", [])
paths = cfgs[0].get("ingressPaths", []) if cfgs else []
if code == "ACTIVE" and paths:
endpoint = paths[0]["endpoint"]
url = endpoint if endpoint.startswith("http") else "https://" + endpoint
print("endpoint:", url)
break
print("status:", code, "- waiting")
time.sleep(15)
else:
sys.exit("service never became ACTIVE")
time.sleep(30) # let the target group pass its health checks
with urllib.request.urlopen(url, timeout=10) as r:
print("HTTP", r.status)
print(r.read(120).decode())Run it with the ARN printed by step 2: python wait_and_probe.py <serviceArn>.
Step 4 is the traffic shift. This is the AWS-documented sequence, and it only applies if you have an existing App Runner service. Create two weighted Route 53 records for your domain, one pointing at the App Runner default domain and one at the Express Mode endpoint, and move the weights through 90/10, 75/25, 50/50, 25/75 and 0/100 for App Runner and ECS respectively. The custom domain on the ECS side is a host header condition on the ALB listener plus a certificate on the HTTPS listener, covered in the custom domain guide. Keep App Runner running for 24 to 48 hours at zero weight so DNS caches expire and you have a rollback.
Step 5 is shipping a new version, which is what you do forever after. Express Mode has an update operation, so a new image tag becomes a canary deployment rather than an in-place swap.
import boto3, sys
ecs = boto3.client("ecs", region_name="us-east-1")
arn, image = sys.argv[1], sys.argv[2]
r = ecs.update_express_gateway_service(
serviceArn=arn,
primaryContainer={"image": image, "containerPort": 80},
)
print(r["service"]["status"]["statusCode"])Verify it works
Three checks, and all three should pass before you move any real traffic. First, aws ecs describe-express-gateway-service --service-arn <arn> --query "service.status.statusCode" returns "ACTIVE". Second, wait_and_probe.py prints HTTP 200 and the first bytes of the nginx welcome page, which begin with <!DOCTYPE html>. Third, the resources exist in your account where App Runner would have hidden them: an Application Load Balancer, a target group with a healthy IP target, a security group pair, a log group named like /aws/ecs/default/sl109-demo-xxxx, and an auto scaling policy on CPU at 60 percent.
That third check is the real difference between the two services, and it is worth five minutes of clicking around the console. Everything you did not have to write is still there.
When it breaks
AccessDeniedException on create. Your caller is missing iam:PassRole for one of the two roles, or the infrastructure role was created seconds ago and has not propagated. Wait a minute and retry before changing policies.
Service stuck before ACTIVE, then tasks cycling. The target group health check path returns something other than a 2xx or 3xx. nginx serves /, but your app may only answer on /health or /ping, and the boto3 reference lists /ping as the API default. Set healthCheckPath to a route your app really serves. The health check grace period defaults to 300 seconds, so a slow starter fails slowly.
Image pull failures. A private ECR image needs the task execution role to have ECR read permissions, which the managed AmazonECSTaskExecutionRolePolicy provides for the same account. Cross-account or private third-party registries need extra configuration.
UnsupportedFeatureException. The service is not available where you pointed the client. The documentation says Express Mode is available in regions where ECS and Fargate are, but check the region you chose before debugging anything else.
The new URL works and your domain does not. Express Mode gives you <name>.ecs.<region>.on.aws. Your own domain needs the host header rule and certificate from the custom domain guide; DNS pointing at the ALB without them returns the ALB default response.
Cleanup
Delete the service, and the managed infrastructure goes with it. Per the delete-express-gateway-service reference, the service drains and then its tasks, the Application Load Balancer, target groups, security groups and auto scaling policies are removed. The ALB is removed only when no other Express service shares it.
aws ecs delete-express-gateway-service --service-arn <serviceArn> --monitor-resources
aws iam detach-role-policy --role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam detach-role-policy --role-name ecsInfrastructureRoleForExpressServices \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices
aws iam delete-role --role-name ecsTaskExecutionRole
aws iam delete-role --role-name ecsInfrastructureRoleForExpressServicesSkip the role deletion if other workloads use those roles. Then confirm: aws elbv2 describe-load-balancers should no longer list the ALB, and the log group /aws/ecs/default/sl109-demo-* can be deleted from CloudWatch if you do not want to keep it. For a real migration, remember the App Runner side too: delete the weighted records, remove the custom domain association, run aws apprunner delete-service, and clean up ECR images and any App Runner specific roles.
Where this leaves you
The lesson of App Runner is not that managed platforms are risky. It is that the packaging layer is the cheapest thing for a vendor to retire, and the parts underneath are the thing you can always keep. Fargate, the ALB, the certificate and the log group are all still there. Express Mode just stops hiding them. If your stack could survive that, the migration is a Tuesday afternoon.

