CI/CD Pipelines in Practice: Test, Build, and Deploy a Node.js API with GitHub Actions

CI/CD Pipelines in Practice: Test, Build, and Deploy a Node.js API with GitHub Actions

A CI/CD pipeline turns your release process into repeatable steps: install dependencies, run tests, build the app, and deploy only when everything passes. For junior and mid-level developers, the biggest win is not “automation for automation’s sake.” It is reducing the chance that a broken commit reaches production.

In this article, we will build a practical GitHub Actions workflow for a small Node.js API. The same structure works for many web projects: Express, Fastify, NestJS, React, Vue, Laravel, or any app that can be tested and built from the command line.

Project Setup

Assume you have a simple Express API with this structure:

my-api/ ├── src/ │ └── server.js ├── test/ │ └── health.test.js ├── package.json ├── Dockerfile └── .github/ └── workflows/ └── ci.yml

Install the required dependencies:

npm init -y npm install express npm install --save-dev jest supertest

Your package.json should include scripts that the pipeline can run:

{ "name": "my-api", "version": "1.0.0", "type": "commonjs", "scripts": { "start": "node src/server.js", "test": "jest", "build": "node -e \"console.log('Build step passed')\"" }, "dependencies": { "express": "^4.18.3" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^6.3.4" } }

The build script is intentionally simple here. In a real app, it might compile TypeScript, bundle frontend assets, or generate production files.

Create a Small API

Create src/server.js:

const express = require("express"); const app = express(); app.get("/health", (req, res) => { res.status(200).json({ status: "ok", service: "my-api" }); }); if (require.main === module) { const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`API running on port ${port}`); }); } module.exports = app;

The /health endpoint is useful because deployment platforms, load balancers, and CI tests can use it to check whether the app responds correctly.

Add a Test That the Pipeline Can Trust

Create test/health.test.js:

const request = require("supertest"); const app = require("../src/server"); describe("GET /health", () => { it("returns service health information", async () => { const response = await request(app).get("/health"); expect(response.status).toBe(200); expect(response.body).toEqual({ status: "ok", service: "my-api" }); }); });

Run the test locally before adding CI:

npm test

A pipeline should not be the first place where you discover basic test failures. Always make sure the commands work locally first.

Create the GitHub Actions CI Workflow

Create .github/workflows/ci.yml:

name: CI on: pull_request: branches: - main push: branches: - main jobs: test-and-build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run build run: npm run build

This pipeline runs on two events:

  • pull_request: checks code before it is merged into main.
  • push: checks the final code after it lands on main.

Use npm ci instead of npm install in CI. It installs dependencies from package-lock.json and fails if the lockfile is out of sync. That makes your pipeline more predictable.

Add a Docker Build Step

Many teams deploy containers rather than raw source code. Add a Dockerfile:

FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY src ./src ENV NODE_ENV=production EXPOSE 3000 CMD ["npm", "start"]

This Dockerfile installs only production dependencies inside the image. The tests still run in GitHub Actions before the image is built.

Now update the workflow to verify that the Docker image builds successfully:

name: CI on: pull_request: branches: - main push: branches: - main jobs: test-build-docker: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run build run: npm run build - name: Build Docker image run: docker build -t my-api:${{ github.sha }} .

The tag ${{ github.sha }} uses the current commit hash. That is useful because every image can be linked back to the exact source code that produced it.

Add Deployment Only After CI Passes

Do not deploy from every pull request. A safer pattern is:

  • Run tests and builds on pull requests.
  • Deploy only from main.
  • Keep secrets outside the repository.

Here is a simple deployment job that runs only after CI passes on main:

name: CI/CD on: pull_request: branches: - main push: branches: - main jobs: test-build-docker: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run build run: npm run build - name: Build Docker image run: docker build -t my-api:${{ github.sha }} . deploy: needs: test-build-docker runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Deploy placeholder run: echo "Deploying commit ${{ github.sha }} to production"

The important parts are needs and if. The needs key means deployment will not start unless the previous job succeeds. The if condition prevents deployment from pull requests or other branches.

Using Secrets Safely

Real deployments usually need credentials: SSH keys, API tokens, registry passwords, or cloud provider keys. Never commit those values into your repository. Store them as GitHub Actions secrets, then read them in the workflow.

deploy: needs: test-build-docker runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Deploy with secret token env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: | if [ -z "$DEPLOY_TOKEN" ]; then echo "DEPLOY_TOKEN is missing" exit 1 fi echo "Deploying with a configured secret token"

This example does not print the secret. That matters. Logs are often visible to several team members, and leaking credentials in CI logs can become a production incident.

Practical CI/CD Rules for Small Teams

  • Keep the first pipeline small. Start with install, test, and build. Add deployment after the basics are stable.
  • Fail early. Put fast checks before slow checks. Tests should run before Docker builds or deployment.
  • Use branch protection. Require CI to pass before merging into main.
  • Make scripts local-first. If npm test works locally and in CI, debugging is easier.
  • Avoid secret sprawl. Give each environment only the secrets it needs.

Common Mistakes to Avoid

A common mistake is putting too much logic directly into YAML. If deployment requires ten shell commands, consider moving them into a script such as scripts/deploy.sh. The workflow should orchestrate steps, not become an unreadable deployment program.

#!/usr/bin/env sh set -e echo "Pull latest image" echo "Restart service" echo "Run post-deploy health check"

Then call it from your workflow:

- name: Run deploy script run: sh scripts/deploy.sh

Another mistake is ignoring the deployment result. A better deploy step should include a health check after release:

- name: Check production health run: | curl --fail https://example.com/health echo "Production health check passed"

curl --fail exits with an error when the response is not successful, which makes the pipeline fail instead of hiding a broken deployment.

Conclusion

A useful CI/CD pipeline does not need to be complex. For most web projects, start with a reliable sequence: install dependencies, run tests, run the build, build the Docker image, and deploy only from main. Once that works, add environment-specific deployments, health checks, container registry publishing, and rollback steps. The goal is simple: every release should be repeatable, visible, and safer than a manual deploy from your laptop.


Leave a Reply

Your email address will not be published. Required fields are marked *