Build Multi-Region Apps on AWS

Deploy to two AWS Regions with GitHub Actions and prepare for failover.

Introduction

30 Second Summary

An online service can feel dependable until the place hosting it has a problem. When every copy lives in that one place, every visitor can lose access at once.

In this project, you will use Terraform to prepare AWS resources for a containerized Node.js app. GitHub Actions will test the app and deploy the same version through Amazon ECS Express Mode in two AWS Regions. A DNS or global routing layer is still required to switch traffic between the regional endpoints automatically.

What You'll Build

You will commit one visible change, watch two regional deployment jobs succeed, then refresh both HTTPS endpoints to see the same version served from different Regions.

By the end of this project, you'll have:

  • A tested container that reports its Region and application version.
  • A Terraform-managed foundation with secure GitHub access and one image repository in each Region.
  • Two synchronized regional services that update from one commit and answer through independent HTTPS endpoints.
  • Secret Mission: Compare uncached response times for both regional endpoints and identify the lower median from your location.

Are there any prerequisites?

You need AWS and GitHub accounts with permission to create IAM, ECR, and ECS resources.

The browser-based path uses AWS CloudShell for Terraform and GitHub-hosted runners for Docker, so you do not install tools or store AWS credentials on your computer.

Before We Start

Before you begin, lock in the goal: use Terraform and GitHub Actions to deploy the same tested app version through two independent AWS regional endpoints.

Set Up Secure GitHub-to-AWS Deployments

Prepare the Repository and Terraform Workspace

A repeatable deployment starts with one known repository and one predictable infrastructure workspace. Preparing both now keeps later AWS changes traceable.

You will create the GitHub repository, record its immutable identity values, then prepare Terraform in AWS CloudShell.

In this step, get ready to:
  • Create the project repository.
  • Record the GitHub identity values.
  • Prepare Terraform in AWS CloudShell.
Create the project repository

The repository becomes the source for the app, Terraform configuration, and deployment workflows. Every later change starts from this one location.

  • Sign in to GitHub in your browser.
  • Open the create menu in the top-right corner.
  • Choose New repository.
  • Enter multi-region-app in the Repository name field.
  • Select Public under visibility.
  • Select Add a README file.
  • Choose Create repository.
  • Record your GitHub username as your GitHub username.

Your repository now shows README.md. That file confirms the project has a default main branch.

Collect the identity values

Finding GitHub's numeric IDs is a little fiddly because the interface normally shows names. The immutable IDs prevent a renamed or replaced repository from inheriting deployment access.

  • Open this address in a new browser tab to inspect your GitHub account:
https://api.github.com/users/[[GITHUB_OWNER="your GitHub username"]]
  • Find the numeric id value near the top of the response.
  • Record it as your GitHub numeric owner ID.
  • Open this address in another browser tab to inspect the repository:
https://api.github.com/repos/[[GITHUB_OWNER="your GitHub username"]]/multi-region-app
  • Find the repository's numeric id value.
  • Record it as your GitHub numeric repository ID.
Prepare Terraform in CloudShell

CloudShell gives Terraform temporary access through your signed-in AWS session. You do not create or download long-term AWS access keys.

  • Open AWS CloudShell from the AWS console header.
  • Wait until the terminal prompt is ready.
  • Install Terraform from HashiCorp's Amazon Linux repository by running these commands:
sudo yum install -y yum-utils shadow-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum install -y terraform
  • Verify the installed Terraform release by running:
terraform version

You should see Terraform v1.16.1 or a later compatible release. Your browser-based infrastructure workspace is ready.

Did Terraform fail to install?

Confirm that CloudShell finished opening before rerunning the installation. If the repository command fails, close CloudShell, reopen it, and retry the three commands.

Help me install Terraform in AWS CloudShell.

  • Check whether your account already has an OIDC provider by running:
aws iam list-open-id-connect-providers

✔️ No GitHub provider is listed

Terraform will create the GitHub OIDC provider in Step 3.

ⓧ A GitHub provider is listed

Record the ARN ending in oidc-provider/token.actions.githubusercontent.com as your existing GitHub OIDC provider ARN. Terraform will reuse it instead of replacing a shared provider.

Your repository and Terraform workspace are ready. Next, you will build the app and prove its container works before AWS receives it.

