DEV Community

Cover image for How I Deployed a MERN Stack App on AWS from Scratch — Step by Step published
Kopalachandran Abinash
Kopalachandran Abinash

Posted on

How I Deployed a MERN Stack App on AWS from Scratch — Step by Step published

I recently built a full-stack Hospital Management System and deployed it on AWS with a complete CI/CD pipeline. In this article I'll walk you through exactly how I did it — every command, every step, every mistake I made along the way.

By the end of this article you'll know how to:

  • Containerize a MERN app with Docker
  • Push images to AWS ECR
  • Deploy backend on EC2
  • Host frontend on S3
  • Set up an Application Load Balancer
  • Configure Auto Scaling
  • Automate everything with Jenkins CI/CD

🏗️ Architecture Overview

GitHub Push → Jenkins CI/CD
                ├── Build Docker Images
                ├── Push to AWS ECR
                ├── Deploy Backend → EC2 (via SSM)
                └── Deploy Frontend → S3

Users → S3 (Frontend) → ALB → EC2 (Backend) → MongoDB Atlas
Enter fullscreen mode Exit fullscreen mode

Tech Stack:

  • Frontend: React.js + Vite + Tailwind CSS
  • Backend: Node.js + Express.js + Socket.IO
  • Database: MongoDB Atlas
  • Storage: Cloudinary
  • AI: Groq API

📋 Prerequisites

Before starting, make sure you have:

  • An AWS account (free tier works fine)
  • Docker Desktop installed
  • Node.js installed
  • Jenkins installed locally
  • Your MERN app code on GitHub

Phase 1 — AWS Account Setup

Create IAM Role for EC2

Your EC2 server needs permissions to talk to other AWS services. Instead of using secret keys on the server, we attach a Role.

  1. Go to IAM → Roles → Create role
  2. Select AWS serviceEC2
  3. Attach these 4 policies:
    • AmazonS3FullAccess
    • AmazonEC2ContainerRegistryFullAccess
    • AmazonSSMManagedInstanceCore
    • CloudWatchAgentServerPolicy
  4. Name it: EC2-Hospital-Role

Create IAM User for CLI

  1. IAM → Users → Create user → name: hospital-cli-user
  2. Attach policies:
    • AmazonEC2ContainerRegistryFullAccess
    • AmazonS3FullAccess
    • AmazonSSMFullAccess
    • AmazonEC2FullAccess
    • AWSCloudFormationFullAccess
  3. Security credentials → Create access key → CLI
  4. Download the CSV — you can't see the secret key again!

Phase 2 — AWS CLI Setup

Install AWS CLI from: https://awscli.amazonaws.com/AWSCLIV2.msi

Then configure it:

aws configure
# AWS Access Key ID: your-access-key
# AWS Secret Access Key: your-secret-key
# Default region name: eu-north-1
# Default output format: json
Enter fullscreen mode Exit fullscreen mode

Verify it works:

aws sts get-caller-identity
Enter fullscreen mode Exit fullscreen mode

You should see your Account ID. ✅


Phase 3 — ECR (Elastic Container Registry)

ECR is AWS's private Docker Hub. We create 2 repositories — one for backend, one for frontend.

aws ecr create-repository --repository-name hospital-backend --region eu-north-1
aws ecr create-repository --repository-name hospital-frontend --region eu-north-1
Enter fullscreen mode Exit fullscreen mode

Login to ECR from your local machine:

aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

Build and push your Docker images:

# Build
docker-compose build

# Tag
docker tag hospital-app-backend:latest YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-backend:latest
docker tag hospital-app-frontend:latest YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-frontend:latest

# Push
docker push YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-backend:latest
docker push YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-frontend:latest
Enter fullscreen mode Exit fullscreen mode

Phase 4 — EC2 Setup

Launch EC2 Instance

  1. EC2 → Launch instance
  2. Name: hospital-backend-server
  3. AMI: Amazon Linux 2023
  4. Instance type: t3.micro (free tier)
  5. Create key pair: hospital-key (.pem format) — save this file!
  6. Security group — open ports: 22, 80, 5000
  7. IAM instance profile: EC2-Hospital-Role

Install Docker on EC2

Connect via EC2 Instance Connect, then run:

sudo yum update -y
sudo yum install -y docker
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker ec2-user
newgrp docker
Enter fullscreen mode Exit fullscreen mode

Run Backend Container

