Learn what CI/CD is and build a GitHub Actions pipeline for an Express 5 API that runs tests against a real database, builds a Docker image and deploys safely.

Bishal Biswas
Self Learner
What you will learn
- What continuous integration (CI) and continuous delivery/deployment (CD) mean
- How to write a GitHub Actions workflow that installs, lints and tests your Express API
- How to run tests against a real MongoDB using service containers
- How to build and publish a Docker image, then deploy and smoke-test it
- How to keep secrets safe and stop broken code from reaching
main
The problem: “I forgot to run the tests”
You’ve written tests. You’ve written a Dockerfile. But both only help if someone actually runs them, every time, before code goes live. People forget. People are in a hurry on Friday afternoon.
CI/CD hands that job to a robot.
- Continuous integration (CI): every time someone pushes code or opens a pull request, a server automatically installs dependencies and runs the tests. Problems are caught within minutes, while the change is still fresh in your mind.
- Continuous delivery (CD): after CI passes, the code is automatically packaged (for example as a Docker image) and made ready to deploy. A human presses the final button.
- Continuous deployment: the same, but there is no button. Every passing change on
maingoes live automatically.
Think of a car factory. CI is the quality inspection at every station. CD is the conveyor belt that takes inspected cars to the showroom.
git push / pull request
|
v
+-------------+ fails? -> stop, show a red X on the PR
| CI: test | install, lint, test (with a real database)
+-------------+
| passes (main branch only)
v
+-------------+
| Build | docker build, push image to a registry
+-------------+
|
v
+-------------+
| Deploy | tell the server to run the new image
+-------------+
|
v
+-------------+
| Smoke test | curl /health on the live site
+-------------+
GitHub Actions basics
GitHub Actions is the CI/CD service built into GitHub. It’s free for public repositories and has a free monthly allowance for private ones. Other tools (GitLab CI, CircleCI, Jenkins) follow the same ideas.
A few terms:
| Term | Meaning |
|---|---|
| Workflow | A YAML file in .github/workflows/ describing an automated process |
Trigger (on) |
The event that starts it: a push, a pull request, a schedule… |
| Job | A group of steps that runs on one fresh virtual machine (a runner) |
| Step | One command (run:) or one ready-made action (uses:) |
| Action | A reusable building block, like actions/checkout to download your code |
Jobs run in parallel by default. Use needs: to make one wait for another.
Prepare your package.json
The pipeline runs the same commands you run locally, so make them simple and reliable:
{
"name": "notes-api",
"type": "module",
"scripts": {
"start": "node server.js",
"lint": "eslint .",
"test": "node --test"
},
"engines": {
"node": ">=22"
}
}
node --test finds files like *.test.js automatically. Make sure package-lock.json is committed, because CI uses npm ci, which needs it.
Step 1: A first CI workflow
Create this file in your repository:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint --if-present
- name: Run tests
run: npm test
What happens:
actions/checkout@v4downloads your repository onto the runner.actions/setup-node@v4installs Node 22.cache: npmsaves npm’s download cache between runs, so installs get faster.npm ciinstalls the exact versions from your lock file.--if-presentskips the lint step if you don’t have alintscript yet.- If any step exits with a non-zero code (a failing test, for example), the job fails.
Commit and push. Open the Actions tab on GitHub to watch it run. On a pull request you’ll see a green check or a red cross next to each commit.
Step 2: Test against a real database
Unit tests with fakes are great, but you also want a few integration tests against a real database. GitHub Actions can start service containers: Docker containers that run next to your job for its whole lifetime.
# .github/workflows/ci.yml (test job with MongoDB)
jobs:
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.runCommand({ping:1})'"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MONGO_URL: mongodb://localhost:27017/notes_test
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint --if-present
- run: npm test
The --health-* options make GitHub wait until MongoDB answers before running your steps. Your tests read process.env.MONGO_URL exactly as they would locally.
Tip: Give each test file its own database name, or clear collections in a
beforeEachhook, so tests don’t depend on each other’s leftovers.
Testing on several Node versions
If you publish a library or support several Node versions, a matrix runs the same job for each value:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
For an app that only ever runs on one version in production, testing that one version is usually enough.
Extra checks worth adding
- name: Check for known vulnerabilities
run: npm audit --omit=dev --audit-level=high
npm audit compares your dependencies with a database of known security problems. --omit=dev ignores dev-only packages and --audit-level=high only fails for serious issues, which keeps the pipeline from failing over minor warnings.
Step 3: Build and publish a Docker image
Once tests pass on main, package the app. We’ll push the image built from the Dockerfile in Dockerising an Express API to the GitHub Container Registry (GHCR), a free place to store images next to your code.
# .github/workflows/ci.yml (add a second job)
docker:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/your-github-name/notes-api:latest
ghcr.io/your-github-name/notes-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
needs: testmeans this job only starts if the tests passed.- The
if:line skips it for pull requests, so onlymainproduces images. secrets.GITHUB_TOKENis created automatically for each run.packages: writegives it permission to push.- Tagging with
github.sha(the commit ID) gives every image a unique name, so you can always roll back to an exact version. cache-from/cache-towithtype=ghareuse Docker layers between runs.
Note: Registry image names must be lowercase. Replace
your-github-namewith your GitHub user or organisation name in lowercase.
Step 4: Deploy and smoke-test
How you deploy depends on where you host. Many platforms (Render, Railway, Fly.io and others) offer a deploy hook: a secret URL that starts a deployment when you send it a request. Here’s the general pattern:
# .github/workflows/ci.yml (add a third job)
deploy:
needs: docker
runs-on: ubuntu-latest
environment: production
steps:
- name: Trigger deployment
env:
DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
run: curl -fsS -X POST "$DEPLOY_HOOK_URL"
- name: Smoke test
run: |
curl -fsS --retry 10 --retry-delay 6 --retry-all-errors \
https://api.example.com/health
- A smoke test is a tiny check that the live app is basically working, here just “does
/healthanswer?”. The name comes from electronics: switch it on and see if smoke comes out. curl -fmakes curl fail on HTTP errors, so the step fails if the app is down.--retrygives the new version time to start.environment: productionlinks the job to a GitHub environment. In your repository settings you can require a manual approval for that environment. That turns continuous deployment into continuous delivery: the pipeline pauses until someone clicks “Approve”.
Secrets: keep them out of your code
A secret is a value like an API key, a deploy hook URL or a database password. Never write them in the workflow file or commit them. Store them in Settings > Secrets and variables > Actions and read them as ${{ secrets.NAME }}.
Warning: Pass secrets to commands through
env:, as in the deploy step above, rather than pasting${{ secrets.X }}directly into arun:line. GitHub hides secret values in logs, but theenv:style avoids accidental leaks through shell quoting and is easier to review.
Note: Secrets are not given to workflows triggered by pull requests from forks. That’s a safety feature: a stranger can’t open a PR that prints your secrets. Design your test job so it doesn’t need any.
Protect the main branch
CI only protects you if broken code can’t skip it. In your repository, go to Settings > Branches (or Rules) and add a rule for main:
- Require a pull request before merging.
- Require status checks to pass, and select your
testjob.
Now nobody, including you, can merge a pull request with failing tests.
Keeping dependencies fresh
Outdated packages slowly collect security problems. Dependabot, built into GitHub, opens pull requests to update them, and your CI tests each one automatically:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Making the pipeline fast
A slow pipeline gets ignored. Some easy wins:
| Tip | How |
|---|---|
| Cache npm downloads | cache: npm in actions/setup-node |
| Cache Docker layers | cache-from: type=gha |
| Cancel outdated runs | A concurrency block (below) |
| Run independent jobs in parallel | Only use needs: where order really matters |
| Keep unit tests fast | Use fakes for most tests, a real DB only for a few |
# .github/workflows/ci.yml (top level, next to "on:")
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
If you push three commits quickly to the same branch, only the newest run continues.
Common mistakes
Common mistake: Using
npm installin CI. It can change versions and update the lock file. Usenpm ci.
Common mistake: Tests that pass locally but fail in CI because they depend on your
.envfile. Set the needed values withenv:in the workflow, and never commit.env.
Common mistake: Deploying on every push to every branch. Limit deploy jobs with
if: github.ref == 'refs/heads/main'andneeds:.
Common mistake: Only tagging images as
latest. Without a unique tag such as the commit SHA, you can’t tell what’s running or roll back to a known good version.
Key takeaways
- CI runs your install, lint and tests automatically on every push and pull request.
- CD packages and deploys passing code; add a manual approval for continuous delivery.
- In GitHub Actions use
actions/checkout@v4,actions/setup-node@v4withnode-version: 22andcache: npm, thennpm ciandnpm test. - Service containers give your tests a real MongoDB or PostgreSQL for each run.
- Tag images with the commit SHA, smoke-test after deploying, and keep secrets in GitHub’s secret store.
- Protect
mainso failing checks block merges.
Practice
- Add the first CI workflow to your Notes API repository and open a pull request that breaks a test. Watch the red cross appear.
- Add a MongoDB service container and write one integration test that creates and reads a note.
- Add the Docker job and pull the image back with
docker pull ghcr.io/<your-name>/notes-api:<sha>. - Set up branch protection on
mainso thetestjob must pass before merging.
What’s next
Your pipeline proves the code works. Next, find out how much traffic it can handle and how to make it faster: Load Testing and Performance Tuning.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready