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.
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.
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.
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
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.
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. 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.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.
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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
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 in plain words — what it actually is, the workflow it enables, and a hands-on demo using Argo CD on a local Kubernetes cluster.
Explore more articles in this category
Run your first three Kubernetes objects — Pod, Deployment, Service — on a local cluster, then understand why each one exists and how they fit together.
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.
Three layers of pooling, three different jobs. We learned the hard way which to use when. Real numbers from a 8k-connection workload.
${{ github.sha }} — built-in variable for the current commit SHA, used as the image tag.