Skip to content
DBDeependra Bhatta~/notes
CI/CD#container-registry · #docker · #nodejs · #docker-compose · #github-actions

MERN Stack CI/CD Pipeline with GitHub Actions and a Self-Hosted Runner

Build a GitHub Actions pipeline that builds MERN stack Docker images, pushes them to Docker Hub and deploys them to a VM through a self-hosted runner.

· updated · 13 min read
ON THIS PAGE

Deploying a containerized application by hand means building images, pushing them to a registry and restarting containers on the server after every change. This guide automates that flow for a MERN application (MongoDB, Express, React, Node.js) with GitHub Actions: on every push to main, GitHub-hosted runners build the backend and frontend Docker images and push them to Docker Hub, then a self-hosted runner on a VM pulls the new images and restarts the stack with Docker Compose. The setup covers the Docker Hub token, repository secrets, the self-hosted runner and the workflow file.

How the pipeline works

TXTPlain text
git push to main
      |
      v
+----------------------------+     +----------------------------+
| build_backend              |     | build_frontend             |   GitHub-hosted runners
| (ubuntu-latest)            |     | (ubuntu-latest)            |   run in parallel
| build image, push to       |     | build image, push to       |
| Docker Hub                 |     | Docker Hub                 |
+-------------+--------------+     +--------------+-------------+
              |                                   |
              +-----------------+-----------------+
                                |  needs: both
                                v
                 +-----------------------------+
                 | deploy                      |   self-hosted runner
                 | (label: deployment_vm)      |   on my VM
                 | docker compose pull, up -d  |
                 +-----------------------------+

GitHub Actions basics

GitHub Actions is the CI/CD service built into GitHub. A workflow runs in response to a repository event, such as a push, a pull request or a manual trigger.

ComponentWhat it is
WorkflowAn automated process, defined in a YAML file under .github/workflows/
EventWhat starts the workflow: push, pull_request, workflow_dispatch (manual), and others
JobA set of steps that runs on one runner
StepOne task in a job: a shell command (run) or a reusable action (uses)
ActionA reusable unit of code, from GitHub Marketplace or your own
RunnerThe machine that runs a job: GitHub-hosted (for example ubuntu-latest) or self-hosted (your own server)

Parallel and sequential jobs

By default, all jobs in a workflow run in parallel. To make a job wait, use needs:

YMLYAML
jobs:
  build_backend:
    runs-on: ubuntu-latest
    steps:
      - run: echo "build backend"
 
  build_frontend:
    runs-on: ubuntu-latest
    steps:
      - run: echo "build frontend"
 
  deploy:
    runs-on: deployment_vm
    needs: [build_backend, build_frontend]   # starts only if both succeed
    steps:
      - run: echo "deploy"

needs: build waits for one job; needs: [job1, job2] waits for all of them. If a required job fails, the dependent job is skipped. In this pipeline, needs guarantees that the deploy never runs unless both images were built and pushed. Keep independent work (builds, linting, tests of separate modules) parallel so the pipeline stays fast.

GitHub Actions vs Jenkins

The same pipeline can be built with Jenkins (see the Jenkins CI/CD series). The two tools compare as follows:

GitHub ActionsJenkins
SetupNothing to install; GitHub runs itYou install and maintain a server, Java and plugins
GitHub integrationBuilt in (PRs, secrets, environments)Through plugins and webhooks
Pipeline languageYAMLGroovy (Jenkinsfile)
CostFree for public repos; free monthly minutes for private reposFree software, but you pay for the servers
Best forGitHub-based projects with standard pipelinesComplex or on-premise setups, many agents, full control

Prerequisites

  • A GitHub repository containing the MERN application: backend/, frontend/, docker-compose.yml and nginx.conf.
  • A Docker Hub account.
  • A Linux x64 VM (the examples use Ubuntu) with sudo access and outbound HTTPS to GitHub.

Step 1: Create a Docker Hub access token

