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.
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
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.
| Component | What it is |
|---|---|
| Workflow | An automated process, defined in a YAML file under .github/workflows/ |
| Event | What starts the workflow: push, pull_request, workflow_dispatch (manual), and others |
| Job | A set of steps that runs on one runner |
| Step | One task in a job: a shell command (run) or a reusable action (uses) |
| Action | A reusable unit of code, from GitHub Marketplace or your own |
| Runner | The 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:
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 Actions | Jenkins | |
|---|---|---|
| Setup | Nothing to install; GitHub runs it | You install and maintain a server, Java and plugins |
| GitHub integration | Built in (PRs, secrets, environments) | Through plugins and webhooks |
| Pipeline language | YAML | Groovy (Jenkinsfile) |
| Cost | Free for public repos; free monthly minutes for private repos | Free software, but you pay for the servers |
| Best for | GitHub-based projects with standard pipelines | Complex or on-premise setups, many agents, full control |
Prerequisites
- A GitHub repository containing the MERN application:
backend/,frontend/,docker-compose.ymlandnginx.conf. - A Docker Hub account.
- A Linux x64 VM (the examples use Ubuntu) with
sudoaccess 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.
- Sign in to Docker Hub.
- Open Account Settings > Security > New Access Token (newer Docker Hub layouts call this Personal access tokens).
- Enter a description such as
GitHub Actions CI/CD. - Set Access permissions to Read & Write.
- Click Generate and copy the token. It is shown only once.
- 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.
- In the repository, go to Settings > Secrets and variables > Actions.
- Click New repository secret and add:
| Name | Value |
|---|---|
DOCKER_USERNAME | Your Docker Hub username |
DOCKER_PASSWORD | The 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.
- In the repository, go to Settings > Actions > Runners > New self-hosted runner.
- 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):
$ 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- Configure the runner:
$ ./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.
- Start the runner.
./run.shruns it in the foreground, which suits a quick test. On a server, install it as a service so it survives logout and reboot:
$ ./run.sh
$ sudo ./svc.sh install
$ sudo ./svc.sh start
$ sudo ./svc.sh status- 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:
$ docker --version
$ docker compose version
$ sudo usermod -aG docker $USERLog 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:
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.mdThe full workflow:
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 -afWhat each part does:
on: the workflow runs on a push tomain, on pull requests tomain, and manually from the Run workflow button (workflow_dispatch).env:DOCKER_HUB_REPOmust match your Docker Hubusername/repository.FRONTEND_API_BASE_URLis passed into the React build asREACT_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_vmroutes the job to the self-hosted runner, andneedsmakes it wait for both builds. The job checks out the repository to getdocker-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:
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
$ git add .github/workflows/docker-ci.yml
$ git commit -m "Add CI/CD workflow"
$ git push origin mainOpen 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:
- 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:
$ docker psThen 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:
on:
push:
branches: [ develop ]Require manual approval before deploy:
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:
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 aboveTroubleshooting
| Problem | Cause and fix |
|---|---|
unauthorized: access denied on push | The 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 password | Wrong DOCKER_USERNAME or token in the secrets. |
No runner matching the specified labels: deployment_vm | The 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 daemon | The runner's user is not in the docker group. Run sudo usermod -aG docker $USER, then restart the runner service. |
| Runner shows Offline | On 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 exist | DOCKER_HUB_REPO does not match your Docker Hub username/repository. |
bind: address already in use | Another container or process uses the port. docker compose down first, or change the port mapping in docker-compose.yml. |
| Compose file not found | docker-compose.yml must be committed at the repository root. The deploy job runs from the checked-out repo. |
| Frontend cannot reach the backend | Use the relative /api/ path, not http://localhost:5000, and make sure nginx.conf proxies to the right backend port. |
| Workflow does not start | The 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 empty | Secrets belong to one repository. A fork needs its own secrets. |
Key takeaways
- Jobs run in parallel by default;
needsadds order and acts as a gate before deploy. - GitHub-hosted runners build and push the images; a self-hosted runner with the
deployment_vmlabel 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
ifcondition on the deploy job keeps unreviewed pull requests from reaching the VM.
See the GitHub Actions documentation for the full workflow syntax.
Keep reading
- Jenkins Distributed Builds with Agents and Labels
Connect two Vagrant VMs to Jenkins as SSH agents, use labels to choose where each stage runs, and ship a Java app and a Node.js app across separate nodes.
- Jenkins Pipeline for Building and Deploying Docker Images
Write a declarative Jenkinsfile that builds a Maven app, packs it into a Docker image, scans it with Trivy, pushes it to Docker Hub, deploys it and emails the team.
- SonarQube and Nexus in a Jenkins Pipeline
Install SonarQube and Sonatype Nexus, define a quality gate, and extend a Jenkins pipeline to scan Java code and publish each WAR build to a Nexus repository.