# Login to ECR from EC2
aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com

# Create .env file
cat > /home/ec2-user/.env << 'EOF'
PORT=5000
MONGO_URI=your_mongodb_uri
JWT_SECRET=your_jwt_secret
# ... add all your env vars
EOF

# Run container
docker run -d \
  --name hospital-backend \
  --restart always \
  -p 5000:5000 \
  --env-file /home/ec2-user/.env \
  YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-backend:latest
Enter fullscreen mode Exit fullscreen mode

Verify it's running:

docker logs hospital-backend
# Should see: Server running on port 5000 ✅
# Should see: MongoDB Connected! ✅
Enter fullscreen mode Exit fullscreen mode

Phase 5 — S3 Frontend Hosting

# Create bucket
aws s3 mb s3://your-app-frontend --region eu-north-1
Enter fullscreen mode Exit fullscreen mode

In AWS Console:

  1. S3 → your bucket → Properties → Static website hosting → Enable
  2. Index document: index.html, Error document: index.html
  3. Permissions → Block public access → untick all
  4. Add bucket policy:
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::your-app-frontend/*"
  }]
}
Enter fullscreen mode Exit fullscreen mode

Build and upload frontend:

# Set your backend URL in frontend/.env
echo "VITE_API_URL=http://YOUR_ALB_DNS/api" > frontend/.env

# Build
cd frontend && npm run build

# Upload
aws s3 sync dist/ s3://your-app-frontend --region eu-north-1 --delete
Enter fullscreen mode Exit fullscreen mode

Phase 6 — Application Load Balancer

WHY: The ALB gives you a stable DNS name that never changes even if EC2 restarts. It also enables Auto Scaling.

  1. EC2 → Target Groups → Create

    • Name: hospital-tg
    • Protocol: HTTP, Port: 5000
    • Health check path: /
    • Register your EC2 instance
  2. EC2 → Load Balancers → Create → Application Load Balancer

    • Name: hospital-alb
    • Internet-facing, all availability zones
    • Listener: HTTP:80 → forward to hospital-tg

Wait for Active status, then copy the DNS name.

Update your frontend .env with the ALB DNS and redeploy to S3.


Phase 7 — Auto Scaling

Auto Scaling automatically adds more EC2 instances when CPU is high and removes them when traffic drops.

  1. Create AMI from your running EC2 instance
  2. Create Launch Template using that AMI
  3. Create Auto Scaling Group:
    • Min: 1, Desired: 1, Max: 3
    • CPU tracking policy: 50%
    • Attach to your ALB target group

Phase 8 — Jenkins CI/CD Pipeline

WHY: Every time you push code, Jenkins automatically builds, pushes, and deploys everything. No manual work needed.

Add credentials in Jenkins:

  • aws-access-key (Secret text)
  • aws-secret-key (Secret text)

Jenkinsfile:

pipeline {
    agent any
    environment {
        AWS_ACCESS_KEY_ID     = credentials('aws-access-key')
        AWS_SECRET_ACCESS_KEY = credentials('aws-secret-key')
        AWS_REGION            = 'eu-north-1'
        AWS_ACCOUNT_ID        = 'YOUR_ACCOUNT_ID'
        ECR_BACKEND           = 'YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-backend'
        ECR_FRONTEND          = 'YOUR_ACCOUNT_ID.dkr.ecr.eu-north-1.amazonaws.com/hospital-frontend'
        S3_BUCKET             = 'your-app-frontend'
    }
    stages {
        stage('Checkout') {
            steps {
                git branch: 'main', url: 'https://github.com/YOUR_USERNAME/YOUR_REPO.git'
            }
        }
        stage('Build Images') {
            steps {
                bat 'docker build -t hospital-backend ./backend'
                bat 'docker build -t hospital-frontend ./frontend'
            }
        }
        stage('Push to ECR') {
            steps {
                bat '"C:\\Program Files\\Amazon\\AWSCLIV2\\aws.exe" ecr get-login-password --region %AWS_REGION% | docker login --username AWS --password-stdin %AWS_ACCOUNT_ID%.dkr.ecr.%AWS_REGION%.amazonaws.com'
                bat 'docker tag hospital-backend:latest %ECR_BACKEND%:latest'
                bat 'docker tag hospital-frontend:latest %ECR_FRONTEND%:latest'
                bat 'docker push %ECR_BACKEND%:latest'
                bat 'docker push %ECR_FRONTEND%:latest'
            }
        }
        stage('Deploy Backend via SSM') {
            steps {
                script {
                    def instanceId = bat(
                        returnStdout: true,
                        script: '"C:\\Program Files\\Amazon\\AWSCLIV2\\aws.exe" ec2 describe-instances --filters "Name=tag:Name,Values=hospital-backend-server" "Name=instance-state-name,Values=running" --query "Reservations[0].Instances[0].InstanceId" --output text --region %AWS_REGION%'
                    ).trim().readLines().last()
                    bat """
                        "C:\\Program Files\\Amazon\\AWSCLIV2\\aws.exe" ssm send-command ^
                        --instance-ids ${instanceId} ^
                        --document-name "AWS-RunShellScript" ^
                        --parameters "commands=['aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin %AWS_ACCOUNT_ID%.dkr.ecr.eu-north-1.amazonaws.com && docker pull %ECR_BACKEND%:latest && docker stop hospital-backend || true && docker rm hospital-backend || true && docker run -d --name hospital-backend --restart always -p 5000:5000 --env-file /home/ec2-user/.env %ECR_BACKEND%:latest']" ^
                        --region %AWS_REGION%
                    """
                }
            }
        }
        stage('Deploy Frontend to S3') {
            steps {
                bat 'cd frontend && npm install && npm run build'
                bat '"C:\\Program Files\\Amazon\\AWSCLIV2\\aws.exe" s3 sync frontend\\dist\\ s3://%S3_BUCKET% --region %AWS_REGION% --delete'
            }
        }
    }
    post {
        success { echo 'Deployment successful!' }
        failure { echo 'Deployment failed!' }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key lesson: Keep secrets out of code!

Use Jenkins credentials for all secrets. GitHub will block your push if it detects API keys in your code (learned this the hard way! 😅).


Phase 9 — CloudFormation (Infrastructure as Code)

Define your infrastructure in a YAML file so you can recreate everything with one command:

AWSTemplateFormatVersion: '2010-09-09'
Description: Hospital App Infrastructure

Resources:
  BackendRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: hospital-backend

  FrontendBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: your-app-frontend
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: index.html

  HospitalSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: hospital-sg
      GroupDescription: Security group for Hospital App
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: 5000
          ToPort: 5000
          CidrIp: 0.0.0.0/0
Enter fullscreen mode Exit fullscreen mode

Deploy with:

aws cloudformation deploy \
  --template-file cloudformation.yml \
  --stack-name hospital-stack \
  --region eu-north-1
Enter fullscreen mode Exit fullscreen mode

🐛 Mistakes I Made (So You Don't Have To)

1. Secrets in Jenkinsfile
I accidentally put my Groq API key directly in the Jenkinsfile. GitHub blocked the push immediately. Always use Jenkins credentials instead of hardcoding secrets.

2. Wrong VITE_API_URL
After deployment the frontend was hitting localhost:5000 instead of the ALB URL. Always double check your .env before building.

3. Missing IAM permissions
I kept getting UnauthorizedOperation errors because my IAM user was missing EC2 and CloudFormation permissions. Add all needed permissions upfront.

4. SSM parameter parsing
Passing environment variables through SSM commands with special characters caused parsing errors. The fix was using --env-file on EC2 instead of passing each variable individually.


🎯 Results

After all this setup, my CI/CD pipeline works like this:

  1. I push code to GitHub
  2. Jenkins automatically triggers
  3. Docker images built and pushed to ECR
  4. Backend deployed to EC2 via SSM (no SSH needed!)
  5. Frontend built and uploaded to S3
  6. Everything live in ~10 minutes

No manual deployment steps. Ever.


📊 Cost

Running this on AWS free tier costs approximately $0/month if you:

  • Use t3.micro EC2 (750 hours/month free)
  • Stop EC2 when not using
  • S3 storage is practically free for a small app

🔗 Resources


Conclusion

Deploying a full-stack app on AWS from scratch taught me more about DevOps than any tutorial. The key things I learned:

  • Always use IAM roles instead of access keys on servers
  • Keep secrets in credential managers, never in code
  • Load Balancers are essential for stable URLs
  • CI/CD pipelines save enormous amounts of time
  • Infrastructure as Code means you never lose your setup again

If you have any questions, drop them in the comments! Happy to help. 🚀


If this helped you, please leave a ❤️ and follow for more DevOps content!

Top comments (0)