If deploying your app involves a person remembering steps, it will eventually be deployed wrong. CI/CD fixes that: every push runs your tests automatically (continuous integration), and code that passes on your main branch deploys itself (continuous deployment). GitHub Actions is the easiest place to start because it's built into GitHub, free for a generous amount of usage on public and private repos, and configured with one YAML file in your repo. In this guide you'll set up a workflow that tests every push and pull request for a Node project, then add a deploy job that only runs on main.
Prerequisites
- A project in a GitHub repository.
- A test command that runs locally. For Node, that means
npm testdoes something real. Even one test counts. - A way to deploy from the command line (a CLI like
wrangler,flyctl, or an rsync/SSH script). The deploy step just automates whatever you already do by hand.
Step 1: Understand the moving parts
Three terms and you know the whole system:
- A workflow is a YAML file in
.github/workflows/that says when to run and what to do. - A job is a set of steps that runs on a fresh virtual machine GitHub spins up for you.
- A step is either a shell command or an action, which is a reusable building block published by GitHub or the community (checking out code, installing Node, and so on).
Step 2: Create the CI workflow
In your repo, create .github/workflows/ci.yml:
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: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
What each piece does:
on:triggers the workflow on any push to main and on every pull request. PRs get tested before they merge, and main gets tested after.actions/checkoutclones your repo onto the runner.actions/setup-nodeinstalls Node 20 and caches your npm downloads so later runs are faster.npm ciinstalls exactly whatpackage-lock.jsonsays. Use it instead ofnpm installin CI: it's faster and it fails loudly if the lockfile is out of sync instead of silently changing versions.npm testruns your tests. If any step exits nonzero, the job fails and GitHub marks the commit with a red X.
Commit the file and push. Go to the Actions tab in your repo and you'll see the run in progress, with live logs for every step.
Step 3: Add a deploy job gated on main
Now extend the same file with a second job that deploys, but only after tests pass and only on the main branch:
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Deploy
run: npm run deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
The two lines that make this safe:
needs: testmeans the deploy job waits for the test job and never runs if tests fail. Broken code cannot ship through this pipeline.- The
if:condition restricts deploys to pushes on main. Pull requests get tested but never deployed.
Replace npm run deploy with your actual deploy command. For a Cloudflare Workers site that's npx wrangler deploy; for a server it might be a script that rsyncs files and restarts a service. Whatever you type by hand today goes here.
Step 4: Store credentials as secrets
Your deploy command almost certainly needs a token or key. Do not put it in the YAML. In your repo, go to Settings, then Secrets and variables, then Actions, and add a repository secret (for example DEPLOY_TOKEN). The ${{ secrets.DEPLOY_TOKEN }} syntax in the workflow injects it as an environment variable at runtime. Secrets are encrypted, masked in logs, and not exposed to workflows triggered from forks.
Step 5: Protect the main branch
The pipeline only helps if people can't route around it. In Settings, then Branches, add a branch protection rule (or ruleset) for main that requires the test check to pass before merging. Now a PR with failing tests physically cannot merge, and since deploys only happen from main, nothing broken reaches production.
Verify it
Run the full loop once, on purpose:
- Open a branch, break a test, and push a PR. The check should go red and the merge button should lock.
- Fix the test, push again, watch it go green, and merge.
- Watch the Actions tab: the test job runs on main, then the deploy job runs, then your change is live.
If all three happen without you touching a server, you have CI/CD. From here you can add more later (linting, multiple Node versions, staging environments), but resist the urge to build a cathedral. A pipeline that tests every change and deploys main automatically already puts you ahead of most small shops we walk into.
Stuck on this, or want it done for you? That's the job.
Email us →