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! π οΈ
π± What is This Project? (Application Overview)
This is a complete full-stack personal diary & management application built from scratch using the MERN stack:
- React SPA Frontend (client): Built with React & Vite. Features interactive diary entry logging, authentication state management, responsive UI, and custom URL shortener views. It is packaged with an optimized Nginx multi-stage Docker build.
- Node.js & Express API Backend (server): Lightweight RESTful API supporting JWT user authentication (Access & Refresh tokens), diary management endpoints, and URL redirection logic.
- MongoDB Database (mongo): Containerized MongoDB instance for persistent storage of users, diary logs, and link analytics.
π Prerequisites & Tools Required
Before we begin, ensure you have the following installed and set up on your machine:
- Git: Version control system to manage project code.
- Docker & Docker Desktop: To build and test containerized services locally.
-
AWS CLI: Configured with IAM credentials (
aws configure). - Terraform CLI (v1.16.2 or higher): To provision AWS infrastructure declaratively.
- AWS Account: An active AWS account with permissions to launch EC2, VPC, Security Groups, and Elastic IPs.
- Custom Domain Name: (Optional but recommended) A domain name pointing to AWS (e.g. from Namecheap, Cloudflare, GoDaddy).
ποΈ High-Level System Architecture
Here is how all the moving parts work together once deployed on AWS:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 β β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Architectural Highlights:
-
Nginx (Frontend Container): Listens on standard public ports
80(HTTP) and443(HTTPS). It serves the production React build and acts as a reverse proxy, forwarding/api/traffic to the backend server. -
Node.js Express (Backend Container): Communicates internally over Docker's bridge network (
port 3000). It is not directly exposed to the public internet, adding a layer of security. -
MongoDB (Database Container): Persists application state internally (
port 27017) using Docker named volumes.
π Repository Structure Overview
Here is the clean layout of our project repository:
.
βββ 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
π Step-by-Step Deployment Walkthrough
Step 1: Local Development & Container Verification
First, verify that your multi-container application runs smoothly on your local machine using Docker Compose:
# 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 --build
π‘ 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:
-
Frontend SPA:
http://localhost -
Backend API:
http://localhost/api/auth/ -
Health Check:
http://localhost/healthz
Figure 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.
Step 2: Provision Infrastructure using Terraform (IaC)
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.
1. Configure AWS CLI & Security Key Pair
Make sure your AWS CLI is authenticated and generate an AWS EC2 Key Pair (e.g. ann_notch or diary-app-key).
2. Provision with Terraform
# Navigate to terraform directory
cd terraform
# Create your tfvars configuration file
cp terraform.tfvars.example terraform.tfvars
Edit your terraform.tfvars:
aws_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"
Initialize and apply the Terraform configuration:
terraform init
terraform apply -auto-approve
Once complete, Terraform will output your server_public_ip (Elastic IP) and domain_name. Keep these outputs handy!
Step 3: Configure Custom Domain & DNS Records (Optional but Recommended)
To make your application accessible at a real domain (e.g., ideategudy.tech) and issue SSL certificates:
- Go to your Domain Registrar DNS settings.
- Create an A Record: Name
@$\rightarrow$ Value<YOUR_SERVER_PUBLIC_IP>. - Create a CNAME Record: Name
www$\rightarrow$ Valueideategudy.tech. - Verify DNS propagation:
nslookup ideategudy.tech 8.8.8.8
Step 4: Configure GitHub Repository Secrets & Variables
To 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) |
Step 5: Automated Deployment via GitHub Actions CI/CD
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:
- GitHub Actions connects securely to your EC2 server via SSH.
- It pulls the latest code.
- It updates environment variables (
.env). - It executes Certbot to issue/renew free Let's Encrypt SSL certificates automatically.
- It runs
docker compose up -d --buildto re-deploy updated containers with zero downtime.
To trigger your deployment:
git add .
git commit -m "Deploy fullstack application to production"
git push origin main
Head over to the Actions tab in GitHub to watch your deployment complete in real-time! β‘
Step 6: Live Production Verification & Secure HTTPS
Once the GitHub Actions workflow completes successfully, open your browser and navigate to your production domain:
-
Deployed App (HTTPS):
https://ideategudy.tech
β Troubleshooting & Common Gotchas
If something doesn't work on your first try, don't worry! Here are the most common hiccups beginners face and how to fix them:
-
SSH Connection Timeout in GitHub Actions:
- Cause: Security Group blocking SSH or wrong IP.
-
Fix: Ensure
allowed_ssh_cidr = "0.0.0.0/0"interraform.tfvarsand check thatEC2_HOSTsecret matches your Terraform output IP.
-
Certbot / SSL Failure:
- Cause: DNS record has not fully propagated before pushing to GitHub Actions.
-
Fix: Run
nslookup yourdomain.comfirst to confirm your domain resolves to your EC2 IP before triggering the workflow.
-
MongoDB Connection Failed:
- Cause: Containers starting out of order or invalid URI.
-
Fix: Make sure
MONGODB_URIin GitHub Secrets is set tomongodb://mongo:27017/diarydb(using the container service namemongo).
π‘οΈ Production Best Practices Implemented
-
IMDSv2 Enforced: AWS Instance Metadata Service v2 required for enhanced EC2 protection (
http_tokens = "required"). -
EBS Volume Encryption: Server storage volume (
gp3) encrypted by default. - Log Management: Docker container logging capped at 10MB per file to prevent disk exhaustion.
-
Enhanced Security Headers: Nginx configured with
X-Frame-Options,X-Content-Type-Options, andserver_tokens off.
β‘ Key Benefits of This Architecture
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 containerizedt3.microEC2 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:
-
Internal Docker Bridge: Backend API (
port 3000) and MongoDB (port 27017) are isolated on internal Docker networks and not exposed directly to the internet. - Nginx Reverse Proxy: Shields application servers by acting as the sole entry point, handling request routing, rate limiting, and SSL termination.
-
Internal Docker Bridge: Backend API (
π Clean Dev-to-Prod Parity with Docker Compose:
The exact samedocker-compose.ymlfile 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 applyorterraform 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.
π― Conclusion
Congratulations! π₯³ You've built and deployed a production-ready, containerized full-stack application on AWS!
Here is a quick recap of what we accomplished:
- Orchestrated local multi-container environments using Docker Compose.
- Automated cloud infrastructure provisioning using Terraform.
- Established secure reverse-proxying and routing with Nginx.
- Automated SSL HTTPS certificate generation via Let's Encrypt & Certbot.
- Built a push-to-deploy CI/CD pipeline using GitHub Actions.
Infrastructure-as-Code and containerization give you complete confidence that your deployment is reproducible, maintainable, and scalable.
π Related Documentation & Alternative Architecture
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.
π€ Connect & Follow Me
If you found this guide helpful, hit the β€οΈ button, bookmark it for later, and feel free to connect with me!
- π GitHub: github.com/ideateGudy
- πΌ LinkedIn: linkedin.com/in/ideategudy
Got questions or run into issues? Drop a comment belowβI'd love to help out! Happy coding! π







Top comments (0)