Building a full-stack web application from scratch is an exciting journeyβfrom writing clean React components and Express REST APIs to persisting data in MongoDB. However, taking that application from your local machine and deploying it securely to production with HTTPS, custom domains, and automated CI/CD can often feel daunting.
In this tutorial, I will walk you through how I built an end-to-end full-stack MERN application and deployed it to AWS EC2 using Terraform (Infrastructure as Code), Docker Compose, Nginx Reverse Proxy, Let's Encrypt (Certbot) for free HTTPS, and GitHub Actions for push-to-deploy CI/CD.
Whether you're a beginner learning full-stack development and DevOps or a developer looking for a reproducible cloud deployment pattern, this guide covers the entire end-to-end workflow! π οΈ
This is a complete full-stack personal diary & management application built from scratch using the MERN stack:
Before we begin, ensure you have the following installed and set up on your machine:
aws configure).Here is how all the moving parts work together once deployed on AWS:
80 (HTTP) and 443 (HTTPS). It serves the production React build and acts as a reverse proxy, forwarding /api/ traffic to the backend server.port 3000). It is not directly exposed to the public internet, adding a layer of security.port 27017) using Docker named volumes.Here is the clean layout of our project repository:
First, verify that your multi-container application runs smoothly on your local machine using Docker Compose:
π‘ How Local vs Production Works: Creating
docker-compose.override.ymltells Docker to usenginx.local.confso your app runs onhttp://localhostwithout expecting SSL certificates. Sincedocker-compose.override.ymlis in.gitignore, it will not be pushed to EC2. On EC2, Docker Compose automatically falls back tonginx.confwith full Let's Encrypt HTTPS support!
You can test your application locally at:
http://localhosthttp://localhost/api/auth/http://localhost/healthzFigure 1: Docker Desktop showing all three containers (
diary-mongo,diary-server, anddiary-client) running green and healthy.
Figure 2: The full-stack application running live in browser on
http://localhost.
Instead of manually clicking through the AWS Console, we use Terraform to provision an EC2 instance, an Elastic IP (static IP), Security Groups (firewall rules), and install Docker via automated user_data scripts.
Make sure your AWS CLI is authenticated and generate an AWS EC2 Key Pair (e.g. ann_notch or diary-app-key).
# Navigate to terraform directory
cd terraform
# Create your tfvars configuration file
Edit your terraform.tfvars:
Initialize and apply the Terraform configuration:
terraform init
terraform apply -auto-approveOnce complete, Terraform will output your server_public_ip (Elastic IP) and domain_name. Keep these outputs handy!
To make your application accessible at a real domain (e.g., ideategudy.tech) and issue SSL certificates:
@ $\rightarrow$ Value <YOUR_SERVER_PUBLIC_IP>.www $\rightarrow$ Value ideategudy.tech.nslookup ideategudy.tech 8.8.8.8To enable seamless, zero-downtime CI/CD deployment on every code push, navigate to your GitHub Repository: Settings > Secrets and variables > Actions
π‘ Tip for Beginners: You can retrieve all your Terraform output values (like your EC2 Public IP) anytime by running
terraform outputinside theterraform/folder!
Add the following Repository Secrets:
| Secret Name | Value / Description |
|---|---|
EC2_HOST | Public Elastic IP address from Terraform output |
EC2_USERNAME | ubuntu |
EC2_SSH_KEY | Entire private SSH key contents (.pem file) |
MONGODB_URI | mongodb://mongo:27017/diarydb |
ACCESS_TOKEN_SECRET | Secure random secret string for JWT access |
REFRESH_TOKEN_SECRET | Secure random secret string for JWT refresh |
Add the following Repository Variable:
| Variable Name | Value / Description |
|---|---|
DOMAIN_NAME | ideategudy.tech (or your domain name) |
Our repository includes .github/workflows/deploy.yml.
π‘ Note for Beginners: You do not need to manually SSH into your server or build Docker containers on your local computer before pushing! When you push code to GitHub, GitHub Actions automatically connects to your EC2 server, pulls the latest code, issues SSL certificates, and restarts your application containers in the cloud.
When you push changes to the main branch:
.env).docker compose up -d --build to re-deploy updated containers with zero downtime.To trigger your deployment:
git
Head over to the Actions tab in GitHub to watch your deployment complete in real-time! β‘
Once the GitHub Actions workflow completes successfully, open your browser and navigate to your production domain:
https://ideategudy.techIf something doesn't work on your first try, don't worry! Here are the most common hiccups beginners face and how to fix them:
allowed_ssh_cidr = "0.0.0.0/0" in terraform.tfvars and check that EC2_HOST secret matches your Terraform output IP.nslookup yourdomain.com first to confirm your domain resolves to your EC2 IP before triggering the workflow.MONGODB_URI in GitHub Secrets is set to mongodb://mongo:27017/diarydb (using the container service name mongo).http_tokens = "required").gp3) encrypted by default.X-Frame-Options, X-Content-Type-Options, and server_tokens off.Choosing this containerized single-server deployment architecture provides several high-value advantages for developers and small-to-medium applications:
π° Cost Efficiency (AWS Free Tier Friendly):
By running Nginx, Node.js Express, and MongoDB inside a single containerized t3.micro EC2 instance with an Elastic IP, you eliminate the overhead of paying for managed load balancers (ALBs) or separate database instances (RDS) during initial rollout or MVP stages.
π Enhanced Security & Internal Networking:
port 3000) and MongoDB (port 27017) are isolated on internal Docker networks and not exposed directly to the internet.π Clean Dev-to-Prod Parity with Docker Compose:
The exact same docker-compose.yml file used for local development runs in production. This eliminates the classic "it works on my machine" bugs by ensuring identical runtimes across development and cloud environments.
π Automated Reproducibility (Infrastructure as Code):
With Terraform, your entire cloud infrastructure (EC2, VPC, Security Groups, Elastic IP) is defined declaratively as code. Re-creating or destroying your environment requires just a single command (terraform apply or terraform destroy).
β‘ Frictionless Push-to-Deploy CI/CD: The GitHub Actions SSH workflow completely automates the release cycle. Every code push automatically updates code, manages SSL certificates via Certbot, and builds updated containers with zero manual server maintenance required.
Congratulations! π₯³ You've built and deployed a production-ready, containerized full-stack application on AWS!
Here is a quick recap of what we accomplished:
Infrastructure-as-Code and containerization give you complete confidence that your deployment is reproducible, maintainable, and scalable.
Looking for an enterprise-grade, multi-environment AWS deployment pattern? Check out the companion article:
This guide covers an advanced deployment architecture featuring S3 static site hosting, CloudFront CDN, Application Load Balancers (ALB), EC2 Auto Scaling Groups (ASG), Amazon ECR, and keyless deployments with GitHub Actions OIDC.
If you found this guide helpful, hit the β€οΈ button, bookmark it for later, and feel free to connect with me!
Got questions or run into issues? Drop a comment belowβI'd love to help out! Happy coding! π
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS EC2 Instance β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Nginx Container (client) β β
User Browser β β Exposes Ports 80 / 443 β β
βββββββββββββββ β β β β
β HTTP / βββββββββΊβ β - Serves built React SPA frontend β β
β HTTPS β β β - Reverse proxies /api and /s/ to backend β β
βββββββββββββββ β ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Node.js Express Server (server) β β
β β Internal Port 3000 β β
β ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β MongoDB (mongo) β β
β β Internal Port 27017 β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ.
βββ client/ # React Frontend Application (Vite + Nginx Dockerfile)
β βββ Dockerfile # Multi-stage build (Node build -> Nginx runtime)
β βββ nginx.conf # Production Nginx reverse proxy configuration (SSL HTTPS)
β βββ nginx.local.conf # Local development Nginx configuration (HTTP localhost)
βββ server/ # Node.js Express Backend API
β βββ Dockerfile # Lightweight Node 22 Alpine container build
βββ terraform/ # Terraform IaC Infrastructure Code
β βββ main.tf # Root Terraform entry point
β βββ variables.tf # Variable definitions
β βββ outputs.tf # Server Public IP, Domain & SSH output definitions
β βββ modules/ec2/ # EC2 module (Instance, Security Group, EIP, UserData)
βββ .github/workflows/
β βββ deploy.yml # GitHub Actions automated SSH deployment pipeline
βββ docker-compose.yml # Multi-container production orchestration config
βββ docker-compose.override.yml.example # Local HTTP development override template
βββ .env.example # Sample environment variables reference# 1. Clone the repository
git clone https://github.com/ideateGudy/deploy-list.git
cd deploy-list
# 2. Copy sample environment file
cp .env.example .env
# 3. Enable local HTTP development config (bypasses SSL cert check on localhost)
cp docker-compose.override.yml.example docker-compose.override.yml
# 4. Build and launch containers locally
docker compose up -d --buildaws_region = "eu-north-1"
environment = "prod"
instance_type = "t3.micro"
key_name = "ann_notch" # Your AWS SSH Key Pair name
allowed_ssh_cidr = "0.0.0.0/0"
ami_id = "ami-0aba19e56f3eaec05" # Ubuntu 26.04 LTS (amd64)
domain_name = "ideategudy.tech"