Build and Test the Container

Your repository and browser-based Terraform workspace are ready. The deployment path now needs an application that can prove where and which version is running.

You will create a small Node.js service, package it with Docker, then use GitHub Actions to test the finished container.

In this step, get ready to:
  • Add the regional application files.
  • Describe the application container.
  • Build and test the container on GitHub.
Add the regional web app

The app reads its Region and version from environment variables. Its health route gives both the test workflow and ECS a concrete success signal.

  • Open the multi-region-app repository in GitHub.
  • Choose Add file above the file list.
  • Choose Create new file.
  • Enter package.json in the filename field.
  • Define the application package by pasting this file:
{
  "name": "multi-region-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

What does this file do?

  • The start script runs server.js inside the container.
  • The dependency pin makes every regional build use Express 5.2.1.
  • Commit package.json with the message Add package configuration.
  • Create another file named server.js.
  • Create the regional web server by pasting this code:
const express = require('express');

// Read the deployment identity shown to the learner.
const app = express();
const port = process.env.PORT || 8080;
const region = process.env.AWS_REGION || 'local';
const version = process.env.APP_VERSION || 'test';

// Expose the app result and a lightweight health check.
app.get('/', (request, response) => {
  response.send(`Multi-Region App | Region: ${region} | Version: ${version}`);
});
app.get('/health', (request, response) => response.status(200).send('healthy'));

// Listen on the port used by the container.
app.listen(port, () => console.log(`Listening on port ${port}`));

What does this code do?

  • The region value identifies the AWS Region serving the request.
  • The version value identifies the Git commit deployed to both services.
  • The /health route returns a successful response without exposing deployment details.
  • Commit server.js with the message Add regional web server.
  • Confirm that both application files appear beside README.md.

✔️ I have both app files

Both application files are saved in the repository.

ⓧ I'd like to double-check

{
  "name": "multi-region-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}
const express = require('express');

// Read the deployment identity shown to the learner.
const app = express();
const port = process.env.PORT || 8080;
const region = process.env.AWS_REGION || 'local';
const version = process.env.APP_VERSION || 'test';

// Expose the app result and a lightweight health check.
app.get('/', (request, response) => {
  response.send(`Multi-Region App | Region: ${region} | Version: ${version}`);
});
app.get('/health', (request, response) => response.status(200).send('healthy'));

// Listen on the port used by the container.
app.listen(port, () => console.log(`Listening on port ${port}`));
Describe the container

A container image packages the app with its Node.js runtime. GitHub builds the image, so Docker does not need to be installed on your computer.

  • Create a repository file named Dockerfile.
  • Describe the container build by pasting this file:
FROM node:24.20.0-bookworm
WORKDIR /app

# Install the pinned production dependency.
COPY package*.json ./
RUN npm install --omit=dev

# Copy the server and expose its runtime port.
COPY server.js ./
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]

What does this file do?

The image uses Node.js 24.20.0, installs the pinned package, and starts the server on port 8080.

  • Commit Dockerfile with the message Add container definition.
  • Confirm that Dockerfile appears beside the application files.

Is the Dockerfile missing?

Check that the filename is exactly Dockerfile with no extension. GitHub filenames are case-sensitive.

Help me check my Dockerfile.

Test the container in GitHub

The first workflow tests the image without deploying it. A green run proves that the container builds, starts, and answers both routes.

  • Create a repository file named .github/workflows/test.yml.
  • Create the container test by pasting this workflow:
name: Test container
on: workflow_dispatch

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      # Read the repository files on the runner.
      - uses: actions/checkout@v7.0.1
      # Build and start the app container.
      - run: docker build --tag multi-region-app:test .
      - run: docker run --detach --name app-test --publish 8080:8080 --env AWS_REGION=local --env APP_VERSION=test multi-region-app:test
      # Verify both HTTP routes.
      - run: |
          sleep 5
          curl --fail http://localhost:8080/health
          curl --fail http://localhost:8080/

What does this workflow test?

  • The runner builds the same image that AWS receives later.
  • The container starts with local Region and version values.
  • Two HTTP checks fail the workflow if either route stops responding.
  • Commit the workflow with the message Add container test.
  • Open the repository's Actions tab.
  • Choose Test container in the workflow list.
  • Choose Run workflow.
  • Choose the green Run workflow button.

The first build usually takes two or three minutes while GitHub downloads the Node.js image. A quiet build log during a layer download does not mean the job has stopped.

Before you open the result, do you think both HTTP checks will pass?

  • Open the newest workflow run.
  • Wait until the test job shows a green check.
  • Expand the final workflow step.
  • Confirm that the output includes healthy and Multi-Region App | Region: local | Version: test.

That green workflow is your first visible win. Your container now builds and answers requests without relying on your computer.

Did the container test fail?

Open the failed workflow step and compare its filename with package.json, server.js, and Dockerfile. A missing file or copied quote usually appears near the bottom of the log.

Help me diagnose the container test.

Your tested container is ready. Next, Terraform will create the secure AWS foundation that stores and deploys it.

Provision the AWS Foundation with Terraform

Your container now builds and answers requests on GitHub. AWS still needs the identity, roles, and repositories that the deployment workflow expects.

You will use Terraform in AWS CloudShell to create that foundation before any deployment begins.

In this step, get ready to:
  • Define the regional AWS foundation as Terraform configuration.
  • Create the IAM roles and ECR repositories.
  • Store the Terraform outputs as GitHub repository variables.
Create the Terraform workspace

Terraform keeps the infrastructure definition and its state together. A dedicated folder makes the same configuration available for cleanup later.

  • Create the multi-region-terraform folder in your CloudShell home directory by running:
mkdir -p ~/multi-region-terraform
cd ~/multi-region-terraform
pwd

You should see a path ending in multi-region-terraform.

  • Create main.tf with the AWS provider configuration by running:
cat > main.tf <<'EOF'
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

# Manage the primary and secondary Regions from one state file.
provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}
EOF

