If you’re shipping a web application in 2026, manual builds and FTP deploys are no longer acceptable. A solid GitHub Actions CI/CD pipeline is the modern baseline: every push runs your tests, every merge produces a deployable artifact, and every release lands in production without human error.
In this tutorial, we won’t just talk theory. We’ll build a complete, copy-paste-ready workflow for a typical Node.js web app, explain each job line by line, and call out the pitfalls that trip up most teams. By the end, you’ll have a pipeline that lints, tests, builds, and deploys, all triggered by a single git push.
What is a GitHub Actions CI/CD Pipeline?
GitHub Actions is GitHub’s native automation platform. It lets you define workflows in YAML files stored in your repository under .github/workflows/. Each workflow can run on events like push, pull_request, schedules, or manual triggers.
A CI/CD pipeline built on GitHub Actions typically combines:
- Continuous Integration (CI): automatically lint, test, and build your code on every change.
- Continuous Delivery / Deployment (CD): automatically ship the built artifact to staging or production.
Unlike Jenkins or CircleCI, there’s no separate server to maintain. Runners are provided by GitHub (or self-hosted if you need it), and pricing is generous for public repos and reasonable for private ones.

Prerequisites
Before we write any YAML, make sure you have:
- A Node.js web app (Express, Next.js, NestJS, Fastify, etc.) hosted on GitHub.
- A
package.jsonwithlint,testandbuildscripts. - Node 20 or 22 locally (we’ll target Node 22 in the pipeline, which is the current active LTS in 2026).
- A deployment target. We’ll use a generic SSH deploy, but the same logic applies to Vercel, AWS, Azure, Fly.io or any container registry.
Step 1: Create the Workflow File
In your repo, create the following file:
.github/workflows/ci-cd.yml
GitHub automatically picks up any YAML file in this directory. You can have multiple workflows (one for CI, one for releases, one for nightly jobs), but for this tutorial we’ll keep everything in a single pipeline with multiple jobs.

Step 2: The Full Working YAML
Here’s the complete pipeline. We’ll break it down right after.
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '22'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://yourapp.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy via SSH
uses: appleboy/[email protected]
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: 'dist/*'
target: '/var/www/app'
- name: Restart application
uses: appleboy/[email protected]
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: pm2 reload app
Step 3: Understanding Each Job
The on trigger
We run the pipeline on pushes to main and develop, and on pull requests targeting main. This gives you fast feedback during code review without wasting minutes on feature branch pushes.
The lint job
Linting fails fast and cheap. Always make it the first gate. Note the cache: 'npm' on setup-node: this caches your node_modules based on package-lock.json, often cutting install time from 60s to 10s.
The test job
Tests run only if linting passes, thanks to needs: lint. We upload the coverage report as an artifact so you can download it from the Actions tab or wire it to a coverage service later.
The build job
The build produces the deployable output (typically in dist/ or .next/). Uploading it as an artifact means the deploy job downloads the exact same build that was tested, instead of rebuilding and risking drift.
The deploy job
This is where CD happens. Three critical things:
- The
ifcondition restricts deployment to pushes onmain. PRs and develop pushes will not deploy. - The
environmentblock ties the job to a GitHub Environment, which lets you require manual approval, restrict secrets, and track deployment history. - Credentials come from GitHub Secrets, never from the YAML itself.

Step 4: Configure Secrets and Environments
Go to your repo settings:
- Settings > Secrets and variables > Actions: add
SSH_HOST,SSH_USER, andSSH_PRIVATE_KEY. - Settings > Environments: create an environment named
production. Optionally enable Required reviewers so a teammate must approve before deploy.
For the SSH key, generate a dedicated deploy key (ssh-keygen -t ed25519 -C "github-actions-deploy") and add the public key to ~/.ssh/authorized_keys on your server. Never reuse your personal key.
Step 5: Add a Status Badge
Show off your green pipeline in your README:


Common Pitfalls to Avoid
After helping dozens of teams set up GitHub Actions pipelines, these are the mistakes we see again and again:
| Pitfall | Why it hurts | Fix |
|---|---|---|
Using npm install instead of npm ci |
Mutates lockfile, non-reproducible builds | Always npm ci in CI |
| Rebuilding in the deploy job | You deploy a build that was never tested | Upload/download artifacts between jobs |
| Hardcoding secrets in YAML | Massive security risk | Use secrets.* exclusively |
| No caching | Pipelines take 5x longer, waste minutes | Enable cache: 'npm' in setup-node |
| Deploying on every branch push | Accidental production deploys | Guard with if: github.ref == 'refs/heads/main' |
Pinning actions to @master |
Supply chain risk, surprise breakage | Pin to a version tag or SHA |
| No timeouts | A hung job can eat your monthly minutes | Set timeout-minutes per job |
Going Further
Once your basic pipeline is green, you can level it up:
- Matrix builds: test on multiple Node versions in parallel with a
strategy.matrix. - Docker: replace the SSH deploy with a build-and-push to GitHub Container Registry, then pull on your server.
- Preview environments: spin up a temporary URL for every PR using Vercel, Netlify or a custom ephemeral environment.
- Security scanning: add
github/codeql-actionandnpm auditas separate jobs. - Slack/Discord notifications: notify your team on success or failure of production deploys.
FAQ
Is GitHub Actions a real CI/CD pipeline?
Yes. GitHub Actions is a fully featured CI/CD platform that handles build, test, and deployment automation, with the added benefit of being natively integrated into the repository where your code lives.
Is GitHub Actions free?
It’s free and unlimited for public repositories. Private repositories get a monthly allowance of free minutes depending on your plan, then it’s pay-as-you-go. Self-hosted runners are always free in terms of GitHub minutes.
How long should a CI/CD pipeline take?
For a typical Node.js web app, aim for under 5 minutes end to end. With proper caching and parallel jobs, most teams hit 2 to 3 minutes. Anything over 10 minutes will hurt developer velocity.
Can I use GitHub Actions to deploy to AWS, Azure, or GCP?
Absolutely. All three clouds publish official actions, and OIDC federation lets you authenticate without storing long-lived credentials as secrets, which is the recommended approach in 2026.
Should I use one workflow file or many?
Start with one. Split into multiple files only when you have clearly distinct triggers (for example, a nightly security scan or a release-on-tag workflow). Too many workflows become hard to reason about.
Wrapping Up
A solid GitHub Actions CI/CD pipeline is one of the highest-leverage investments you can make in a codebase. The YAML above is production-ready: copy it, swap in your scripts and secrets, and you’re shipping in minutes instead of hours.
At Pixelseed, we set up pipelines like this every week for our clients’ web apps. If you want a tailored review of your DevOps setup, get in touch and we’ll help you ship faster, safer, and with more confidence.