Before CI/CD, deployments were a manual nightmare. Someone runs the tests locally (maybe), builds the app, copies files to the server, restarts things, and prays it works. With CI/CD, all of that happens automatically every time we push code. No manual steps, no human error, no prayers.
CI vs CD
CI (Continuous Integration) — Every time we push code or open a PR, the system automatically:
- Pulls the latest code
- Installs dependencies
- Runs the linter
- Runs the tests
- Builds the app
If any step fails, we know immediately. No more “it works on my machine.”
CD (Continuous Delivery / Continuous Deployment) — After CI passes, what happens next?
- Continuous Delivery — The app is built and ready to deploy, but a human clicks the button. We have a release candidate sitting there, validated and packaged.
- Continuous Deployment — No human in the loop. CI passes, deploy happens automatically. Merge to main = live in production.
Most teams start with Continuous Delivery and move to Continuous Deployment once they trust their test suite.
The Pipeline Flow
A typical CI/CD pipeline looks like this:
The CI part catches bugs early. The CD part ensures consistent deployments. Together, they give us confidence that what’s in main is always shippable.
GitHub Actions Basics
GitHub Actions is the most popular CI/CD platform for GitHub repos. Workflows are YAML files that live in .github/workflows/. Let’s break down the key concepts:
- Workflow — A YAML file that defines the automation. Triggered by events.
- Trigger — What starts the workflow:
push,pull_request,schedule,workflow_dispatch(manual). - Job — A group of steps that run on the same runner. Jobs run in parallel by default.
- Step — An individual task: run a command, use an action, etc.
- Action — A reusable piece of automation (like
actions/checkoutoractions/setup-node). - Runner — The machine that runs the job (GitHub provides
ubuntu-latest,macos-latest, etc.).
A Practical GitHub Actions Workflow
Here’s a real-world workflow that lints and tests on PRs, then deploys on merge to main:
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
# When to run
on:
push:
branches: [main] # deploy on merge to main
pull_request:
branches: [main] # lint + test on PRs
jobs:
# Job 1: Lint and Test (runs on every push and PR)
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # clone the repo
- uses: actions/setup-node@v4 # install Node.js
with:
node-version: 20
cache: "npm" # cache node_modules
- run: npm ci # install deps
- run: npm run lint # run linter
- run: npm test # run tests
# Job 2: Deploy (only on push to main, after CI passes)
deploy:
needs: ci # wait for CI to pass
if: github.ref == 'refs/heads/main' # only on main branch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to server
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }} # from GitHub Secrets
run: |
echo "$SSH_KEY" > key.pem
chmod 600 key.pem
rsync -avz --delete dist/ user@server:/var/www/app/ -e "ssh -i key.pem"
A few things to notice:
needs: cimakes the deploy job wait for CI to passif: github.ref == 'refs/heads/main'ensures we only deploy from the main branch- Secrets are stored in GitHub Settings, never in the workflow file
Branch Strategy
CI/CD works best with a good branching strategy:
- main — Always deployable. Protected branch, no direct pushes.
- Feature branches —
feature/add-auth,fix/login-bug. Short-lived. - Pull Requests — Feature branch → main. CI runs on every PR. Code review required.
- Merge — Once PR is approved and CI passes, merge to main → CD triggers deployment.
# Typical workflow
git checkout -b feature/add-auth # create feature branch
# ... write code ...
git push -u origin feature/add-auth # push and open PR
# CI runs automatically on the PR
# Teammate reviews and approves
# Merge to main → deploy happens automatically
Build Caching
CI builds can be slow. Caching dependencies between runs saves a lot of time.
# GitHub Actions — cache npm dependencies
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm" # built-in caching
# Or manual caching for anything
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
The hashFiles('package-lock.json') key means the cache is busted only when dependencies change. If package-lock.json hasn’t changed, npm install finishes in seconds.
Why CI/CD Matters
Without CI/CD:
- “Did anyone run the tests before merging?” — Nobody knows
- Deployments are scary, manual, and different every time
- Bugs make it to production because nobody tested that edge case
With CI/CD:
- Every PR is automatically tested — broken code can’t merge
- Deployments are consistent and repeatable
- The team ships faster because they trust the pipeline
The investment in setting up CI/CD pays for itself after the first week.
In simple language, CI/CD automates the entire path from code push to production deployment — CI catches bugs early by running tests on every change, and CD makes sure deployments are consistent and hands-free.