The pipeline pushes images to Docker Hub, so it needs credentials. Use an access token rather than your account password.

  1. Sign in to Docker Hub.
  2. Open Account Settings > Security > New Access Token (newer Docker Hub layouts call this Personal access tokens).
  3. Enter a description such as GitHub Actions CI/CD.
  4. Set Access permissions to Read & Write.
  5. Click Generate and copy the token. It is shown only once.
  6. Optional: create a repository, for example mern_stack_app. Docker Hub can also create it on the first push.

Step 2: Add the secrets to GitHub

Secrets are encrypted values that workflows read at run time. GitHub masks them in job logs.

  1. In the repository, go to Settings > Secrets and variables > Actions.
  2. Click New repository secret and add:
NameValue
DOCKER_USERNAMEYour Docker Hub username
DOCKER_PASSWORDThe access token from Step 1 (not your account password)

After saving, the values cannot be viewed again, only replaced. Never echo a secret in a workflow step. A token can be rotated at any time without changing your Docker Hub password.

Step 3: Set up a self-hosted runner on the VM

A self-hosted runner is a lightweight agent that runs on your own machine. It opens an outbound HTTPS connection to GitHub (port 443) and runs the jobs that target it, so no inbound port needs to be opened on the VM.

  1. In the repository, go to Settings > Actions > Runners > New self-hosted runner.
  2. Choose the VM's operating system (Linux, x64). GitHub shows the exact commands, with the current runner version and a short-lived registration token. Copy them from that page. They look like this (version 2.311.0 was current when I set it up):
terminal
$ mkdir actions-runner && cd actions-runner
$ 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
$ tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz
  1. Configure the runner:
terminal
$ ./config.sh --url https://github.com/<user>/<repo> --token <registration-token>

At the prompts, press Enter to accept the default runner name and work folder. When prompted for labels, enter deployment_vm. The workflow uses this label to route the deploy job to this machine.

  1. Start the runner. ./run.sh runs it in the foreground, which suits a quick test. On a server, install it as a service so it survives logout and reboot:
terminal
$ ./run.sh
$ sudo ./svc.sh install
$ sudo ./svc.sh start
$ sudo ./svc.sh status
  1. Confirm the connection. The terminal shows √ Connected to GitHub, and the runner appears as Idle under Settings > Actions > Runners.

Step 4: Install Docker on the VM

The deploy job runs docker compose on the VM, so Docker Engine and the Compose v2 plugin must be installed there. Install both from Docker's apt repository by following the official Ubuntu install guide (the full steps are in the Docker install guide). Then add the runner's user to the docker group so it can run Docker without sudo:

terminal
$ docker --version
$ docker compose version
$ sudo usermod -aG docker $USER

Log out and back in (or restart the runner service), then confirm with docker ps.

Step 5: Write the workflow

The repository is laid out as follows:

TXTPlain text
mern-github-actions-cicd/
├── .github/
│   └── workflows/
│       └── docker-ci.yml      # CI/CD pipeline
├── backend/
│   ├── Dockerfile
│   ├── package.json
│   └── server.js
├── frontend/
│   ├── Dockerfile
│   ├── package.json
│   └── src/
├── docker-compose.yml         # runs the containers on the VM
├── nginx.conf                 # reverse proxy for the frontend and /api
└── README.md

The full workflow:

YMLdocker-ci.yml
.githubworkflowsdocker-ci.yml
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 -af

What each part does:

  • on: the workflow runs on a push to main, on pull requests to main, and manually from the Run workflow button (workflow_dispatch).
  • env: DOCKER_HUB_REPO must match your Docker Hub username/repository. FRONTEND_API_BASE_URL is passed into the React build as REACT_APP_API_URL. It is a relative path (/api/), so the browser calls the same host and Nginx forwards the request to the backend.
  • Build jobs: each one checks out the code, logs in to Docker Hub with the secrets, then builds and pushes one image with docker/build-push-action. The two jobs run in parallel on GitHub-hosted runners.
  • deploy: runs-on: deployment_vm routes the job to the self-hosted runner, and needs makes it wait for both builds. The job checks out the repository to get docker-compose.yml, pulls the new images, recreates the containers, and removes unused images so the VM disk does not fill up.

