GitHub Actions CI/CD: From Zero to Production Pipeline in 30 Minutes
A working CI/CD pipeline catches bugs before production and deploys automatically. Here is how to build one with GitHub Actions in under 30 minutes.

DevForge Team
AI Development Educators

Every time you push code manually to production, you're making a bet that nothing is broken. A CI/CD pipeline turns that bet into a guarantee: tests run automatically, builds are verified, and deployments happen only when everything passes.
GitHub Actions is the fastest way to build this pipeline if your code is already on GitHub. It's free for public repositories and includes 2,000 minutes per month on the free plan for private repositories.
This guide builds a complete pipeline that runs tests on every pull request and deploys to production on merge to main.
How GitHub Actions Works
A GitHub Actions workflow is a YAML file in .github/workflows/ that defines:
- Triggers: When the workflow runs (push, pull request, schedule, manual)
- Jobs: Groups of steps that run on a runner (GitHub-managed virtual machine)
- Steps: Individual commands or actions that run in sequence within a job
Workflows run on GitHub's servers — you don't need to maintain any infrastructure.
Your First Workflow: Running Tests
Create the file .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm testThis workflow:
- Triggers on pushes to main and on any pull request targeting main
- Checks out your code
- Sets up Node.js 20 with npm caching (speeds up subsequent runs)
- Installs dependencies with npm ci
- Runs your linter
- Runs your test suite
Push this file to your repository. The workflow runs immediately, and you'll see the results in the Actions tab on GitHub.
Adding a Build Step
If your application requires a build step (TypeScript compilation, Next.js build, Vite bundle), add it:
- name: Build
run: npm run buildFailing builds fail the workflow and block the PR from being merged — exactly what you want.
Environment Variables and Secrets
Tests that interact with databases or external APIs need credentials. Never put secrets in your workflow file. Use GitHub Secrets instead.
Add secrets in GitHub: Settings → Secrets and variables → Actions → New repository secret.
Reference them in the workflow:
- name: Run tests
run: npm test
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}Secrets are masked in logs — if a secret value appears in output, GitHub replaces it with ***.
Running Tests Against a Real Database
For integration tests that need a database, use GitHub Actions service containers:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run migrations
run: npm run db:migrate
env:
DATABASE_URL: postgres://postgres:testpassword@localhost:5432/testdb
- name: Run tests
run: npm test
env:
DATABASE_URL: postgres://postgres:testpassword@localhost:5432/testdbThe service container starts before the job steps and is available at localhost on the specified port.
Adding Deployment
Now add a deployment job that runs only after tests pass, and only on merges to main:
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
deploy:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
# Your deployment command here
# Examples belowThe needs: test line means the deploy job only starts after the test job succeeds. The if condition restricts deployment to push events on main (not pull requests).
Deployment Examples
Deploy to Vercel:
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'Deploy to Netlify:
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: './dist'
production-branch: main
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}Deploy via SSH to a VPS:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /app
git pull origin main
npm ci --only=production
pm2 restart appMatrix Testing
Test against multiple Node.js versions to ensure compatibility:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm testThis runs three parallel test jobs, one for each Node.js version.
Caching Dependencies
npm install is slow. Cache node_modules between workflow runs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'The cache: 'npm' option automatically caches the npm cache directory and restores it on subsequent runs. Cache hits reduce install time from 30-60 seconds to under 5 seconds.
Branch Protection Rules
Once your CI workflow is working, enforce it. In GitHub: Settings → Branches → Add rule → Require status checks to pass before merging.
Add your test job as a required status check. Now pull requests cannot be merged until tests pass. This is the policy that makes CI valuable — the pipeline only prevents bad code from shipping if it's actually enforced.
What a Complete Pipeline Looks Like
A production-ready pipeline for a Node.js application:
- On every PR: Run linter, type checker, unit tests, integration tests, build verification
- On merge to main: Run all checks again, then deploy to staging
- On tag or manual trigger: Deploy to production after staging verification
Start with step one. A pipeline that runs tests on PRs catches most bugs before they reach main. The rest can be added incrementally as your team's confidence in the system grows.
The 30 minutes you spend setting up GitHub Actions pays back in the first bug it catches before deployment.