Automating MERN Stack Deployment with GitHub Actions CI/CD Pipeline

Automating MERN Stack Deployment with GitHub Actions CI/CD Pipeline

Introduction

Hi again! If you're working on web applications and want to automate testing, building, or deployment, you're in the right place. Recently, I deployed a MERN stack application on an AWS EC2 instance using GitHub Actions, and it was a game-changer! Instead of manually pushing updates to my server, GitHub Actions now takes care of everything—from running tests to deploying new versions automatically.

In this blog, I’ll break down what GitHub Actions is, how it works, and how you can use it to automate your workflows. Whether you’re new to CI/CD or looking to refine your automation process, this guide will help you get started! 🚀

Let’s dive in! 👇


What is GitHub Actions?

GitHub Actions is a built-in CI/CD tool provided by GitHub that allows you to define and execute automation workflows within your repositories. It integrates seamlessly with GitHub and enables developers to automate software development processes such as testing, deployment, and monitoring.

Key Features of GitHub Actions:

✅ Automate tasks like testing, building, and deployment
✅ Event-driven execution (e.g., on push, pull request, or schedule)
✅ Supports custom workflows written in YAML
✅ Built-in marketplace for reusable actions
✅ Secure and scalable

Core Concepts of GitHub Actions

Before we dive into writing workflows, let’s understand the key components:

1️⃣ Workflows

A workflow is an automated process defined in a .github/workflows/ YAML file. It consists of one or more jobs and runs when a specified event occurs.

2️⃣ Events

An event is a trigger that starts a workflow. Examples include:

  • push: Triggered when code is pushed to a branch

  • pull_request: Runs when a pull request is created

  • schedule: Runs on a cron job (e.g., every night at 12 AM)

3️⃣ Jobs

A job is a set of steps executed sequentially on a virtual machine (runner). Each job runs independently by default, but dependencies can be set between jobs.

4️⃣ Steps

A step is a single task within a job, such as running a script, checking out the code, or installing dependencies.

5️⃣ Runners

A runner is a server that executes workflows. GitHub provides free hosted runners, or you can use self-hosted runners for more control.


If you want to see GitHub Actions in action, check out my MERN stack deployment project on GitHub! 🚀 You’ll find the complete CI/CD pipeline, Docker setup, and deployment steps.

🔗 GitHub Repository: https://github.com/amaan-sayyed/MERN-Action-Workflows


GitHub Actions Workflow (CI/CD Pipeline)

Here’s the full GitHub Actions YAML file I used to deploy my MERN stack application on an AWS EC2 instance. This workflow automates testing, building, and deployment.

name: Build and Deploy MERN App

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:

jobs:
  build-and-push:
    name: Build and Push Docker Images
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}


      - name: Build and push frontend Docker image
        run: |
          docker build -t ${{ secrets.DOCKER_USERNAME }}/mern-frontend:latest ./mern/frontend
          docker push ${{ secrets.DOCKER_USERNAME }}/mern-frontend:latest

      - name: Build and push backend Docker image
        run: |
          docker build -t ${{ secrets.DOCKER_USERNAME }}/mern-backend:latest ./mern/backend
          docker push ${{ secrets.DOCKER_USERNAME }}/mern-backend:latest

  deploy:
    name: Deploying application
    runs-on: ubuntu-latest
    needs: build-and-push

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.5.3
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Copy Docker Compose file to EC2
        run: |
          scp -o StrictHostKeyChecking=no docker-compose.yaml ubuntu@${{ secrets.EC2_HOST }}:/home/ubuntu/docker-compose.yaml

      - name: Stop existing containers
        run: |
          ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml down"

      - name: Pull latest Docker images
        run: |
          ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml pull"

      - name: Start containers with Docker Compose
        run: |
          ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml up -d"

When setting up your CI/CD pipeline with GitHub Actions for your MERN stack application, the first step is to configure the required secrets in your GitHub repository.

These secrets are essential for securely storing sensitive information such as Docker Hub credentials and SSH keys, which will be used in your workflow. Don’t forget to configure the following secrets in your GitHub repository before proceeding:

  1. DOCKER_USERNAME: Your Docker Hub username.

  2. DOCKER_PASSWORD: Your Docker Hub password.

  3. SSH_PRIVATE_KEY: The private key used for SSH authentication to your EC2 instance.

  4. EC2_HOST: The public IP address or DNS name of your EC2 instance.

To configure these secrets:

  1. Go to your GitHub repository.

  2. Navigate to Settings > Secrets and variables > Actions.

  3. Click on New repository secret and add each secret with its appropriate name and value.

Once these secrets are configured, your GitHub Actions workflow will be able to securely reference them during execution.

Let’s break down the GitHub Actions workflow file with code snippets and explanations for each part:

1. Triggering the Workflow

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:
  • on: This section specifies when the workflow will run.

    • push: The workflow will run when code is pushed to the main branch.

    • pull_request: The workflow will also run when a pull request is made to the main branch.

    • workflow_dispatch: This allows you to manually trigger the workflow via GitHub’s UI.

2. Define the Jobs

The workflow contains two main jobs: build-and-push (for building Docker images) and deploy (for deploying to EC2). Each job runs on ubuntu-latest.

Job 1: build-and-push

This job builds the Docker images for both the frontend and backend and pushes them to Docker Hub.

jobs:
  build-and-push:
    name: Build and Push Docker Images
    runs-on: ubuntu-latest
  • runs-on: ubuntu-latest: Specifies that the job will run on the latest Ubuntu runner provided by GitHub Actions.
Steps in build-and-push:
  1. Checkout code:
