Skip to main content
Your First CI/CD Pipeline with GitHub Actions

Your First CI/CD Pipeline with GitHub Actions

KU
• 6 min read•Updated •29 views

Walk through a working GitHub Actions workflow — install, test, build, deploy — for a tiny Node app. Every line explained.

By the end of this post you'll have a working CI/CD pipeline that runs tests on every push, builds a Docker image, and deploys it — all triggered by a git push. We'll use a tiny Node.js app and GitHub Actions. About 30 minutes.

You'll need a GitHub account, Node.js 20+ installed locally, and Docker installed.

What CI/CD actually means

CI (Continuous Integration) is the practice of running checks (tests, linting, builds) automatically every time you push code. The point: you find out something broke within minutes, not days.

CD (Continuous Deployment / Delivery) is the practice of automatically pushing code that passes CI to a real environment. Continuous Deployment goes all the way to production; Continuous Delivery stops at staging and waits for a human to click deploy.

GitHub Actions is one tool for running CI/CD. The same patterns apply to GitLab CI, CircleCI, Jenkins, and the rest. We'll focus on Actions because it's free for public repos and built into GitHub.

Step 1: Create a tiny app

bash.bash
mkdir hello-ci && cd hello-ci
npm init -y
npm install express
npm install --save-dev jest supertest

Edit package.json and add scripts:

json.json
{
  "scripts": {
    "start": "node index.js",
    "test": "jest"
  }
}

Create index.js:

javascript.javascript
const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.json({ message: "Hello from CI/CD!", time: new Date().toISOString() });
});

if (require.main === module) {
  app.listen(3000, () => console.log("Listening on 3000"));
}

module.exports = app;

Create index.test.js:

javascript.javascript
const request = require("supertest");
const app = require("./index");

test("root returns greeting", async () => {
  const res = await request(app).get("/");
  expect(res.status).toBe(200);
  expect(res.body.message).toContain("Hello");
});

Run it locally to confirm it works:

bash.bash
npm test

You should see one test pass.

Step 2: Add a Dockerfile

We'll build the app into a container as part of the pipeline. Save this as Dockerfile:

dockerfile.dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.js .
EXPOSE 3000
CMD ["node", "index.js"]

Test the build locally:

bash.bash
docker build -t hello-ci:local .
docker run --rm -p 3000:3000 hello-ci:local
# In another terminal: curl http://localhost:3000

Step 3: Push to GitHub

Create a new repo on github.com (no README, no gitignore — keep it empty). Then locally:

bash.bash
git init
git add .
git commit -m "initial"
git branch -M main
git remote add origin https://github.com/<your-username>/hello-ci.git
git push -u origin main

GitHub now has your code. Time to add the pipeline.

Step 4: Write the workflow

GitHub Actions workflows live in .github/workflows/<name>.yml. Create .github/workflows/ci.yml:

yaml.yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup 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

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t hello-ci:${{ github.sha }} .

What each part does:

  • on: — triggers. Run on every push to main and on every PR opened against main.
  • jobs: — independent units of work. Each gets its own VM.
  • runs-on: ubuntu-latest — GitHub provides a fresh Ubuntu VM for each run.
  • uses: actions/checkout@v4 — clones your repo into the VM.
  • uses: actions/setup-node@v4 — installs Node.js. The cache: "npm" line caches node_modules between runs to make the next build faster.
  • needs: test — the build job waits for the test job to pass before running.
  • ${{ github.sha }} — built-in variable for the current commit SHA, used as the image tag.

Commit and push:

bash.bash
git add .github/workflows/ci.yml
git commit -m "add CI workflow"
git push

Within seconds, go to your repo's "Actions" tab on GitHub. You should see a run starting, with two jobs: test and build. After about 30 seconds both should turn green.

Step 5: Make it fail and watch the feedback

To verify the pipeline actually catches problems, break the test:

javascript.javascript
// in index.test.js, change "Hello" to "Goodbye"
expect(res.body.message).toContain("Goodbye");
bash.bash
git commit -am "intentional break"
git push

The test job will fail. The build job (which depended on test) will be skipped. GitHub will email you about the failure. You'll also see a red ✗ next to the commit on the repo's main page.

Revert the test:

bash.bash
# change "Goodbye" back to "Hello"
git commit -am "fix"
git push

Pipeline goes green again.

Step 6: Add a deploy step (optional)

Real deploy steps depend on where you're deploying. A minimal pattern that works for Vercel, Fly.io, AWS, etc.:

yaml.yaml
  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy.sh

The if: github.ref == 'refs/heads/main' ensures deploys only happen for the main branch, not PRs. The secrets.DEPLOY_TOKEN is a GitHub repo secret you'd configure under Settings → Secrets and variables → Actions.

Common mistakes

Storing tokens in the workflow file. Anything committed to the repo is public for public repos. Always use ${{ secrets.NAME }} and configure the actual value in the GitHub UI.

Skipping npm ci in favor of npm install. npm ci reads the lockfile and refuses to update it — meaning CI gets exactly the dependencies you tested with. npm install may resolve fresh versions and break things.

Forgetting needs: between jobs. Without needs:, jobs run in parallel. That sounds fast but means you might deploy code that hasn't been tested yet. Use needs: to enforce order.

Long-running workflows. A pipeline that takes 20 minutes blocks every PR. Cache aggressively (npm, Docker layers, build outputs) and parallelize. Aim for under 5 min.

You have a working pipeline. The next levels:

CI/CD isn't a complex technology. It's a discipline of running the same checks every time. Once you have a basic pipeline running, every improvement is incremental — faster, more checks, smarter deploys.

Explore topics:CI/CDDevOps
React
Share this post
KU

Kiril Urbonas

AI Engineer

560 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.