What does this configuration do?

The default provider manages resources in us-east-1. The west alias lets the same Terraform state manage us-west-2.

  • Create variables.tf with the GitHub identity inputs by running:
cat > variables.tf <<'EOF'
# Restrict OIDC access to this repository and its main branch.
variable "github_owner" { type = string }
variable "github_owner_id" { type = string }
variable "github_repo_id" { type = string }
variable "existing_oidc_provider_arn" { type = string }

locals {
  github_subject = format("repo:%s@%s/multi-region-app@%s:ref:refs/heads/main", var.github_owner, var.github_owner_id, var.github_repo_id)
  oidc_provider_arn = var.existing_oidc_provider_arn != "" ? var.existing_oidc_provider_arn : aws_iam_openid_connect_provider.github[0].arn
}
EOF

Why use GitHub's numeric IDs?

The trust subject includes the repository owner and repository numeric IDs. A renamed or replaced repository cannot inherit this deployment role.

Use the tab that matches the OIDC provider check you completed in Step 1.

Create a provider

  • Create terraform.tfvars with an empty existing-provider value by running:
cat > terraform.tfvars <<'EOF'
github_owner              = "[[GITHUB_OWNER="your GitHub username"]]"
github_owner_id           = "[[GITHUB_OWNER_ID="your GitHub numeric owner ID"]]"
github_repo_id            = "[[GITHUB_REPO_ID="your GitHub numeric repository ID"]]"
existing_oidc_provider_arn = ""
EOF

Reuse the provider

  • Create terraform.tfvars with the provider ARN you recorded by running:
cat > terraform.tfvars <<'EOF'
github_owner              = "[[GITHUB_OWNER="your GitHub username"]]"
github_owner_id           = "[[GITHUB_OWNER_ID="your GitHub numeric owner ID"]]"
github_repo_id            = "[[GITHUB_REPO_ID="your GitHub numeric repository ID"]]"
existing_oidc_provider_arn = "[[EXISTING_OIDC_PROVIDER_ARN="your existing GitHub OIDC provider ARN"]]"
EOF
  • Confirm that the three workspace files exist by running:
ls -1 main.tf variables.tf terraform.tfvars

You should see all three filenames. Your Terraform inputs now match the repository created in Step 1.

Define the AWS resources

The deployment needs one federated GitHub role and two service roles. It also needs one in-Region image repository for each regional service.

  • Create oidc.tf with the conditional GitHub provider by running:
cat > oidc.tf <<'EOF'
# Create the account-level provider only when one does not exist.
resource "aws_iam_openid_connect_provider" "github" {
  count = var.existing_oidc_provider_arn == "" ? 1 : 0

  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}
