Deploy a React + Express App to AWS with ECS
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.