GITHUB Actions
From Zero to Deploy: Building a MERN Stack CI/CD Pipeline with GitHub Actions Continuous Integration (CI) and Continuous Deployment (CD) have become essential pillars of modern software development…
ON THIS PAGE
From Zero to Deploy: Building a MERN Stack CI/CD Pipeline with GitHub Actions
Continuous Integration (CI) and Continuous Deployment (CD) have become essential pillars of modern software development. They automate the repetitive aspects of building, testing, and deploying applications, allowing developers to focus on what matters most: writing quality code.
In this comprehensive guide, we’ll walk through building a robust CI/CD pipeline using GitHub Actions to deploy a containerized MERN (MongoDB, Express, React, Node.js) application to a self-hosted Virtual Machine (VM).
Understanding GitHub Actions
What is GitHub Actions?
GitHub Actions is a powerful, event-driven CI/CD platform natively integrated into GitHub. It allows you to automate workflows triggered by repository events such as pushes, pull requests, or even manual triggers.
Core Components
| Component | Description |
|---|---|
| Workflows | Automated processes defined in YAML files stored in .github/workflows/ |
| Events | Triggers that start workflows (push, pull_request, workflow_dispatch, etc.) |
| Jobs | Sets of steps that execute on a runner; can run in parallel or sequentially using needs |
| Runners | Execution environments (GitHub-hosted or self-hosted) that run your jobs |
| Steps | Individual tasks within a job (run commands, use actions) |
| Actions | Reusable units of code from GitHub Marketplace or custom-built |
How Workflows Work
Declarative vs. Imperative Approach:
GitHub Actions uses a declarative approach with YAML. Instead of telling the system how to do something step-by-step (imperative), you declare what you want to achieve.
| Aspect | Imperative (Jenkins/Bash) | Declarative (GitHub Actions) |
|---|---|---|
| Focus | How to do it (detailed steps) | What to achieve (desired outcome) |
| Readability | Can become complex | More concise and clear |
| Example | for loop to build each component | needs: to define dependencies |
Understanding Job Execution: Parallel vs Sequential
One of the most important concepts in GitHub Actions is understanding how jobs execute. By default, all jobs run in parallel, which speeds up your pipeline significantly. However, you can configure them to run sequentially when needed.
Default Behavior: Parallel Execution
Jobs run simultaneously to save time:
jobs:
build_backend:
runs-on: ubuntu-latest
steps:
- name: Build backend
run: npm run build
build_frontend:
runs-on: ubuntu-latest
steps:
- name: Build frontend
run: npm run build
run_tests:
runs-on: ubuntu-latest
steps:
- name: Run tests
run: npm testIn this example, all three jobs (build_backend, build_frontend, run_tests) start at the same time and run in parallel.
Advantages of Parallel Execution:
- ⚡ Faster pipeline – Multiple jobs complete simultaneously
- 💰 Cost-efficient – Less total runtime means lower costs
- 🔄 Independent tasks – Jobs that don’t depend on each other can run together
When to Use Parallel Execution:
- Building multiple independent components
- Running tests for different modules
- Performing code quality checks (linting, security scans)
Sequential Execution with needs:
Sometimes you need jobs to run in a specific order. Use the needs keyword to create dependencies:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Build application
run: npm run build
test:
runs-on: ubuntu-latest
needs: build # Waits for 'build' to complete successfully
steps:
- name: Run tests
run: npm test
deploy:
runs-on: deployment_vm
needs: test # Waits for 'test' to complete successfully
steps:
- name: Deploy application
run: ./deploy.shExecution Flow:
buildruns firsttestwaits forbuildto complete ✅deploywaits fortestto complete ✅
When to Use Sequential Execution:
- Deployment should only happen after successful tests
- Integration tests need the build artifacts
- Database migrations must run before deployment
Advanced: Multiple Dependencies
A job can wait for multiple jobs to complete:
jobs:
build_backend:
runs-on: ubuntu-latest
steps:
- name: Build backend
run: npm run build
build_frontend:
runs-on: ubuntu-latest
steps:
- name: Build frontend
run: npm run build
test_backend:
runs-on: ubuntu-latest
needs: build_backend # Waits for backend build
steps:
- name: Test backend
run: npm test
test_frontend:
runs-on: ubuntu-latest
needs: build_frontend # Waits for frontend build
steps:
- name: Test frontend
run: npm test
deploy:
runs-on: deployment_vm
needs: [test_backend, test_frontend] # Waits for BOTH tests
steps:
- name: Deploy application
run: ./deploy.shExecution Flow:
build_backendandbuild_frontendrun in parallel ⚡test_backendwaits forbuild_backend✅test_frontendwaits forbuild_frontend✅- Both tests run in parallel ⚡
deploywaits for both tests to complete ✅✅
Visual Comparison
Parallel Execution (Default):
build_backend ████████
build_frontend ████████
run_tests ████████
↓
All complete at ~same time (8 seconds)Sequential Execution:
build ████████
↓
test ████████
↓
deploy ████████
↓
Total time: 24 secondsMixed (Optimized):
build_backend ████████
build_frontend ████████
↓ (both complete)
test_backend ████████
test_frontend ████████
↓ (both complete)
deploy ████████
↓
Total time: 16 secondsKey Takeaways
| Scenario | Configuration | Result |
|---|---|---|
No needs keyword | Jobs run independently | All jobs run in parallel (fastest) |
With needs: job_name | Single dependency | Jobs run sequentially (controlled order) |
With needs: [job1, job2] | Multiple dependencies | Waits for all specified jobs to complete |
Best Practice: Maximize parallel execution where possible, but use needs: to ensure quality gates (like tests) before deployment.
Example from Our Pipeline:
build_backend: # Runs in parallel
build_frontend: # Runs in parallel
↓ (both complete)
deploy:
needs: [build_backend, build_frontend] # Waits for bothThis ensures we never deploy unless both backend and frontend builds succeed!
GitHub Actions vs. Jenkins
Both are powerful CI/CD tools, but they excel in different scenarios.
| Feature | GitHub Actions | Jenkins | Winner |
|---|---|---|---|
| Setup & Maintenance | Zero setup. Fully managed by GitHub. | Requires server setup, Java, and plugin management. | GitHub Actions for ease |
| Integration | Native GitHub integration (issues, PRs, secrets). | Requires plugins for GitHub integration. | GitHub Actions for GitHub users |
| Customization & Flexibility | YAML-based, limited to available actions. | Groovy scripting allows unlimited customization and complex logic. | Jenkins for complex workflows |
| Cost | Free for public repos, generous free tier for private. | Free and open-source, but you pay for infrastructure. | GitHub Actions for cloud-first teams |
| Learning Curve | Easy to start, YAML syntax. | Steeper learning curve, requires Groovy/Java knowledge. | GitHub Actions for beginners |
| Plugin Ecosystem | Marketplace with 18,000+ actions. | 1,800+ plugins, mature ecosystem. | Jenkins for legacy integrations |
| Enterprise Features | Built-in secrets, RBAC, audit logs. | Extensive enterprise features, fine-grained permissions. | Jenkins for large enterprises |
| Pipeline Complexity | Good for standard workflows. | Excellent for complex, multi-branch, conditional pipelines. | Jenkins for complex scenarios |
| On-Premise/Air-Gapped | Requires GitHub Enterprise Server. | Works anywhere, full on-premise support. | Jenkins for restricted environments |
| Self-Hosted Runners | Supported, but limited compared to Jenkins agents. | Powerful master-agent architecture, unlimited agents. | Jenkins for distributed builds |
When to Choose Jenkins:
- Complex, multi-stage pipelines with intricate conditional logic
- Large enterprises with existing Jenkins infrastructure
- On-premise or air-gapped environments
- Need for extensive customization and plugin ecosystem
- Distributed builds across multiple agents with different configurations
- Regulatory requirements for full infrastructure control
When to Choose GitHub Actions:
- GitHub-centric workflow
- Quick setup with zero maintenance
- Standard CI/CD pipelines
- Modern, cloud-native applications
- Teams prioritizing speed over customization
Setting Up GitHub Actions: Step by Step
Let’s walk through the complete setup process for a beginner.
Step 1: Prepare Your Docker Hub Account
Before setting up GitHub Actions, you need a Docker Hub account to store your container images.
-
Create Docker Hub Account: Sign up at hub.docker.com
-
Generate Access Token (⚠️ CRITICAL: Must have Read & Write permissions):
- Log into Docker Hub
- Click on your username → Account Settings
- Go to Security → New Access Token
- Token Description:
GitHub Actions CI/CD - Access permissions: ⚠️ Select “Read & Write” (NOT “Read-only” – this is critical!)
- Click Generate and copy the token immediately (you won’t see it again)
⚠️ IMPORTANT: If you select “Read-only” permissions, you’ll get
unauthorized: access deniederrors when the pipeline tries to push images to Docker Hub. The token MUST have Read & Write permissions for the CI/CD pipeline to work. -
Create a Repository (optional):
- Go to Repositories → Create Repository
- Name:
mern_stack_app(or your preferred name) - Visibility: Public or Private
Step 2: Add Secrets to GitHub Repository
Secrets securely store sensitive information and make them available as environment variables in workflows.
Detailed Steps:
- Navigate to Repository Settings:
- Open your GitHub repository
- Click Settings (top menu)
- In the left sidebar, expand Secrets and variables
- Click Actions
- Add Docker Hub Username:
- Click New repository secret
- Name:
DOCKER_USERNAME - Secret: Your Docker Hub username (e.g.,
johndoe) - Click Add secret
- Add Docker Hub Access Token:
- Click New repository secret again
- Name:
DOCKER_PASSWORD - Secret: Paste the access token you generated earlier (not your Docker Hub password!)
- Click Add secret
- Verify Secrets:
- You should now see two secrets listed:
DOCKER_USERNAMEDOCKER_PASSWORD
- Secret values are hidden and cannot be viewed again
- You should now see two secrets listed:
Important Notes:
- Secrets are encrypted and only exposed to workflows during runtime
- Never log or echo secrets in your workflow steps
- Use access tokens instead of passwords for better security
- Tokens can be rotated without changing your password
Step 3: Set Up Self-Hosted Runner on Your VM
A self-hosted runner allows GitHub Actions to execute deployment jobs directly on your server.
Detailed Setup:
- Navigate to Runners Page:
- In your GitHub repository, go to Settings
- Scroll down in the left sidebar to Actions
- Click Runners
- Click New self-hosted runner
- Select Your Operating System:
- Choose your VM’s OS (Linux, macOS, or Windows)
- Follow the displayed commands
- Execute Setup Commands on Your VM (Example for Linux):
# Create a directory for the runner
mkdir actions-runner && cd actions-runner
# Download the latest runner package
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
# Extract the installer
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz- Configure the Runner:
# Run the configuration script
./config.sh --url https://github.com/YOUR_USERNAME/YOUR_REPO \ --token YOUR_REGISTRATION_TOKEN
# When prompted:
# - Enter runner name: press Enter for default or type a custom name
# - Enter runner labels: Type "deployment_vm" (important!)
# - Enter work folder: press Enter for default
Critical: The label deployment_vm is how your workflow will target this specific runner.- Start the Runner:
# Run interactively (for testing)
./run.sh#Good for you personal laptop
# OR install as a service (recommended for production)
sudo ./svc.sh install sudo ./svc.sh start sudo ./svc.sh status- Verify Runner Connection:
- You should see:
√ Connected to GitHub - In GitHub UI, the runner should appear with status Idle (green dot)
- If offline, check firewall settings and network connectivity
- You should see:
Runner Troubleshooting:
- Cannot connect: Check if port 443 (HTTPS) is open for outbound connections
- Permission denied: Ensure the user has necessary permissions (Docker group, file access)
- Runner not appearing: Refresh the GitHub page or check the token hasn’t expired
Step 4: Verify Docker Installation on VM
Your runner needs Docker to build and deploy containers.
# Check Docker installation
docker --version
docker-compose --version
# If not installed (Ubuntu/Debian):
sudo apt-get update
sudo apt-get install -y docker.io docker-compose
# Add your user to Docker group (to run without sudo)
sudo usermod -aG docker $USER
# Log out and log back in, then verify:
docker psCreating Your First Workflow
Understanding the Workflow Structure
Create .github/workflows/docker-ci.yml in your repository:
name: Docker CI/CD Pipeline
# When to run this workflow
on:
push:
branches: [ main ] # Trigger on push to main branch
pull_request:
branches: [ main ] # Trigger on PRs targeting main
workflow_dispatch: # Allow manual triggering from GitHub UI
# Global variables available to all jobs
env:
DOCKER_HUB_REPO: your_docker_username/mern_stack_app
FRONTEND_API_BASE_URL: /api/
jobs:
# Job 1: Build Backend Image (runs on GitHub's servers)
build_backend:
name: Build and Push Backend Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ env.DOCKER_HUB_REPO }}:backend-latest
# Job 2: Build Frontend Image (runs on GitHub's servers)
build_frontend:
name: Build and Push Frontend Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push frontend image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: ${{ env.DOCKER_HUB_REPO }}:frontend-latest
build-args: |
REACT_APP_API_URL=${{ env.FRONTEND_API_BASE_URL }}
# Job 3: Deploy to VM (runs on YOUR server)
deploy:
name: Deploy to Production VM
runs-on: deployment_vm # Uses your self-hosted runner
needs: [build_backend, build_frontend] # Waits for builds to complete
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Pull latest images and deploy
run: |
docker compose pull
docker compose down
docker compose up -d
docker image prune -afKey Concepts Explained:
1. Event Triggers (on:)
push: branches: [main]– Runs when code is pushed to main branchpull_request– Runs when PR is created/updatedworkflow_dispatch– Adds a “Run workflow” button in GitHub UI for manual triggering
2. Jobs Run in Parallel by Default
build_backendandbuild_frontendrun simultaneously (faster!)deploywaits for both to complete (needs:keyword)
3. Runner Selection (runs-on:)
ubuntu-latest– Uses GitHub’s cloud servers (free for public repos)deployment_vm– Uses your self-hosted runner (your VM label)
4. Accessing Secrets
${{ secrets.DOCKER_USERNAME }}– Securely injects the secret value- Secrets are never printed in logs
Step 5: Commit and Push Your Workflow
# Add the workflow file
git add .github/workflows/docker-ci.yml
# Commit
git commit -m "Add CI/CD workflow"
# Push to trigger the pipeline
git push origin mainMonitoring and Debugging
Where to Find GitHub Actions Data
After pushing your code, here’s how to monitor your pipeline:
1. Navigate to Actions Tab
- Click Actions in the top menu of your repository
- You’ll see a list of all workflow runs
2. Workflow Run Overview
- Click on the latest run (named by your commit message)
- You’ll see three jobs:
build_backend,build_frontend,deploy - Jobs have status indicators:
- 🟡 Yellow (In Progress) – Currently running
- ✅ Green (Success) – Completed successfully
- ❌ Red (Failure) – Failed (click to see logs)
- ⚪ Gray (Queued) – Waiting to run
3. View Job Details
- Click on any job name to see individual steps
- Each step shows:
- Step name
- Execution time
- Detailed logs (expand with ▶)
4. View Real-Time Logs
- While a job is running, logs stream in real-time
- You can see Docker build output, test results, deployment status
5. Download Logs
- Click “…” (three dots) in the top right of the workflow run
- Select Download log archive (ZIP file with all logs)
6. Re-run Failed Workflows
- Click Re-run jobs → Re-run failed jobs
- Or Re-run all jobs to start fresh
7. Check Runner Status
- Go to Settings → Actions → Runners
- See if your runner is online and idle
- View runner labels and recent job history
Understanding Workflow Artifacts
You can store build outputs, test reports, or logs:
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: ./build/Access artifacts:
- Go to workflow run page
- Scroll to Artifacts section at the bottom
- Download the uploaded files
Common Issues and Quick Fixes
Here are the most common issues beginners face with GitHub Actions and how to resolve them:
1. Authentication Failed with Docker Hub
- Error:
unauthorized: incorrect username or passwordorunauthorized: access denied - Fix: Verify
DOCKER_USERNAMEandDOCKER_PASSWORDsecrets are correct; ⚠️ MOST COMMON CAUSE: Docker Hub token has “Read-only” permissions instead of “Read & Write” – regenerate token with correct permissions.
2. Runner Not Found
- Error:
No runner matching the specified labels: deployment_vm - Fix: Check if runner is online in Settings → Actions → Runners; verify the label matches exactly in workflow YAML.
3. Permission Denied on Docker Commands
- Error:
permission denied while trying to connect to the Docker daemon - Fix: Add runner user to Docker group:
sudo usermod -aG docker $USERand restart runner service.
4. Port Already in Use
- Error:
bind: address already in use - Fix: Stop existing containers:
docker compose downbefore deploying, or change port mappings in docker-compose.yml.
5. Image Not Found During Deploy
- Error:
pull access denied, repository does not exist - Fix: Ensure
DOCKER_HUB_REPOenvironment variable matches your Docker Hub username/repository exactly.
6. API Connection Errors (Frontend to Backend)
- Issue: Frontend can’t reach backend API
- Fix: Use relative paths (
/api/) in frontend code, not absolute URLs likehttp://localhost:5000; ensure Nginx proxy configuration matches backend port.
7. Workflow Not Triggering
- Issue: Pushed code but workflow didn’t run
- Fix: Check workflow file is in
.github/workflows/directory; verify branch name matches trigger configuration; check Actions tab for disabled workflows.
8. Secrets Not Working
- Error: Secret value is empty or undefined
- Fix: Secrets are repository-specific; if you forked the repo, you must add secrets again in your fork’s settings.
9. Self-Hosted Runner Offline
- Issue: Runner shows offline status
- Fix: SSH into VM and check runner service:
sudo ./svc.sh status; restart if needed:sudo ./svc.sh restart; verify network connectivity to GitHub (port 443).
10. Docker Compose File Not Found
- Error:
can't open file 'docker-compose.yml' - Fix: Ensure docker-compose.yml is in repository root and checked into Git; runner executes commands from repository root.
Getting Started with the Project
Clone and Run This Project
This repository contains a complete MERN stack application with GitHub Actions CI/CD pipeline pre-configured.
Repository: https://github.com/YOUR_USERNAME/mern-github-actions-cicd
Quick Start Guide:
1. Fork or Clone the Repository
git clone https://github.com/YOUR_USERNAME/mern-github-actions-cicd.git
cd mern-github-actions-cicd2. Set Up Docker Hub
- Create account at hub.docker.com
- Generate access token with ⚠️ Read & Write permissions (NOT Read-only – this will cause
unauthorized: access deniederrors!)- Go to: Account Settings → Security → New Access Token
- Select “Read & Write” permissions
- Copy the token immediately (you can’t view it again)
3. Configure GitHub Repository Secrets
- Go to your repository on GitHub
- Navigate to: Settings → Secrets and variables → Actions
- Add two secrets:
DOCKER_USERNAME= your Docker Hub usernameDOCKER_PASSWORD= your Docker Hub access token (not password!)
4. Update Docker Repository Name
- Open
.github/workflows/docker-ci.yml - Change
DOCKER_HUB_REPO:value toyour_dockerhub_username/mern_stack_app - Commit and push this change
5. Set Up Self-Hosted Runner (if deploying to your VM)
- Follow the detailed runner setup in Step 3 above
- Use label:
deployment_vm - Ensure Docker is installed on the VM
6. Trigger Your First Pipeline
# Make any small change
echo "# CI/CD Pipeline" >> README.md
# Commit and push to trigger the workflow
git add .
git commit -m "Trigger first CI/CD pipeline"
git push origin main7. Monitor the Pipeline
- Go to Actions tab in your GitHub repository
- Click on the latest workflow run
- Watch the three jobs execute: build_backend → build_frontend → deploy
- All jobs should turn green ✅
8. Verify Deployment
- If using self-hosted runner, check your VM:
docker ps # Should show running containers - Access your application:
- Frontend:
http://YOUR_VM_IP:3000 - Backend API:
http://YOUR_VM_IP:5000/api/health
- Frontend:
Project Structure
mern-github-actions-cicd/
├── .github/
│ └── workflows/
│ └── docker-ci.yml # CI/CD pipeline definition
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ └── src/
├── docker-compose.yml # Container orchestration
├── nginx.conf # Reverse proxy config
└── README.mdWhat Happens When You Push Code?
- GitHub Actions triggers (detects push to main branch)
- Build jobs run in parallel on GitHub’s servers:
- Backend Docker image is built and pushed to Docker Hub
- Frontend Docker image is built and pushed to Docker Hub
- Deploy job runs on your self-hosted runner (VM):
- Pulls latest images from Docker Hub
- Stops old containers
- Starts new containers with updated code
- Application is live with your latest changes!
Customizing the Pipeline
To deploy to a different branch:
on:
push:
branches: [ develop ] # Change from 'main' to 'develop'To add manual approval before deployment:
deploy:
runs-on: deployment_vm
environment: production # Creates approval gate
needs: [build_backend, build_frontend]Then configure environment protection rules in: Settings → Environments → production → Required reviewers
To add tests before building:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: npm test
build_backend:
needs: test # Only build if tests pass
runs-on: ubuntu-latest
# ... rest of build stepConclusion
You’ve now learned how to:
- ✅ Understand GitHub Actions core concepts
- ✅ Set up Docker Hub with proper access tokens
- ✅ Configure repository secrets securely
- ✅ Install and configure self-hosted runners
- ✅ Create and trigger CI/CD workflows
- ✅ Monitor pipeline execution and view logs
- ✅ Troubleshoot common issues
GitHub Actions simplifies CI/CD by integrating directly into your development workflow. As a beginner-friendly platform, it removes the infrastructure overhead of traditional CI/CD tools while providing powerful automation capabilities.
Start experimenting with the cloned repository, make changes, and watch your pipeline automatically build and deploy your application. The best way to learn is by doing!
Additional Resources
- GitHub Actions Documentation: docs.github.com/actions
- GitHub Marketplace: github.com/marketplace
- Docker Hub: hub.docker.com
- Self-Hosted Runners Guide: docs.github.com/actions/hosting-your-own-runners
Questions or feedback? Open an issue in the repository or contribute improvements. Happy deploying! 🚀
Keep reading
- SonarQube
Installation To install sonarqube you can simply use this script. After installing this you can simply browse this machine in the ip address of the machine and the port 9000. This is the home page of…
- Github Hooks in Jenkins
🚀 Step 1: Install Required Jenkins Plugins Go to Jenkins Dashboard → Manage Jenkins → Manage Plugins. GitHub Integration GitHub Plugin Pipeline: GitHub (if not installed) Install and Restart…
- Distributed Build Example in Jenkins
Creating 2 VMs to know the concept of multistage distributed builds. Setting up Node in Jenkins master Setting label for Jenkins master Example 1: So first let’s run a simple pipeline in this node…