EOF

Why reuse an existing provider?

An AWS account can share one GitHub OIDC provider across repositories. Reusing its ARN prevents Terraform from trying to create a duplicate account-level provider.

  • Create github-role.tf with the repository-specific trust policy by running:
cat > github-role.tf <<'EOF'
# Allow only this repository's main branch to request credentials.
resource "aws_iam_role" "github_actions" {
  name = "github-actions-ecs-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRoleWithWebIdentity"
      Principal = { Federated = local.oidc_provider_arn }
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          "token.actions.githubusercontent.com:sub" = local.github_subject
        }
      }
    }]
  })
}
EOF

What protects this role?

The audience condition restricts tokens to AWS Security Token Service. The subject condition restricts access to the recorded repository and its main branch.

  • Create execution-role.tf with the ECS task execution role by running:
cat > execution-role.tf <<'EOF'
# Let ECS tasks pull images and publish logs.
resource "aws_iam_role" "task_execution" {
  name = "ecsTaskExecutionRole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "task_execution" {
  role       = aws_iam_role.task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
EOF

What does the execution role do?

Amazon ECS tasks use this role to pull the private container image and publish runtime logs. The AWS-managed policy supplies those execution-time permissions.

  • Create infrastructure-role.tf with the Express Mode infrastructure role by running:
cat > infrastructure-role.tf <<'EOF'
# Let ECS manage Express Mode infrastructure.
resource "aws_iam_role" "infrastructure" {
  name = "ecsInfrastructureRoleForExpressServices"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Service = "ecs.amazonaws.com" }
      Action = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "infrastructure" {
  role       = aws_iam_role.infrastructure.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices"
}
EOF

What does the infrastructure role do?

Express Mode uses this role to manage its load balancer, security groups, certificate, monitoring, and auto scaling resources.

  • Create repositories.tf with one ECR repository in each Region by running:
cat > repositories.tf <<'EOF'
# Keep each service's image in its own Region.
resource "aws_ecr_repository" "east" {
  name         = "multi-region-app-east"
  force_delete = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecr_repository" "west" {
  provider     = aws.west
  name         = "multi-region-app-west"
  force_delete = true

  image_scanning_configuration {
    scan_on_push = true
  }
}
EOF

Why create two repositories?

Each deployment pushes to an ECR repository in the same Region as its service. The regional paths remain independent and visible in the deployment matrix.

  • Create ecr-permissions.tf with the image push permissions by running:
cat > ecr-permissions.tf <<'EOF'
# Let the GitHub role authenticate and push to both repositories.
resource "aws_iam_role_policy" "github_ecr" {
  name = "push-regional-images"
  role = aws_iam_role.github_actions.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["ecr:GetAuthorizationToken"]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:CompleteLayerUpload", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"]
        Resource = [aws_ecr_repository.east.arn, aws_ecr_repository.west.arn]
      }
    ]
  })
}
EOF

How are the ECR permissions scoped?

The authorization token action applies to the registry. Every image upload action is restricted to the two repositories managed by this configuration.

  • Create ecs-permissions.tf with the Express Mode deployment permissions by running:
cat > ecs-permissions.tf <<'EOF'
# Let the GitHub role create or update Express Mode services.
resource "aws_iam_role_policy" "github_ecs" {
  name = "deploy-express-services"
  role = aws_iam_role.github_actions.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["ecs:CreateCluster", "ecs:RegisterTaskDefinition", "ecs:CreateExpressGatewayService", "ecs:UpdateExpressGatewayService", "ecs:DescribeExpressGatewayService", "ecs:DescribeClusters", "ecs:DescribeServices", "ecs:ListServiceDeployments", "ecs:DescribeServiceDeployments", "ecs:TagResource", "ecs:UntagResource"]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = ["iam:PassRole"]
        Resource = [aws_iam_role.task_execution.arn, aws_iam_role.infrastructure.arn]
      }
    ]
  })
}
EOF

Why does GitHub need PassRole?

The deployment action gives the two service roles to Amazon ECS. The GitHub role can pass only the execution and infrastructure roles created here.

  • Create outputs.tf with the values required by GitHub Actions by running:
