Deploy a React + Express App to AWS with ECS

Deploy a React + Express App to AWS with ECS

Published September 15, 2025 Last updated February 20, 2026
tutorial aws ecs docker deployment

Deploy a React + Express App to AWS with ECS

A React frontend and Express backend is one of the most common web app setups, but deploying it to AWS involves two very different targets. The React app is a static build that belongs on S3 behind CloudFront. The Express server is a container that runs on ECS. This guide walks through a single DevRamps pipeline that builds and deploys both, with staging and production stages targeting separate AWS accounts.

Project Structure

The pipeline expects your repo to look roughly like this:

my-app/
├── .devramps/
│   └── my-app/
│       └── pipeline.yaml
├── services/
│   ├── backend/
│   │   ├── src/
│   │   ├── package.json
│   │   └── Dockerfile
│   └── frontend/
│       ├── src/
│       ├── public/
│       └── package.json
└── infrastructure/
    ├── main.tf
    └── outputs.tf

The infrastructure/ directory contains Terraform that provisions the ECS cluster, S3 bucket, CloudFront distribution, and everything else both services need. Keeping it in one place means your frontend and backend infrastructure stay in sync.

The Pipeline Configuration

Here’s the full pipeline.yaml. It builds both artifacts, then deploys them in parallel within each stage:

version: "1.0.0"

pipeline:
  cloud_provider: AWS
  pipeline_updates_require_approval: ALWAYS

  stages:
    - name: staging
      account_id: "111111111111"
      region: us-east-1
      deployment_time_window: NONE
      vars:
        env: staging
        api_url: https://api-staging.example.com

    - name: production
      account_id: "222222222222"
      region: us-east-1
      vars:
        env: production
        api_url: https://api.example.com

  artifacts:
    Backend Image:
      type: DEVRAMPS:DOCKER:BUILD
      architecture: "linux/amd64"
      host_size: "medium"
      rebuild_when_changed:
        - /services/backend
      params:
        dockerfile: /services/backend/Dockerfile
        build_root: /services/backend
        args:
          - NODE_ENV=production

    Frontend Bundle:
      type: DEVRAMPS:BUNDLE:BUILD
      per_stage: true
      architecture: "linux/amd64"
      host_size: "medium"
      dependencies: ["node.24"]
      rebuild_when_changed:
        - /services/frontend
      params:
        build_commands: |
          cd services/frontend
          REACT_APP_API_URL=${{ vars.api_url }} npm ci
          npm run build
          zip -r ../../frontend.zip build/
        file_path: /frontend.zip

  steps:
    - name: Synthesize Infrastructure
      id: infra
      type: DEVRAMPS:TERRAFORM:SYNTHESIZE
      params:
        requires_approval: DESTRUCTIVE_CHANGES_ONLY
        source: /infrastructure
        variables:
          environment: ${{ vars.env }}
          aws_account_id: ${{ stage.account_id }}

    - name: Deploy Backend
      type: DEVRAMPS:ECS:DEPLOY
      goes_after: ["Synthesize Infrastructure"]
      params:
        cluster_name: ${{ steps.infra.ecs_cluster_name }}
        service_name: ${{ steps.infra.backend_service_name }}
        reference_task_definition: ${{ steps.infra.backend_task_def_arn }}
        images:
          - container_name: backend
            image: "${{ stage.artifacts['Backend Image'].image_url }}"

    - name: Deploy Frontend
      type: DEVRAMPS:S3:UPLOAD
      goes_after: ["Synthesize Infrastructure"]
      params:
        source_s3_url: ${{ stage.artifacts['Frontend Bundle'].s3_url }}
        bucket: ${{ steps.infra.frontend_bucket }}
        decompress: true
        clean: true
        metadata:
          cache_control: "max-age=31536000, immutable"

    - name: Invalidate CDN
      type: DEVRAMPS:CLOUDFRONT:INVALIDATE
      goes_after: ["Deploy Frontend"]
      params:
        distribution_id: ${{ steps.infra.cloudfront_distribution_id }}
        paths: ["/*"]

    - name: Bake Period
      type: DEVRAMPS:APPROVAL:BAKE
      goes_after: ["Deploy Backend", "Invalidate CDN"]
      params:
        duration_minutes: 5

A few things to note. The backend and frontend deploy in parallel because they both list only Synthesize Infrastructure in their goes_after. The bake period waits for both to finish. And rebuild_when_changed means if you only touch frontend code, the backend image won’t rebuild (and vice versa).

Handling Stage-Specific Config

The React build needs different environment variables per stage. Your staging app hits api-staging.example.com; production hits api.example.com. The per_stage: true flag on the Frontend Bundle artifact handles this. It tells DevRamps to run a separate build for each stage, injecting that stage’s variables.

The relevant bit:

Frontend Bundle:
  type: DEVRAMPS:BUNDLE:BUILD
  per_stage: true          # ← separate build per stage
  params:
    build_commands: |
      REACT_APP_API_URL=${{ vars.api_url }} npm ci
      npm run build

Without per_stage, the bundle would build once and get reused across stages. That works for code that reads config at runtime, but React bakes environment variables into the bundle at build time. You need separate builds.

The Express backend doesn’t have this problem. It reads environment variables at runtime from the ECS task definition, so a single Docker image works across all stages.

The Deployment Flow

When you push to the tracked branch, here’s what happens:

Git Push
    │
    ▼
Build Artifacts (parallel)
├── Docker build → Backend Image → ECR
└── Bundle build → Frontend Bundle → S3
    │
    ▼
Stage: staging (account 111111111111)
├── Mirror artifacts to staging account
├── Terraform apply
├── Deploy Backend to ECS  ─┐
├── Deploy Frontend to S3  ─┤ (parallel)
├── Invalidate CloudFront   │
└── Bake 5 minutes ─────────┘
    │
    ▼
Stage: production (account 222222222222)
├── Mirror artifacts to production account
└── (same steps, production variables)

Artifact mirroring is automatic. DevRamps copies the Docker image from the CI/CD account’s ECR to each target account’s ECR, and does the same with the S3 bundle. You build once; it distributes.

Stages run sequentially. Staging has to pass (all steps succeed, bake period completes) before production begins. If the backend deploy fails in staging, production never sees it. For more on how this sequential model works as a safety net, see Deployment Rollback Strategies for AWS.

Terraform Outputs Wire Everything Together

The pipeline references values like ${{ steps.infra.ecs_cluster_name }} throughout. These come from Terraform outputs. Your infrastructure/outputs.tf needs to export everything the deployment steps reference:

output "ecs_cluster_name" {
  value = aws_ecs_cluster.main.name
}

output "backend_service_name" {
  value = aws_ecs_service.backend.name
}

output "backend_task_def_arn" {
  value = aws_ecs_task_definition.backend.arn
}

output "frontend_bucket" {
  value = aws_s3_bucket.frontend.id
}

output "cloudfront_distribution_id" {
  value = aws_cloudfront_distribution.frontend.id
}

This approach means your pipeline YAML never hardcodes resource names. If Terraform recreates a resource with a new name, the pipeline picks it up automatically on the next run.

Going Further

This setup deploys both services from a single pipeline, which works well when the frontend and backend release together. If your team starts shipping frontend and backend changes independently, you can split into separate pipelines in the same repo with their own triggers and deployment cadences.

You can also add ephemeral environments that spin up a complete copy of both services per pull request. Reviewers get a live URL to test against instead of checking out the branch locally.

For the full pipeline YAML reference, see the pipeline configuration docs. For bootstrapping your AWS accounts before the first deploy, see the CLI documentation.