In this guide
Right now your deployment process is probably: make changes, hope you remember everything, copy files to the server, restart something, pray. It works until the day you forget a step at 11 PM before a demo — or a teammate's "small fix" breaks the build and nobody notices for a week.
CI/CD (Continuous Integration / Continuous Deployment) replaces hope with automation: every push runs your tests automatically, and passing code deploys itself. GitHub Actions gives you this for free on GitHub. This guide builds a real pipeline, step by step.
CI vs CD in one paragraph
- Continuous Integration: every push to the repo automatically builds the code and runs the test suite. Broken code is caught in minutes, while the change is fresh in someone's mind.
- Continuous Delivery/Deployment: every passing build is automatically released — to staging always, to production either automatically (deployment) or with one approval click (delivery).
The pipeline is: push → build → test → (deploy to staging) → (deploy to production). Each stage gates the next.
Your first workflow: test on every push
GitHub Actions workflows live in .github/workflows/ as YAML files. Here is a complete Node.js CI workflow:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run lint
Reading it top to bottom: the workflow is named CI; it triggers on pushes to main and on pull requests; the test job runs on a fresh Ubuntu virtual machine; it checks out your code, sets up Node 22 with npm caching, installs dependencies deterministically, then runs tests and linting. If any step fails, the whole job fails and GitHub shows a red X on the commit.
Key concepts in that file:
- Events (
on) — what triggers the workflow: pushes, pull requests, schedules, manual dispatch. - Jobs — run in parallel by default on separate fresh VMs. Use
needs:to sequence them. - Steps — sequential commands or pre-built actions (
uses:) from the marketplace. - Runners — the VMs GitHub provides.
ubuntu-latestis free for public repos with generous limits for private ones.
Note: Every job starts from a clean VM — nothing persists between runs unless you explicitly cache it or upload artifacts. This is a feature: your build cannot secretly depend on leftover state from a previous run.
Adding a database service
Real apps need a database in tests. Actions can spin up service containers alongside your job:
jobs:
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test
env:
MONGO_URL: mongodb://localhost:27017/testdb
The services block starts MongoDB before your steps run; your tests reach it at localhost:27017. Postgres, Redis, and MySQL all work the same way.
Secrets: the right way
Pipelines need credentials — server SSH keys, API tokens, Docker Hub passwords. Rules:
- Never hardcode secrets in workflow files. Use GitHub's encrypted secrets: repository Settings → Secrets and variables → Actions.
- Reference them as
[secrets.DEPLOY_KEY]— GitHub masks them in logs automatically. - Scope secrets narrowly: a deploy key should only access what deployment needs, not your whole cloud account.
- Rotate any secret that was ever committed to git — assume it is compromised.
Deploying: the CD half
A common student-friendly pattern: deploy to your VPS over SSH when main passes tests.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
needs: test # only deploy if the CI workflow's tests pass
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: [secrets.VPS_HOST]
username: [secrets.VPS_USER]
key: [secrets.VPS_SSH_KEY]
script: |
cd /opt/myapp
git pull origin main
docker compose up --build -d
The needs: test line is the CI/CD contract: deployment only happens if tests pass. The SSH action connects to your VPS and runs a short update sequence — pull, rebuild, restart. Keep the remote script short; the heavy logic (what to build, how to test) lives in versioned workflow files, not in commands typed on the server.
Safer progression for team projects: deploy automatically to a staging environment on every push, but require a manual approval (GitHub Environments with required reviewers) before production. You get automation without 2 AM surprises.
Caching and speed
Pipelines get slow as projects grow. The standard fixes:
- Dependency caching —
cache: npm(or pip/gradle equivalents) avoids reinstalling everything every run. - Fail fast — run lint and unit tests before slow integration tests so quick failures surface in seconds.
- Matrix builds — test across Node versions or OSes in parallel with a
strategy.matrix. - Artifacts —
actions/upload-artifactpasses build outputs (compiled frontend, test reports) between jobs instead of rebuilding.
Environments and approvals
For team projects, the step between "CI passes" and "production deploys" deserves a gate. GitHub Environments provide it:
- Define environments (e.g.
staging,production) in repository settings. - Required reviewers: production deploys wait for a human approval — automation proposes, a teammate disposes.
- Environment secrets: the production database password exists only in the
productionenvironment scope, invisible to staging workflows and pull-request runs. - Deployment history: GitHub records every deployment per environment — who approved, what commit, when.
Reference the environment in the deploy job:
jobs:
deploy-prod:
runs-on: ubuntu-latest
needs: test
environment: production # triggers the approval gate + scoped secrets
steps:
# ... deploy steps using secrets.PROD_VPS_HOST ...
The result: pushes auto-deploy to staging for everyone to see, and production updates only after tests pass and a human approves. Fast for developers, safe for users.
Common mistakes
- Testing nothing in CI. A pipeline that only builds is decoration. The value is in the tests — see Testing JavaScript with Jest to have something worth running.
- Deploying from a developer's laptop instead of the pipeline. If production can only be updated by one person's machine, you do not have CD — you have a script.
- Secrets in workflow files or logs. Echoing a secret for debugging prints
***if you use the secrets context — but hardcoding it in YAML exposes it to everyone with repo access. - No branch protection. CI on pull requests is toothless if
mainstill accepts direct pushes. Protectmain: require PRs and passing checks before merge. - Deploying on every push without staging. For team projects, auto-deploy to staging on push, gate production behind approval.
- Ignoring the free-tier minutes. Private repos get limited free minutes; cache aggressively and avoid redundant jobs so you do not burn the quota mid-semester.
Quick checklist
- Workflow runs tests + lint on every push and pull request
-
mainbranch protected: requires PR + passing checks - Secrets stored in GitHub Secrets, referenced via secrets context, never in YAML
- Deploy job gated on tests passing (
needs:) - Staging auto-deploys; production needs approval (team projects)
- Pipeline is the only path to production — no manual server edits
Where to go from here
- Have tests worth running: Testing JavaScript with Jest and E2E Testing with Cypress.
- Containerize what you deploy: Docker Compose for Full-Stack Projects.
- Coordinate the team: Git branching workflows for project teams.
- Server setup for the deploy target: how to deploy a MERN app on a VPS.
- More DevOps topics in the Web Development branch hub.