cat > outputs.tf <<'EOF'
# Print the non-secret values used by the deployment workflow.
output "aws_role_arn" {
  value = aws_iam_role.github_actions.arn
}
output "ecs_execution_role_arn" {
  value = aws_iam_role.task_execution.arn
}
output "ecs_infrastructure_role_arn" {
  value = aws_iam_role.infrastructure.arn
}
output "ecr_east_uri" {
  value = aws_ecr_repository.east.repository_url
}
output "ecr_west_uri" {
  value = aws_ecr_repository.west.repository_url
}
EOF
  • Format the Terraform files by running:
terraform fmt
  • Initialize the AWS provider by running:
terraform init

The first initialization can take a minute while Terraform downloads the AWS provider. You should see Terraform has been successfully initialized! when it finishes.

  • Check the configuration before creating resources by running:
terraform validate

You should see Success! The configuration is valid..

Does Terraform report an error?

Run ls -1 and confirm that every .tf file is in multi-region-terraform. The first error usually names the file and line that needs attention.

Help me diagnose my Terraform validation error.

Apply the Terraform plan

This apply creates IAM and ECR resources but does not start the billed Express Mode services. You will review the plan before Terraform changes the account.

  • Create a saved Terraform plan by running:
terraform plan -out=tfplan

Before you apply the plan, do you expect Terraform to create a GitHub OIDC provider or reuse the ARN supplied in terraform.tfvars?

  • Apply the reviewed plan by running:
terraform apply tfplan

You should see Apply complete! followed by the number of resources created. Your deployment role and both regional repositories now exist.

  • Print the values needed by GitHub by running:
terraform output
  • Record aws_role_arn as your GitHub deployment role ARN.
  • Record ecs_execution_role_arn as your ECS task execution role ARN.
  • Record ecs_infrastructure_role_arn as your ECS infrastructure role ARN.
  • Record ecr_east_uri as your east ECR repository URI.
  • Record ecr_west_uri as your west ECR repository URI.
  • Open the Settings tab in your GitHub repository.
  • Choose Secrets and variables in the sidebar.
  • Choose Actions.
  • Open the Variables tab.
  • Choose New repository variable.
  • Create AWS_ROLE_ARN with the recorded deployment role ARN.
  • Create ECS_EXECUTION_ROLE_ARN with the recorded execution role ARN.
  • Create ECS_INFRASTRUCTURE_ROLE_ARN with the recorded infrastructure role ARN.
  • Create ECR_EAST_URI with the recorded east repository URI.
  • Create ECR_WEST_URI with the recorded west repository URI.
  • Confirm that the Variables tab lists all five names.

That is the missing foundation restored. GitHub can now assume a real IAM role and push images to repositories that already exist.

Did the Terraform apply fail?

If AWS reports that the GitHub OIDC provider already exists, return to terraform.tfvars and enter the existing provider ARN from Step 1. Run the plan and apply commands again.

Help me diagnose the Terraform apply.

Your AWS foundation is ready and GitHub has its non-secret configuration values. Next, you will deploy the tested container to the east Region.

Deploy the First Regional Service

Terraform has created the deployment role, service roles, and both regional repositories. Next, you will connect the tested container to that secure AWS foundation.

The first deployment targets us-east-1 only. This smaller starting point makes the workflow easier to verify before you add a second AWS Region.

In this step, get ready to:
  • Create the east Region deployment workflow.
  • Watch GitHub build and deploy the app.
  • Open the first live HTTPS endpoint.
Create the east deployment workflow

This workflow turns every push to main into a container build and an Amazon ECS Express Mode deployment. GitHub receives short-lived AWS credentials through OIDC for each run.

  • Choose Add file above the GitHub repository file list.
  • Choose Create new file.
  • Enter .github/workflows/deploy.yml in the filename field.
  • Create the east deployment by pasting this workflow into the editor:
