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.
Key takeaways
- Walk through a working GitHub Actions workflow — install, test, build, deploy — for a tiny Node app.
- Every line explained.
On this page
Your First CI/CD Pipeline with GitHub Actions#
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
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
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.