- name: Checkout code
  uses: actions/checkout@v3
  • This step checks out the code from the repository.
  1. Set up Docker Buildx:

     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v2
    
  • This sets up Docker Buildx, a tool that facilitates building multi-platform Docker images.
  1. Log in to Docker Hub:

     - name: Log in to Docker Hub
       uses: docker/login-action@v2
       with:
         username: ${{ secrets.DOCKER_USERNAME }}
         password: ${{ secrets.DOCKER_PASSWORD }}
    
  • This step logs into Docker Hub using credentials stored in GitHub Secrets (i.e., DOCKER_USERNAME and DOCKER_PASSWORD).
  1. Build and Push Frontend Docker Image:

     - name: Build and push frontend Docker image
       run: |
         docker build -t ${{ secrets.DOCKER_USERNAME }}/mern-frontend:latest ./mern/frontend
         docker push ${{ secrets.DOCKER_USERNAME }}/mern-frontend:latest
    
  • This step builds the frontend Docker image using the Dockerfile located in the ./mern/frontend directory and tags it with the latest tag, then pushes it to Docker Hub.
  1. Build and Push Backend Docker Image:

     - name: Build and push backend Docker image
       run: |
         docker build -t ${{ secrets.DOCKER_USERNAME }}/mern-backend:latest ./mern/backend
         docker push ${{ secrets.DOCKER_USERNAME }}/mern-backend:latest
    
  • This step does the same for the backend Docker image, building from the ./mern/backend directory and pushing it to Docker Hub.

Job 2: deploy

This job deploys the application to an EC2 instance using Docker Compose.

deploy:
  name: Deploying application
  runs-on: ubuntu-latest
  needs: build-and-push
  • needs: build-and-push: This indicates that the deploy job depends on the successful completion of the build-and-push job.
Steps in deploy:
  1. Checkout code:
  •   - name: Checkout code
        uses: actions/checkout@v3
    
  • This checks out the code again (as it's a separate job).

  1. Set up SSH:

     - name: Set up SSH
       uses: webfactory/ssh-agent@v0.5.3
       with:
         ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
    
  • This step sets up SSH using the private key stored in GitHub Secrets (SSH_PRIVATE_KEY), enabling secure SSH connections to the EC2 instance.
  1. Copy Docker Compose file to EC2:

     - name: Copy Docker Compose file to EC2
       run: |
         scp -o StrictHostKeyChecking=no docker-compose.yaml ubuntu@${{ secrets.EC2_HOST }}:/home/ubuntu/docker-compose.yaml
    
  • scp: This command copies the docker-compose.yaml file from the local runner to the EC2 instance, located at ubuntu@${{ secrets.EC2_HOST }}.
  1. Stop Existing Containers:
- name: Stop existing containers
  run: |
    ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml down"
  • ssh: This command uses SSH to connect to the EC2 instance and run docker-compose down, which stops and removes any currently running containers.

    1. Pull Latest Docker Images
    - name: Pull latest Docker images
      run: |
        ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml pull"
  • This SSH command tells Docker Compose to pull the latest images (frontend and backend) from Docker Hub to the EC2 instance.
Start Containers with Docker Compose
- name: Start containers with Docker Compose
  run: |
    ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.EC2_HOST }} "docker-compose -f /home/ubuntu/docker-compose.yaml up -d"
  • docker-compose up -d: This command starts the containers in detached mode (-d) on the EC2 instance, using the docker-compose.yaml file.

Pipeline Execution:

Lets execute this pipeline shall we ?!?!

We have configured our workflow to be manually triggered. Click on Run workflow to execute the pipeline.

So the Workflow for our first job Build and Push Docker Images is started and lets checkout the logs of each step.

  1. This sets up the GitHub hosted Runner for our job :

  2. Checks out the codebase of our Repository:

  3. Logs In to my DockerHub Account inside the Runner, Builds the images and pushes them to DockerHub:

    As you can see our First Job is successfully executed and our application service’s docker images are built and pushed to Docker hub.

As soon as our first job build-and-push i=has completed its execution, our deployment job(which i have configured as a separate job) starts its execution.

For the deployment of our application I have created an AWS EC2 Instance. NOTE : Make sure Docker and Docker-Compose are installed on the instance and set permissions to execute docker daemon. Also enable Inbound rule at port 5173 of the instance, because this is where we will access our application.

Job starts its execution.. and we can checkout its logs :

The deploy job in the GitHub Actions workflow automates the process of deploying the MERN app to an EC2 instance. Here’s what happens:

  1. Checkout code: Retrieves the latest code from the repository.

  2. Set up SSH: Configures SSH access to the EC2 instance using the private key stored in GitHub Secrets.

  3. Copy Docker Compose file: Transfers the docker-compose.yaml file to the EC2 instance.

  4. Stop existing containers: Stops any running containers on the EC2 instance.

  5. Pull latest Docker images: Pulls the updated Docker images for the frontend and backend.

  6. Start containers: Uses Docker Compose to start the containers in detached mode (-d), running the updated app on the EC2 instance.

Lets access it using the Instance IP and the mapped port number :

And there you go!! We have successfully deployed our 3-tier MERN Stack application to a remote EC2 Instance which acts like a Deployment server using GitHub Actions.


Conclusion:

In conclusion, setting up a CI/CD pipeline with GitHub Actions for a MERN stack application significantly streamlines the deployment process. By automating tasks like building Docker images, pushing them to Docker Hub, and deploying the app to an EC2 instance, we ensure that the application is always up-to-date and consistently deployed without manual intervention. The use of GitHub Secrets adds an extra layer of security, ensuring sensitive credentials are safely managed. This workflow not only enhances development efficiency but also helps maintain a smooth and reliable deployment pipeline for continuous integration and delivery.