name: Deploy application
on: {push: {branches: [main]}, workflow_dispatch: {}}
permissions: {id-token: write, contents: read}
jobs:
  deploy:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: aws-actions/configure-aws-credentials@v6.2.4
        with:
          aws-region: us-east-1
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
      - uses: aws-actions/amazon-ecr-login@v2.1.7
      - name: Build and push the east image
        env:
          IMAGE_URI: ${{ vars.ECR_EAST_URI }}:${{ github.sha }}
        run: |
          docker build --tag $IMAGE_URI .
          docker push $IMAGE_URI
      - uses: aws-actions/amazon-ecs-deploy-express-service@v1.2.2
        with: {service-name: multi-region-app-east, image: '${{ vars.ECR_EAST_URI }}:${{ github.sha }}', execution-role-arn: '${{ vars.ECS_EXECUTION_ROLE_ARN }}', infrastructure-role-arn: '${{ vars.ECS_INFRASTRUCTURE_ROLE_ARN }}', container-port: 8080, environment-variables: '[{"name":"AWS_REGION","value":"us-east-1"},{"name":"APP_VERSION","value":"${{ github.sha }}"}]', cpu: '256', memory: '512', min-task-count: 1, max-task-count: 2, health-check-path: /health}

What does this workflow do?

  • The credential step exchanges GitHub's OIDC token for temporary AWS access through AWS_ROLE_ARN.
  • The ECR login action authenticates Docker without an inline password command.
  • The build step pushes the commit-tagged image to the repository stored in ECR_EAST_URI.
  • The final action deploys that image with the Terraform-managed ECS role ARNs.

Committing this workflow starts usage-based AWS resources. The cleanup section removes the Fargate service, load balancer, monitoring resources, and container image when you finish.

  • Choose Commit changes.
  • Enter Deploy the east service as the commit message.
  • Choose Commit changes in the confirmation window.

That commit immediately starts the workflow. The first deployment usually takes three to five minutes while AWS creates the load balancer, service, certificate, and networking resources.

Watch the deployment
  • Open the Actions tab in GitHub.
  • Choose the newest Deploy application run.
  • Open the deploy job.
  • Wait until every step shows a green check.

That is your first automatic AWS deployment complete. GitHub built the image, stored it in the east Region, and created the running service.

Did the deployment fail?

A new IAM role can take about one minute to become available. If authentication fails, confirm that AWS_ROLE_ARN matches the Terraform output before choosing Re-run jobs.

Open the failed step and copy only its error text. Help me diagnose the east deployment.

Open the east endpoint

Before you open the service URL, do you expect the page to report local or us-east-1?

  • Set the AWS console Region selector to us-east-1.
  • Find Amazon ECS using the console search bar.
  • Open Amazon ECS from the search results.
  • Choose Express mode in the navigation pane.
  • Open multi-region-app-east.
  • Wait until the service reaches its completed state.
  • Open the Application URL in a new browser tab.
  • Record the URL as your east application endpoint.

You will see a message starting with Multi-Region App | Region: us-east-1 | Version: and ending with the deployed commit SHA. That message proves the public endpoint reached the image deployed by GitHub.

Does the endpoint fail to load?

Confirm that the GitHub workflow finished before reopening the URL. In Amazon ECS, check that the service reports one running task and that the latest deployment completed.

Help me troubleshoot the east endpoint.

Your first regional service is live. Next, you will teach the same workflow to deploy an independent copy in the west Region.

Deploy the Second Regional Service

Your east Region service is live and came entirely from a GitHub workflow. The application still depends on one Region until the same workflow creates a second copy.

You will add a small deployment matrix for us-east-1 and us-west-2. GitHub will then run one matching deployment job for each Region.

In this step, get ready to:
  • Expand the workflow to two AWS Regions.
  • Watch both regional jobs finish.
  • Compare the two live endpoints.
Add the regional matrix

A matrix repeats one job with different values. This keeps the two deployment paths together instead of duplicating an entire workflow.

  • Open .github/workflows/deploy.yml in the GitHub file browser.
  • Choose the pencil icon above the file to edit it.
  • Select all of the existing workflow content.
  • Replace it with this two-Region workflow:
