Every time you push code, a few things should happen automatically: your tests should run, your code should get checked for obvious mistakes, and — if everything passes — your app should deploy. Doing all of that by hand is slow and easy to forget. That's exactly the gap CI/CD fills.
In this guide, you'll build a real, working CI/CD pipeline from scratch using GitHub Actions — no prior DevOps experience needed. By the end, every push to your repo will automatically install dependencies, lint your code, run your tests, and (optionally) deploy. It takes about 15 minutes.
Let's demystify the jargon first, then build.
What CI/CD actually means
- CI (Continuous Integration): every time code is pushed, it's automatically built and tested. The goal is to catch broken code before it ever reaches your main branch.
- CD (Continuous Delivery/Deployment): once code passes CI, it's automatically prepared for release — or shipped straight to production.
GitHub Actions is GitHub's built-in automation tool. It watches your repository for events (like a push or a pull_request) and runs jobs you define in a YAML file. No separate server to manage, and it's free for public repos and generous on private ones.
What you'll need
- A GitHub account
- Node.js installed locally (we'll use a tiny Node app as the example — the concepts map to any language)
- Basic comfort with the command line
Step 1: Create a small project to test
Let's make a trivial app with one function and one test. Create a folder and initialize it:
mkdir hello-cicd && cd hello-cicd
npm init -y
Create a file called sum.js:
// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
Now a test. We'll use Jest, a popular, zero-config test runner:
npm install --save-dev jest
Create sum.test.js:
// sum.test.js
const sum = require('./sum');
test('adds 2 + 3 to equal 5', () => {
expect(sum(2, 3)).toBe(5);
});
test('adds negative numbers correctly', () => {
expect(sum(-1, -1)).toBe(-2);
});
Open package.json and set the test script:
{
"scripts": {
"test": "jest"
}
}
Run it locally to confirm it works:
npm test
You should see two passing tests. This is the code our pipeline will check on every push.
Step 2: Push the project to GitHub
Create a new repository on GitHub (call it hello-cicd), then link and push:
git init
echo "node_modules/" > .gitignore
git add .
git commit -m "Initial commit: app with tests"
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/hello-cicd.git
git push -u origin main
Replace
YOUR_USERNAMEwith your actual GitHub username.
Step 3: Write the workflow file
Here's the core of it. GitHub Actions looks for YAML files inside a special folder: .github/workflows/. Any file there gets picked up automatically.
Create the folder and file:
mkdir -p .github/workflows
Create .github/workflows/ci.yml:
name: CI Pipeline
# When should this run?
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
# 1. Grab your code
- name: Check out repository
uses: actions/checkout@v4
# 2. Set up Node.js
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# 3. Install dependencies
- name: Install dependencies
run: npm ci
# 4. Run the tests
- name: Run tests
run: npm test
Let's read that top to bottom, because understanding each block is the whole point:
-
name— a label for the workflow, shown in GitHub's UI. -
on— the trigger. Here, the pipeline runs on every push tomainand on every pull request targetingmain. This is the "CI" part: nothing merges without being checked. -
jobs— a workflow is made of one or more jobs. We have one,build-and-test. -
runs-on: ubuntu-latest— GitHub spins up a fresh Linux virtual machine for you, every run. No server to maintain. -
steps— the ordered list of things to do. Each step eitherusesa prebuilt action orruns a shell command.
A few specifics worth knowing:
-
actions/checkout@v4clones your repo into the runner. Without it, the VM is empty. -
actions/setup-node@v4installs Node. Thecache: 'npm'line caches your dependencies between runs so future pipelines are faster. -
npm ci(notnpm install) is the CI-friendly install — it's faster and installs exactly what's in your lockfile, so builds are reproducible.
Step 4: Push it and watch it run
git add .github/workflows/ci.yml
git commit -m "Add CI pipeline"
git push
Now go to your repository on GitHub and click the Actions tab. You'll see your workflow running live — a spinning yellow dot that turns into a green checkmark when the tests pass. Click into it to see each step's logs.
That's a working CI pipeline. Every push from now on is automatically tested. Try breaking a test on purpose (change toBe(5) to toBe(6)), push it, and watch the run go red. That red X is the pipeline doing its job — stopping broken code.
Step 5: Add a linting step (catch mistakes before tests)
Tests check behavior. Linting checks code quality and style — unused variables, bad syntax, inconsistent formatting. Adding it is a one-step upgrade.
Install ESLint locally:
npm install --save-dev eslint
npx eslint --init
Then add a lint step to your workflow, right before the tests:
- name: Run linter
run: npx eslint .
Now your pipeline enforces both correctness and cleanliness on every push. Steps run in order, and if the linter fails, the pipeline stops before wasting time on tests.
Step 6: The "CD" part — deploy on success
This is where Continuous Deployment comes in. You typically only want to deploy when code lands on main and everything passes. Here's a second job that depends on the first:
deploy:
needs: build-and-test # only runs if tests pass
if: github.ref == 'refs/heads/main' # only on main
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Deploy
run: echo "Deploying to production..."
# In real life, this step would push to your host
# (Vercel, Netlify, AWS, a server via SSH, etc.)
Two key ideas here:
-
needs: build-and-test— this job waits for the test job and only runs if it succeeded. No green tests, no deploy. -
if: github.ref == 'refs/heads/main'— this guards the deploy so it happens onmainonly, not on every pull request.
For a real deployment, you'd swap the echo line for your host's deploy action, and store any tokens or keys as encrypted secrets (Repo → Settings → Secrets and variables → Actions), then reference them like ${{ secrets.MY_TOKEN }}. Never paste credentials directly into the YAML.
What you've built
You now have a pipeline that, on every push, automatically:
- Checks out your code on a fresh machine
- Installs dependencies reproducibly
- Lints for quality issues
- Runs your test suite
- Deploys — but only from
main, and only when everything passes
That's the same fundamental loop powering CI/CD at companies of every size. The tools around it get fancier (matrix builds across OS versions, container images, staging environments, rollbacks), but the mental model you just built is the whole foundation.
Where to go next
- Matrix builds: run your tests across multiple Node versions or operating systems at once.
-
Status badges: add a green "passing" badge to your README (
Actionstab → workflow → "Create status badge"). - Caching and artifacts: speed up builds and pass build outputs between jobs.
- Real deployment: wire the deploy job to Vercel, Netlify, or a server over SSH.
If you found this useful, drop a comment with what you'd like to automate next — deployment to a specific host is a great follow-up topic.
Happy shipping. 🚀
Top comments (0)