“Deploy to AWS” has no single answer, because AWS offers a dozen ways to run a web app. The right one depends on what you built: a static site, a server-rendered framework, a container, or a function. This guide helps you choose, then walks through deploying a containerised web app end to end with Amazon ECS Express Mode, which is AWS’s current recommended route for that case.
Updated October 2026: I rewrote this 2023 post. The original was a long tour of AWS concepts (regions, auto scaling, CDNs, replication) with no actual deployment in it, and the service landscape has changed since: AWS closed App Runner to new customers on 30 April 2026 and now points people to ECS Express Mode, which launched in November 2025; and AWS’s free tier is now a credits-based plan for new accounts. I checked the services and commands against AWS’s documentation and tested the sample app locally, but I did not run the AWS deployment itself in an account, so treat the commands as a starting point and watch for errors.
Step 1: Pick the Right Service
| What you have | Good AWS option | Why |
|---|---|---|
| Static site or single-page app (React, Vue, Angular, Hugo) | AWS Amplify Hosting | Git-based deploys, preview builds for pull requests, custom domains and a global CDN. Also supports SSR frameworks such as Next.js and Nuxt. |
| Server-rendered framework (Next.js, Nuxt) | Amplify Hosting, or a container on ECS | Amplify lists Next.js and Nuxt as supported SSR frameworks; containers give you more control. |
| A containerised web app or API (any language) | Amazon ECS Express Mode | You supply an image and two IAM roles; ECS creates the Fargate service, load balancer, HTTPS URL and autoscaling. |
| Event-driven code or a small API with spiky traffic | AWS Lambda | Pay per use, no servers. See our serverless guide for limits and trade-offs. |
| A traditional server you want full control of | Amazon EC2 (or Lightsail for simpler hosting) | You manage the machine. See our EC2 and Docker walkthrough, which also covers real AWS costs. |
A note on App Runner. If older guides (including the first version of this one) point you to AWS App Runner, know that, per AWS’s availability notice, it is closed to new customers. Existing customers can keep using it, but AWS plans no new features and recommends ECS Express Mode as the replacement. For a new project, don’t start on App Runner.
I’ll use the container path in the rest of this guide because it is the most general: if your app runs in a container, this works for almost any language or framework.
Step 2: Set Up Your AWS Account Safely
- Create the account, then secure the root user immediately: a strong unique password and multi-factor authentication. Don’t use the root user for daily work.
- Create an administrative identity for yourself (IAM Identity Center is AWS’s recommended approach for people; an IAM user with MFA is the simple alternative) and use that to work.
- Set a budget and billing alert before creating anything. New accounts get a credits-based free plan: AWS says up to $200 in credits, which close the account after 6 months or when used up on the free plan, so know your end date. Some things always cost money regardless of credits: for example a public IPv4 address is billed at $0.005 per hour per AWS’s pricing page.
- Install the AWS CLI v2, configure it with credentials for your admin identity (
aws configure ssooraws configure), and choose a Region. Keep everything in one Region while learning. - Install Docker (see the Docker install steps in our EC2 guide for a server, or Docker Desktop locally).
Step 3: Prepare the App
Whatever your stack, a cloud-ready web app should: listen on a port you configure (not a hard-coded one), expose a cheap health-check endpoint so the load balancer can tell it’s alive, read configuration from environment variables, log to standard output, keep no important state on local disk, and shut down gracefully when told to. Here is a minimal Node.js example with those properties:
const http = require('node:http');
const PORT = process.env.PORT || 8080;
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end('ok');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello from AWS', env: process.env.APP_ENV || 'dev' }));
});
server.listen(PORT, () => console.log(`Listening on ${PORT}`));
// Containers get SIGTERM when a task is stopped or replaced: finish in-flight requests, then exit
process.on('SIGTERM', () => server.close(() => process.exit(0)));I ran this locally: /health returned “ok”, the root path returned JSON, and the process exited on SIGTERM. The graceful-shutdown handler matters because ECS sends SIGTERM when it replaces or stops a task during a deployment; finishing in-flight requests avoids dropped connections.
The Dockerfile packages it. Running as the non-root node user is a cheap security win:
FROM node:22-alpine
WORKDIR /app
COPY server.js .
ENV NODE_ENV=production
EXPOSE 8080
USER node
CMD ["node", "server.js"]Keep secrets out of the image and out of the repository. Pass non-secret config as environment variables, and secrets through AWS Secrets Manager or Systems Manager Parameter Store references.
Step 4: Push the Image to Amazon ECR
Amazon ECR is AWS’s private container registry. Replace the account ID, Region and names with yours. One gotcha: ECS Express Mode runs on x86_64 by default, so if you build on an Apple Silicon Mac, tell Docker to build for linux/amd64 (or choose ARM64 when you create the service).
export AWS_REGION=us-east-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export REPO=my-web-app
# One-time: create the repository
aws ecr create-repository --repository-name $REPO --region $AWS_REGION
# Log Docker in to ECR
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
# Build for x86_64, tag and push
docker build --platform linux/amd64 -t $REPO:v1 .
docker tag $REPO:v1 $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPO:v1
docker push $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$REPO:v1Use unique tags (a version or commit hash) rather than only latest, so every deployment is traceable and you can roll back to an exact image.
Step 5: Create the IAM Roles
ECS Express Mode needs two roles: a task execution role that lets ECS pull your image and write logs, and an infrastructure role that lets ECS create the load balancer, scaling and networking on your behalf. These commands come from AWS’s Express Mode getting-started guide (you can also let the console create the roles for you):
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": [{
"Sid": "AllowAccessInfrastructureForECSExpressServices",
"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/AmazonECSInfrastructureRoleforExpressGatewayServicesIAM changes are eventually consistent. If the next step fails right away with “Unable to assume the service linked role” or a similar error, wait about a minute and retry, as AWS’s guide notes.
Step 6: Create the Service
One command creates the whole stack. The container port must match your app (8080 here), and the health-check path must return a 200:
aws ecs create-express-gateway-service \
--service-name my-web-app \
--execution-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsTaskExecutionRole \
--infrastructure-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsInfrastructureRoleForExpressServices \
--primary-container '{"image":"'$ACCOUNT_ID'.dkr.ecr.'$AWS_REGION'.amazonaws.com/'$REPO':v1","containerPort":8080,"environment":[{"name":"APP_ENV","value":"production"}]}' \
--health-check-path "/health" \
--scaling-target '{"minTaskCount":1,"maxTaskCount":3}' \
--monitor-resourcesPer AWS’s Express Mode overview, this provisions a Fargate-based ECS service with its own HTTPS URL, a load balancer with TLS, autoscaling policies, monitoring and networking, in your own account where you can see and change every resource. AWS’s guide says provisioning typically takes a few minutes, and the URL has the form servicename.ecs.REGION.on.aws. By default it uses your account’s default VPC and public subnets, so if you deleted the default VPC you need to create one or specify subnets. Without CPU and memory flags it uses the defaults of 1 vCPU and 2 GB; AWS’s guide shows --cpu, --memory and --cpu-architecture flags if you want to change them.
When it finishes, open the URL: you should see {"message":"Hello from AWS","env":"production"}.
Step 7: Add Your Own Domain
Request a public certificate in AWS Certificate Manager in the same Region, validate it through DNS, then follow AWS’s guide to add a custom domain to an Express Mode service: you add your hostname to the load balancer’s listener rules and the certificate to its HTTPS listener, then point a DNS record at the load balancer (an alias record if the domain is in Route 53, or a CNAME elsewhere).
Step 8: Automate Deployments
Once the manual path works, put it in CI so every push to your main branch builds, pushes and deploys. The sketch below follows the pattern in AWS’s own App Runner migration guide, using GitHub Actions with OpenID Connect so no long-lived AWS keys are stored in GitHub. You first create an IAM OIDC identity provider for GitHub and a role (here github-actions-ecs-role) that your repository may assume, with permission to push to ECR and update the Express service. The action versions below are the ones in AWS’s guide at the time of writing; check for newer ones.
name: Build and deploy
on:
push:
branches: [ main ]
permissions:
id-token: write # needed for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v5
with:
aws-region: ${{ vars.AWS_REGION }}
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/github-actions-ecs-role
- id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:${{ github.sha }}
- name: Deploy to ECS Express Mode
uses: aws-actions/amazon-ecs-deploy-express-service@v1
with:
service-name: ${{ vars.ECS_SERVICE }}
image: ${{ steps.ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:${{ github.sha }}
execution-role-arn: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/ecsTaskExecutionRole
infrastructure-role-arn: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/ecsInfrastructureRoleForExpressServices
container-port: 8080
health-check-path: /healthThis workflow is adapted from AWS’s documentation and I haven’t run it. Add tests and a lint step before the build, and protect the main branch so only reviewed code deploys. Amplify Hosting has its own built-in CI/CD if you chose that route.
What Production Needs Next
- Database. Don’t run a database in your app container. Use a managed database (Amazon RDS or Aurora for relational data, DynamoDB for key-value), in private subnets, with automated backups, and let the app’s task role (not a hard-coded password) or Secrets Manager provide credentials.
- Scaling and availability. Express Mode creates autoscaling; set a minimum of two tasks for production so one failure doesn’t take the app down, and tune the scaling target using real load tests.
- Observability. Logs and metrics go to CloudWatch. Create alarms for error rates, latency and unhealthy tasks, and structure your logs as JSON.
- Security. Least-privilege IAM roles for the task, a web application firewall in front if the app is public, patched base images (rebuild regularly), and dependency scanning. ECR can scan images for known vulnerabilities.
- Cost. Express Mode has no extra fee, but per AWS you pay for what it creates: Fargate compute, the Application Load Balancer, CloudWatch and data transfer. The load balancer carries an hourly charge even at zero traffic, which is why a tiny hobby app can cost more than expected. AWS notes Express Mode shares a load balancer across services with the same networking configuration to reduce that. Check the pricing pages and your Cost Explorer after the first few days.
- Multi-Region and DR. The old post’s “cross-region redundancy” advice is for later: most apps are better served by multiple Availability Zones in one Region (the default with a load balancer and multiple tasks) plus tested backups.
Clean up
When you finish experimenting, delete the service (in the ECS console (or with the matching CLI command)), then check that the load balancer, ECR images and any logs it created are gone so you stop paying for them. Look at Cost Explorer the next day to confirm.
What is the easiest way to deploy a web app on AWS?
For a static site or a framework like React or Next.js, AWS Amplify Hosting connects to your Git repository and deploys with a CDN. For any containerised app, ECS Express Mode creates a Fargate service, load balancer and HTTPS URL from a container image and two IAM roles.
Should I use AWS App Runner?
Not for a new project. AWS closed App Runner to new customers on 30 April 2026 and recommends Amazon ECS Express Mode instead. Existing App Runner customers can continue using it.
Is AWS free for hosting a web app?
Not entirely. New accounts get a credits-based free plan of up to $200 that ends after 6 months or when credits run out, and some resources, such as public IPv4 addresses and load balancers, are billed regardless. Set a budget alert before you create resources.
What is the difference between ECS Express Mode and Elastic Beanstalk or EC2?
EC2 gives you a virtual machine to manage yourself. Express Mode runs your container on Fargate with a load balancer and autoscaling set up for you, in your own account. I did not re-verify the current status of Elastic Beanstalk for this update, so check AWS’s documentation if you are considering it.
How do I deploy automatically when I push code?
Use a CI pipeline such as GitHub Actions with OpenID Connect to assume an AWS role, build and push the image to ECR, then update the ECS Express Mode service with the new image. Amplify Hosting has built-in Git-based deployment for static and SSR apps.