name: Deploy application
on: {push: {branches: [main]}, workflow_dispatch: {}}
permissions: {id-token: write, contents: read}
jobs:
  deploy:
    name: Deploy to ${{ matrix.region }}
    strategy:
      matrix:
        include:
          - {region: us-east-1, suffix: east, repository_uri: '${{ vars.ECR_EAST_URI }}'}
          - {region: us-west-2, suffix: west, repository_uri: '${{ vars.ECR_WEST_URI }}'}
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: aws-actions/configure-aws-credentials@v6.2.4
        with: {aws-region: '${{ matrix.region }}', role-to-assume: '${{ vars.AWS_ROLE_ARN }}'}
      - uses: aws-actions/amazon-ecr-login@v2.1.7
      - name: Build and push the regional image
        env: {IMAGE_URI: '${{ matrix.repository_uri }}:${{ github.sha }}'}
        run: |
          docker build --tag $IMAGE_URI .
          docker push $IMAGE_URI
      - uses: aws-actions/amazon-ecs-deploy-express-service@v1.2.2
        with: {service-name: 'multi-region-app-${{ matrix.suffix }}', image: '${{ matrix.repository_uri }}:${{ github.sha }}', execution-role-arn: '${{ vars.ECS_EXECUTION_ROLE_ARN }}', infrastructure-role-arn: '${{ vars.ECS_INFRASTRUCTURE_ROLE_ARN }}', container-port: 8080, environment-variables: '[{"name":"AWS_REGION","value":"${{ matrix.region }}"},{"name":"APP_VERSION","value":"${{ github.sha }}"}]', cpu: '256', memory: '512', min-task-count: 1, max-task-count: 2, health-check-path: /health}

How does the matrix work?

  • The matrix creates one job for the east values and one for the west values.
  • Each job uses AWS_ROLE_ARN to request temporary AWS credentials.
  • The ECR login action authenticates Docker in the matrix Region.
  • Each job tags the image with the same commit SHA and pushes it to its Terraform-created repository URI.

This commit creates a second set of usage-based Fargate, load-balancing, monitoring, and data-transfer resources. The cleanup section removes both regional services when you finish.

  • Choose Commit changes.
  • Enter Deploy to both Regions as the commit message.
  • Choose Commit changes in the confirmation window.

✔️ My matrix is ready

Your workflow now contains two regional matrix entries.

ⓧ I'd like to double-check

name: Deploy application
on: {push: {branches: [main]}, workflow_dispatch: {}}
permissions: {id-token: write, contents: read}
jobs:
  deploy:
    name: Deploy to ${{ matrix.region }}
    strategy:
      matrix:
        include:
          - {region: us-east-1, suffix: east, repository_uri: '${{ vars.ECR_EAST_URI }}'}
          - {region: us-west-2, suffix: west, repository_uri: '${{ vars.ECR_WEST_URI }}'}
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: aws-actions/configure-aws-credentials@v6.2.4
        with: {aws-region: '${{ matrix.region }}', role-to-assume: '${{ vars.AWS_ROLE_ARN }}'}
      - uses: aws-actions/amazon-ecr-login@v2.1.7
      - name: Build and push the regional image
        env: {IMAGE_URI: '${{ matrix.repository_uri }}:${{ github.sha }}'}
        run: |
          docker build --tag $IMAGE_URI .
          docker push $IMAGE_URI
      - uses: aws-actions/amazon-ecs-deploy-express-service@v1.2.2
        with: {service-name: 'multi-region-app-${{ matrix.suffix }}', image: '${{ matrix.repository_uri }}:${{ github.sha }}', execution-role-arn: '${{ vars.ECS_EXECUTION_ROLE_ARN }}', infrastructure-role-arn: '${{ vars.ECS_INFRASTRUCTURE_ROLE_ARN }}', container-port: 8080, environment-variables: '[{"name":"AWS_REGION","value":"${{ matrix.region }}"},{"name":"APP_VERSION","value":"${{ github.sha }}"}]', cpu: '256', memory: '512', min-task-count: 1, max-task-count: 2, health-check-path: /health}
Watch both regional jobs
  • Open the Actions tab.
  • Choose the newest Deploy application run.
  • Confirm that the run contains Deploy to us-east-1 and Deploy to us-west-2.
  • Wait until both jobs show green checks.

Expect the west job to take three to five minutes while Express Mode creates its regional resources. The east job updates the service that is already running.

Both green jobs confirm that one GitHub commit reached two AWS Regions. The same workflow now keeps their deployment process synchronized.

Did one regional job fail?

Open the failed regional job and identify whether the problem happened during authentication, image push, or service deployment. Check that the Region in the job name matches the ECR repository suffix used by that job.

Help me diagnose the regional job.

Compare the regional endpoints

