Deploying a full-stack web application into production is often one of the most daunting steps for modern developers. You build a complete application locally, but when it comes to hosting it securely on cloud infrastructure with HTTPS, custom domains, and automated deployment pipelines, things can quickly get overwhelming.
In this post, I will walk you through how I built and deployed an end-to-end full-stack MERN application ("Little List")βfrom the frontend user interface and Express REST API to provisioning production AWS cloud infrastructure with Terraform and automating deployments with Keyless GitHub Actions OIDC.
Whether you are a beginner looking to understand full-stack architecture & cloud deployment or an experienced engineer reviewing multi-environment Terraform design, this guide covers the entire end-to-end workflow!
"Little List" is a full-stack personal productivity and management application built from scratch:
Before diving in, here is what you will need if you want to replicate this deployment architecture:
>= 1.16.2).aws configure).ideategudy.tech).Before writing code, let us look at the high-level architecture diagram of what we are provisioning on AWS:
ββββββββββββββββββββββββ
β GitHub Actions β
ββββββββββββ¬ββββββββββββ
β
βββββββββββββββββββ΄ββββββββββββββββββ
β β
Frontend deploy Backend deploy
β β
βΌ βΌ
βββββββββββββββββ βββββββββββββββββ
β S3 β β ECR β
β React build β β Docker image β
βββββββββ¬ββββββββ βββββββββ¬ββββββββ
β β
β β docker pull
β βΌ
β βββββββββββββββββ
β β EC2 β
β β Express/Dockerβ
β βββββββββ¬ββββββββ
β β
β βββββββββ΄ββββββββ
β β ASG β
β β EC2 fleet β
β βββββββββ¬ββββββββ
β β
βΌ βββββββββΌββββββββ
βββββββββββββββββ β ALB β
β Route 53 DNS β β Load Balancer β
βββββββββ¬ββββββββ βββββββββ²ββββββββ
β β
βΌ β
βββββββββββββββββ β
β CloudFront ββββββββββββββββββββββββββββββ
β CDN (HTTPS) β Path-based routing (/api/*)
βββββββββββββββββdev.ideategudy.tech) and routes client requests directly to CloudFront via A-record Aliases.dist/ build files) privately.Here is how the project files are structured:
.
βββ client/ # Vite + React SPA Frontend
βββ server/ # Node.js + Express API Backend (Dockerized)
βββ infra/ # Modular Terraform Infrastructure
β βββ main.tf # Root Terraform Module
β βββ variables.tf # Root Variables & Configuration Defaults
β βββ outputs.tf # Root Terraform Outputs
β βββ versions.tf # AWS & Provider Requirements
β βββ dev.tfvars.example # Development Environment Settings
β βββ prod.tfvars.example # Production Environment Settings
β βββ modules/
β βββ vpc/ # VPC, Public Subnets (2 AZs), IGW, Route Tables
β βββ backend/ # ECR, ALB, ASG, Launch Template, EC2 IAM Role
β βββ frontend/ # S3 Bucket, CloudFront OAC, CDN Distribution, Route53/ACM
β βββ github_oidc/ # GitHub OIDC Identity Provider & IAM Role
βββ .github/
βββ workflows/
βββ deploy.yml # Parallel CI/CD Workflow (Frontend & Backend)One common challenge with SPA + API setups is CORS and handling multiple URLs (api.domain.com vs domain.com). We solved this by serving both frontend and backend under the exact same domain (https://dev.ideategudy.tech) via CloudFront Path-Based Routing:
| Request Path Pattern | Origin Target | Description |
|---|---|---|
/ or /assets/* | s3-frontend | Serves static React SPA files |
/api/*, /s/*, /healthz | alb-api | Forwards API requests directly to the EC2/Express backend |
π‘ Pro-Tip: When forwarding requests through CloudFront to an ALB, ensure your CloudFront cache behavior explicitly forwards the
Authorization,Accept,Content-Type,Origin, andRefererheaders! Otherwise, CloudFront strips theAuthorization: Bearer <jwt>header by default, leading to401 Unauthorizederrors on authenticated routes.
Instead of creating long-lived IAM user keys (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) and pasting them into GitHub Secrets, we configured an AWS IAM OIDC Identity Provider.
GitHub Actions assumes a temporal AWS IAM Role dynamically per pipeline run using OpenID Connect authentication. If your pipeline is compromised, there are no static credentials to leak!
dev.ideategudy.tech) & SSL ConfigurationPrimary infrastructure resources (VPC, EC2, ASG, ALB, ECR, S3) are deployed in eu-north-1 (Stockholm). HTTPS is terminated at the CloudFront CDN level using AWS Certificate Manager (ACM) in us-east-1 (required by CloudFront for global edge distributions).
dev.ideategudy.tech)?Instead of pointing the root/apex domain (ideategudy.tech) directly to this environment, we configured a dedicated subdomain (dev.ideategudy.tech) for several key reasons:
dev vs prod): Using subdomains allows us to run isolated development (dev.ideategudy.tech) and production (ideategudy.tech or app.ideategudy.tech) infrastructure in separate Terraform workspaces without DNS conflicts.NS records for dev.ideategudy.tech in Namecheap, we delegate only the dev subdomain DNS resolution to AWS Route 53 while keeping the parent apex domain (ideategudy.tech) managed at Namecheap.dev.ideategudy.tech) to AWS:infra/dev.tfvars:
domain_name = "dev.ideategudy.tech"
create_route53_zone = trueterraform apply --var-file="dev.tfvars".route53_nameservers = [
"ns-1143.awsdns-14.org",
"ns-1698.awsdns-20.co.uk",
"ns-263.awsdns-32.com",
"ns-847.awsdns-41.net"
]ideategudy.tech -> Advanced DNS -> Add 4 NS Records for Host dev pointing to the Route 53 Nameservers.Figure: Configured NS records in Namecheap delegating
dev.ideategudy.techto AWS Route 53.
https://dev.ideategudy.tech!deploy.yml)When code is pushed to main, GitHub Actions triggers .github/workflows/deploy.yml which executes two parallel jobs:
π‘ Note for Beginners: You do not need to manually build the React frontend or Docker container on your local computer before pushing. GitHub Actions automatically compiles the React production bundle and builds/pushes the Docker image in the cloud during the pipeline run!
deploy-frontend:
aws s3 sync).aws cloudfront create-invalidation).deploy-backend:
/mern-deploy/dev/...).RunShellScript.cd infra
# Copy example variables
cp dev.tfvars.example dev.tfvars
# Edit values in dev.tfvars (domain_name, github_org, github_repo)
terraform init
terraform plan --var-file="dev.tfvars"
terraform apply --var-file="dev.tfvars" --auto-approveFigure 1: Successful
terraform applyshowing outputs for S3, CloudFront, ECR, and ALB.
In your GitHub repo under Settings -> Secrets and variables -> Actions:
π‘ Tip: You can retrieve all your Terraform output values anytime by running
terraform outputinside theinfra/folder.
Figure 2: GitHub Actions repository variables and secrets configured for OIDC deployment.
Secrets:
MONGODB_URI: MongoDB connection stringACCESS_TOKEN_SECRET: JWT access secretREFRESH_TOKEN_SECRET: JWT refresh secretVariables:
AWS_ROLE_ARN: Output github_actions_role_arnAWS_REGION: eu-north-1ECR_REPOSITORY: Output ecr_repository_nameFRONTEND_BUCKET: Output frontend_bucketCLOUDFRONT_DISTRIBUTION_ID: Output cloudfront_distribution_idAPI_ASG_NAME: Output api_asg_nameSSM_PREFIX: Output ssm_prefixCLIENT_URL: https://dev.ideategudy.techSERVER_URL: https://dev.ideategudy.techPush your updates to the repository:
git add .
git commit -m "Deploy production architecture to AWS"
git push origin mainFigure 3: Parallel CI/CD execution for
deploy-frontendanddeploy-backendjobs.
Figure 4: The live full-stack application running on
https://dev.ideategudy.techserved securely over CloudFront HTTPS.
Choosing this cloud setup over monolithic single-server hosting provides several high-value production advantages:
π Zero-Downtime & Ultra-Fast Global Delivery: By hosting the React SPA on Amazon S3 + CloudFront, static assets are cached globally at edge locations close to users. CloudFront delivers lightning-fast page loads while shielding your backend EC2 instances from static file requests.
π° Extreme Cost Optimization (Free Tier Friendly):
π Enterprise-Grade Keyless Security (OIDC):
By avoiding permanent AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY credentials in GitHub Secrets, your deployment security posture is greatly hardened. GitHub Actions requests temporary, short-lived IAM credentials via OIDC scoped strictly to the resources it needs.
π‘οΈ High Availability & Auto-Healing: The Application Load Balancer (ALB) and Auto Scaling Group (ASG) automatically monitor EC2 instance health. If an instance crashes or fails health checks, ASG automatically terminates it and launches a fresh, containerized replacement instance seamlessly.
π¦ Modular & Multi-Environment Ready:
The modular Terraform layout (vpc, backend, frontend, github_oidc) makes it effortless to spin up separate dev, staging, and production environments in minutes with zero manual AWS Console clicking.
No cloud architecture is silver bullet. Understanding the trade-offs helps you decide when to choose this pattern over alternatives:
Initial Setup Complexity vs. Single-Server EC2:
modules/) abstracts away this complexity so it only needs to be written once.Cold Start / Provisioning Time for New EC2 Instances:
api_min_size = 1 ensures at least one instance is always warm and ready. For even faster scaling, custom pre-built AMIs (via Packer) can be used.CloudFront Cache Invalidation Lag:
/*), which takes ~30β60 seconds to propagate across all edge locations worldwide.aws cloudfront create-invalidation) so developers don't have to trigger it manually.Stateful Session Management (Sticky Sessions):
Building cloud infrastructure using modular Terraform modules and automated keyless CI/CD pipelines transforms complex AWS operations into predictable, repeatable, and maintainable software engineering workflows.
By decoupling the architecture into public VPC subnets, S3/CloudFront SPA hosting, and auto-scaling EC2 container fleets, the application stays fast, cost-optimized, and resilient.
Looking for a simpler, single-server EC2 deployment pattern? Check out the companion article:
This guide covers deploying the full-stack MERN application on a single AWS EC2 instance using Docker Compose, Nginx reverse proxy, Certbot (Let's Encrypt SSL), and SSH-based GitHub Actions CI/CD.
If you found this guide helpful or have any questions about AWS, Terraform, Docker, or MERN stack architecture, let us connect!
Drop a reaction on Dev.to and feel free to star the GitHub repository! Happy coding! π