Your First CI/CD Pipeline with GitHub Actions
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
mkdir hello-ci && cd hello-ci
npm init -y
npm install express
npm install --save-dev jest supertest
Edit package.json and add scripts:
{
"scripts": {
"start": "node index.js",
"test": "jest"
}
}
Create index.js:
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:
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:
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:
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:
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:
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:
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. Thecache: "npm"line cachesnode_modulesbetween 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:
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:
// in index.test.js, change "Hello" to "Goodbye"
expect(res.body.message).toContain("Goodbye");
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:
# 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.:
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.
What to read next
You have a working pipeline. The next levels:
- Scalable CI/CD with GitHub Actions — patterns we use across 80 repos
- CI/CD pipeline optimization: where the time goes — how to cut a 28-minute build to 6 minutes
- Canary releases: a gradual rollout strategy — the next step after "automated deploy"
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.
Docker for Beginners — Build, Run, and Ship Your First Container
Walk through your first Dockerfile, container run, and image push in 30 minutes. No theory dumps — just the commands and what each one is doing.
GitOps Explained — What It Is and Why Teams Adopt It
GitOps in plain words — what it actually is, the workflow it enables, and a hands-on demo using Argo CD on a local Kubernetes cluster.
More from DevOps
Explore more articles in this category
GitHub Actions Workflow Lockfiles: Pin Every Action, Including the Ones You Can't See
GitHub now records the exact commit of every direct and transitive action in .github/workflows/actions.lock. Here is how to adopt it, what it fixes that SHA pinning never did, and where it still leaks.
GitHub Actions macOS 14 Retirement: Fix Your Builds Before Nov 2
GitHub retires the macOS 14 runner image on November 2, and brownouts are already failing jobs. Here is how to find every macos-14 reference and move without breaking Xcode.
PHP2Shell (CVE-2026-109618, CVE-2026-109619): Patch Before the Bots Do
Two unauthenticated RCEs in PHP core, leaked a day before the patch, with exploit attempts already logged. Here is how to tell if you are exposed and what to do tonight.
You might have missed
Evergreen posts worth revisiting.