Before you open the west URL, do you expect its address or its Region message to match the east endpoint?

  • Set the AWS console Region selector to us-west-2.
  • Open Amazon ECS from the console search results.
  • Choose Express mode in the navigation pane.
  • Open multi-region-app-west.
  • Open the Application URL in a new browser window.
  • Record it as your west application endpoint.
  • Place the east and west application windows side by side.
  • Confirm that one page reports us-east-1 while the other reports us-west-2.

You now have the same app running through two different HTTPS URLs. Each service can answer independently from its own AWS Region.

What does failover readiness mean?

These two services are ready to become failover origins, but they do not switch traffic automatically yet. A later CloudFront origin group can provide the routing and failover layer.

Both regional copies are live. Next, you will change the app once and watch GitHub update both services automatically.

Secret mission

Compare Regional Response Times

Measure each regional endpoint from your browser with caching disabled. Compare three document-request durations per Region using their medians. Document the timing result without treating it as proof of failover readiness.

Clean Up Your Resources

Clean Up Your Resources

Your two live Amazon ECS Express Mode services can continue generating AWS charges. Decide whether to keep them running or delete them now, since Express Mode has no paused state.

Cost warning

Express Mode has no additional service fee. Its Fargate, load-balancing, logging, and data-transfer resources use usage-based pricing.

Delete both services when you finish unless you plan to continue testing them.

Resources you used:

  • The multi-region-app-east Express Mode service in us-east-1 and its managed resources.
  • The multi-region-app-west Express Mode service in us-west-2 and its managed resources.
  • The Terraform-managed ECR repositories and three IAM roles.
  • The GitHub OIDC provider if Terraform created it for this project.

Keep everything running

No action is needed. Choose this if you are still comparing the regional endpoints.

  • Leave multi-region-app-east running in us-east-1.
  • Leave multi-region-app-west running in us-west-2.
  • Expect usage-based charges to continue while the services remain active.

Pause - I'll come back to this later

Express Mode has no paused state. Delete both services to stop their runtime charges, then recreate them from this project when you return.

Remove the East Service
  • Set the AWS Region selector to us-east-1.
  • Open Amazon ECS from the console search bar.
  • Choose Clusters in the navigation pane.
  • Choose the default cluster.
  • Choose the Services tab.
  • Select multi-region-app-east.
  • Choose Delete.
  • Enter delete in the confirmation field.
  • Choose Delete in the confirmation dialog.
  • Wait until the service and its service-specific managed resources are removed.
Remove the West Service
  • Set the AWS Region selector to us-west-2.
  • Open the default cluster in Amazon ECS.
  • Choose the Services tab.
  • Select multi-region-app-west.
  • Choose Delete.
  • Enter delete in the confirmation field.
  • Choose Delete in the confirmation dialog.
  • Wait until the service and its service-specific managed resources are removed.

Delete - I don't want to use this again

Deleting the services and Terraform foundation is permanent. Terraform leaves a reused GitHub OIDC provider untouched because it is outside this project's state.

Delete the East Service
  • Set the AWS Region selector to us-east-1.
  • Open Amazon ECS from the console search bar.
  • Choose Clusters in the navigation pane.
  • Choose the default cluster.
  • Choose the Services tab.
  • Select multi-region-app-east.
  • Choose Delete.
  • Enter delete in the confirmation field.
  • Choose Delete in the confirmation dialog.
  • Wait until the service and its service-specific managed resources are removed.
Delete the West Service
  • Set the AWS Region selector to us-west-2.
  • Open the default cluster in Amazon ECS.
  • Choose the Services tab.
  • Select multi-region-app-west.
  • Choose Delete.
  • Enter delete in the confirmation field.
  • Choose Delete in the confirmation dialog.
  • Confirm that the service and its service-specific managed resources are removed.

Nice Work!

Nice Work!

You made it! You deployed the same web app through Amazon ECS Express Mode in two independent AWS Regions and opened both public endpoints.

You've learned how to:

  • Confirm default networking in both target Regions before creating runtime resources.
  • Deploy a public container image through ECS Express Mode and open its HTTPS application URL.
  • Run the same web app in two Regions and verify the independent services through their distinct endpoints.
  • Secret Mission: Compare three uncached response-time measurements for each regional endpoint and identify the lower median from your location.

Ready to quiz yourself?