To build on pull requests but deploy only after the merge to main, add a condition to the deploy job:

±changes.diff+1−0
   deploy:
     name: Deploy to Production VM
     runs-on: deployment_vm
     needs: [build_backend, build_frontend]
+    if: github.event_name != 'pull_request'

Step 6: Push and watch the run

terminal
$ git add .github/workflows/docker-ci.yml
$ git commit -m "Add CI/CD workflow"
$ git push origin main

Open the Actions tab in the repository and select the latest run, which is named after the commit message. The run shows three jobs: the two builds run side by side, then deploy starts. Select a job to see each step with its logs, which stream live while the job runs. From the run page, use Re-run failed jobs or download all logs from the ... menu.

To keep files that a job produces, such as a build folder or test reports, upload them as artifacts. They appear at the bottom of the run page:

YMLYAML
- name: Upload build artifacts
  uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: ./build/

Step 7: Verify the deployment

On the VM, confirm that the containers are running:

terminal
$ docker ps

Then open the application in a browser:

  • Frontend: http://<vm-ip>:3000
  • Backend health check: http://<vm-ip>:5000/api/health

Optional changes

Deploy from another branch:

YMLYAML
on:
  push:
    branches: [ develop ]

Require manual approval before deploy:

YMLYAML
deploy:
  runs-on: deployment_vm
  environment: production
  needs: [build_backend, build_frontend]

Then add Required reviewers under Settings > Environments > production. The deploy job waits until a reviewer approves the deployment.

Run tests before building:

YMLYAML
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test
 
  build_backend:
    needs: test   # build only if tests pass
    runs-on: ubuntu-latest
    # ... build steps as above

Troubleshooting

ProblemCause and fix
unauthorized: access denied on pushThe Docker Hub token is Read-only. Create a new token with Read & Write and update DOCKER_PASSWORD. Also check the username secret.
unauthorized: incorrect username or passwordWrong DOCKER_USERNAME or token in the secrets.
No runner matching the specified labels: deployment_vmThe runner is offline or the label is different. Check Settings > Actions > Runners and make the label match runs-on exactly.
permission denied while trying to connect to the Docker daemonThe runner's user is not in the docker group. Run sudo usermod -aG docker $USER, then restart the runner service.
Runner shows OfflineOn the VM run sudo ./svc.sh status and sudo ./svc.sh start (or stop then start). Check that outbound HTTPS (port 443) to GitHub works.
pull access denied, repository does not existDOCKER_HUB_REPO does not match your Docker Hub username/repository.
bind: address already in useAnother container or process uses the port. docker compose down first, or change the port mapping in docker-compose.yml.
Compose file not founddocker-compose.yml must be committed at the repository root. The deploy job runs from the checked-out repo.
Frontend cannot reach the backendUse the relative /api/ path, not http://localhost:5000, and make sure nginx.conf proxies to the right backend port.
Workflow does not startThe file must be in .github/workflows/, the branch must match the trigger, and the workflow must not be disabled in the Actions tab.
Secrets are emptySecrets belong to one repository. A fork needs its own secrets.

Key takeaways

  • Jobs run in parallel by default; needs adds order and acts as a gate before deploy.
  • GitHub-hosted runners build and push the images; a self-hosted runner with the deployment_vm label deploys to the VM without any inbound ports open.
  • Store Docker Hub credentials as repository secrets, and give the token Read & Write permission or the push fails.
  • Install Docker Compose v2 on the VM, because the deploy step uses docker compose.
  • An if condition on the deploy job keeps unreviewed pull requests from reaching the VM.

See the GitHub Actions documentation for the full workflow syntax.