Introduction
Hello again !! In this blog, lets explore GitHub Actions and their role in automating CI/CD workflows. We’ll walk through a detailed explanation of a GitHub Actions YAML file I created for automating the CI/CD pipeline of a MERN stack application. Additionally, lets also compare GitHub Actions with Jenkins to know perks and cons of both.
What Are GitHub Actions?
GitHub Actions is a CI/CD and automation platform directly integrated into GitHub. It allows developers to automate their workflows—such as testing, building, and deploying—by writing simple YAML configuration files. These workflows are triggered by events such as pushes, pull requests, or even manual intervention.
GitHub Actions goes beyond just DevOps and lets you run workflows when other events happen in your repository. For example, you can run a workflow to automatically add the appropriate labels whenever someone creates a new issue in your repository.
Why Use GitHub Actions?
Seamless Integration: It’s built into GitHub, so there’s no need for separate configuration. If any organization uses Github for its codebase, it just makes sense of use Github Actions for their automation and SDLC workflows.
Automation: Automate repetitive tasks like testing, building, and deploying code.
Flexibility: Trigger workflows based on a variety of events such as commits to certain branches, issues created to the repository, PRs created, manual triggering with input parameters and many more.
Cost-Effectiveness: Its free for public repositories and has generous limits for private repositories.
Key Components:
You can configure a GitHub Actions workflow to be triggered when an event occurs in your repository, such as a pull request being opened or an issue being created. Your workflow contains one or more jobs which can run in sequential order or in parallel. Each job will run inside its own virtual machine runner, or inside a container, and has one or more steps that either run a script that you define or run an action, which is a reusable extension that can simplify your workflow.
-
Workflows:
A Workflow is a configurable automated process that will run one or more jobs. Workflows are defined by a YAML file checked in to your repository and will run when triggered by an event in your repository, or they can be triggered manually, or at a defined schedule.
Workflows are defined in the
.github/workflows
directory in a repository. A repository can have multiple workflows, each of which can perform a different set of tasks such as:- Building and testing pull requests
- Deploying your application every time a release is created
- Adding a label whenever a new issue is opened
-
Events:
An event is a specific activity in a repository that triggers a workflow run. For example, an activity can originate from GitHub when someone creates a pull request, opens an issue, or pushes a commit to a repository. You can also trigger a workflow to run on a schedule, by posting to a REST API, or manually.
-
Jobs:
A job is a set of steps in a workflow that is executed on the same runner. Each step is either a shell script that will be executed, or an action that will be run. Steps are executed in order and are dependent on each other. Since each step is executed on the same runner, you can share data from one step to another. For example, you can have a step that builds your application followed by a step that tests the application that was built.
Steps: Individual tasks in a job, like running a script or executing an action.
Runners: A runner is a server that runs your workflows when they're triggered. Each runner can run a single job at a time. GitHub provides Ubuntu Linux, Microsoft Windows, and macOS runners to run your workflows. Each workflow run executes in a fresh, newly-provisioned virtual machine.
So before going forward, check out the MERN Stack Application we will be working with at this blog and checkout this project’s repository.
CI/CD Workflow for the MERN Stack Application
Below is the complete GitHub Actions YAML file which i have written for this project from scratch. Let’s break it down step by step.
Full YAML File:
name: Build and Deploy a MERN Stack Application
on:
workflow_dispatch:
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }}
VERSION: ${{ github.sha }}
INSTANCE_IP: ${{ secrets.INSTANCE_IP }}
SSH_KEY: ${{ secrets.PRIVATE_KEY }}
jobs:
Build-Push:
name: Build and Push Docker Images
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Login to DockerHub
run: echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USER --password-stdin
- name: Build and push Frontend Image
run: |
cd ./mern/frontend
docker build -t $DOCKERHUB_USER/mern-frontend:$VERSION .
docker push $DOCKERHUB_USER/mern-frontend:$VERSION
- name: Build and push Backend Image
run: |
cd ./mern/backend
docker build -t $DOCKERHUB_USER/mern-backend:$VERSION .
docker push $DOCKERHUB_USER/mern-backend:$VERSION
Deploy:
name: Deploy the application
runs-on: ubuntu-latest
needs: Build-Push
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Add SSH Key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Add EC2 to known hosts
run: |
ssh-keyscan -H $INSTANCE_IP >> ~/.ssh/known_hosts
- name: Copy startup-script and docker-compose to EC2
run: |
scp -i ~/.ssh/id_rsa startup-script.sh ubuntu@$INSTANCE_IP:/home/ubuntu
scp -i ~/.ssh/id_rsa docker-compose.yml ubuntu@$INSTANCE_IP:/home/ubuntu
- name: Run deployment script on EC2
run: |
ssh -i ~/.ssh/id_rsa ubuntu@$INSTANCE_IP "bash /home/ubuntu/startup-script.sh $VERSION"
Step-by-Step Explanation
Workflow Trigger: on: workflow_dispatch
Purpose: This allows manual triggering of the workflow from the GitHub Actions interface.
Why: Useful for deployments where you want to ensure readiness before running the CI/CD pipeline.
We can also configure these triggers to execute our workflows by events such as commits to certain branches, issues raised to the repository, PRs created, manual triggering with input parameters and many more.
env
Section:
This defines environment variables used across jobs. We have to configure these secrets under repository Settings/Secrets and Variables/Actions/Repository Secrets.
I have created these variables or secrets to avoid hardcoding values of repetitive and secrets information in the pipeline script.
DOCKERHUB_USER
andDOCKERHUB_PASSWORD
are pulled from GitHub Secrets for secure access to my Dockerhub account while pushing and pulling built images.VERSION
usesgithub.sha
to dynamically tag Docker images with the commit SHA. This voids the need to hardcode image tags.INSTANCE_IP
specifies the target EC2 instance IP for deployment.SSH_KEY
contains the private key for SSH access to the EC2 instance.
Job 1: Build-Push
Runs-on: ubuntu-latest
Purpose: Specifies the environment where the job will run. GitHub-hosted runners (like
ubuntu-latest
) provide a pre-configured environment. When a workflow starts that is meant to be “run-on“ one of these GitHub Hosted Runners, GitHub spins up a fresh Virtual Machine(or a container) based on the specified environment(hereubuntu-latest
). After the Job is executed, the VM is destroyed which ensures a clean environment for every new job or a workflow.This Runner comes with pre-configured environment, that’s the reason why i am able to run Docker commands without explicitly installing it.
These Runners are managed and maintained entirely by GitHub and our project has no ownership over it.
Steps in Build-Push Job:
-
Checkout Code:
- name: Checkout code uses: actions/checkout@v2
* **Purpose:** `actions/checkout@v2` is a pre-built action from GitHub’s marketplace. Retrieves the code from the repository to the runner.
-
Login to DockerHub:
- name: Login to DockerHub run: echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USER --password-stdin
* **Purpose:** Logs into DockerHub to allow pushing images.
*
In this step I used the Environment Variables created at the start of the script.
-
Build and Push Frontend Image:
- name: Build and push Frontend Image run: | cd ./mern/frontend docker build -t $DOCKERHUB_USER/mern-frontend:$VERSION . docker push $DOCKERHUB_USER/mern-frontend:$VERSION
* **Purpose:** Builds and tags the frontend image, then pushes it to DockerHub. The folder already contains the custom DockerFile for the frontend service.
* Thing to notice is how i use `VERSION` variable that uses `github.sha` to dynamically tag Docker images with the commit SHA. This voids the need to hardcode image tags.
-
Build and Push Backend Image:
- name: Build and push Backend Image run: | cd ./mern/backend docker build -t $DOCKERHUB_USER/mern-backend:$VERSION . docker push $DOCKERHUB_USER/mern-backend:$VERSION
* **Purpose:** Similar to the frontend step but for the backend application.
Here our First Job is completed where we Checked out the repository codebase, built the Docker Images for the application services and pushed them to the DockerHub.
After the execution of this Job, the GitHub hosted runner (ubuntu-latest
) is terminated.
Job 2: Deploy
For Deploying our application I will be using a AWS EC2 Instance. So stick with me, I will also show the execution of the entire pipeline, this is just the explanation of the pipeline script.
Needs: Build-Push
- Ensures this job starts only after the
Build-Push
job completes successfully.
Steps in Deploy Job:
- Checkout Code:
* Same as the previous step; ensures code is available on the runner.
-
Add SSH Key:
- name: Add SSH Key run: | mkdir -p ~/.ssh echo "${{ secrets.PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
* **Purpose:** Configures the private SSH key for accessing the EC2 instance securely. I am using the `${{ secrets.PRIVATE_KEY }}` which stores the private key to the deployment server (EC2 Instance).
-
Add EC2 to Known Hosts:
- name: Add EC2 to known hosts run: | ssh-keyscan -H $INSTANCE_IP >> ~/.ssh/known_hosts
* **Purpose:** Prevents host authenticity issues during SSH commands.
-
Copy Deployment Files to EC2:
- name: Copy startup-script and docker-compose to EC2 run: | scp -i ~/.ssh/id_rsa startup-script.sh ubuntu@$INSTANCE_IP:/home/ubuntu scp -i ~/.ssh/id_rsa docker-compose.yml ubuntu@$INSTANCE_IP:/home/ubuntu
* **Purpose:** This transfers my custom startup shell script and the docker-compose file for deployment to the target EC2 instance.
-
Run Deployment Script on EC2:
- name: Run deployment script on EC2 run: | ssh -i ~/.ssh/id_rsa ubuntu@$INSTANCE_IP "bash /home/ubuntu/startup-script.sh $VERSION"
**Purpose:** Executes the deployment script remotely on the EC2 instance to start the application using Docker Compose. I export the `$VERSION` variable to the EC2 Instance using the Shell Script which i have referenced in the Docker-Compose file to pull and run the correct versions of application service containers.
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.
- This sets up the GitHub hosted Runner for our job :
- Checks out the codebase of our Repository:
- 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 with dynamic tags.
As soon as our first job Build-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 :
This copies the bash Startup-script and the docker-compose file to the EC2 Instance.
And with this command :
It SSH’s into the server and executes the custom shell script which deploys the application.
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.
Jenkins vs GitHub Actions
Having built CI/CD pipelines using both I have felt quite a difference between both. GitHub Actions uses its compute resources more efficiently and is more fast and easy to begin with as a beginner. Perfect for projects hosted on GitHub, as it requires minimal setup and no external tools to start automating workflows.
Jenkins requires separate installation and setup, either on-premises or via cloud infrastructure. Pipelines are defined using Groovy scripts, which can have a steeper learning curve for beginners compared to YAML. But it is best for advanced users who need fine-grained control over their CI/CD pipelines and want to access highly extensible UI with a rich plugin ecosystem (over 1,800 plugins), allowing integration with nearly any tool or platform.
Checkout my Jenkins pipeline setup for this same application, check out [this blog].
Connect with me on LinkedIn.
Top comments (0)