Manual deployments are a hidden tax on every development team — the "deploy to production" ritual that blocks everyone, takes 20 minutes, and occasionally breaks at 6pm on a Friday. GitHub Actions is the answer that's already in your repository, costs nothing for public projects, and takes an afternoon to set up properly. This tutorial walks you through the core concepts and builds a real, working CI/CD pipeline from scratch.
What Is CI/CD?
Continuous Integration (CI) means automatically running tests and checks every time code is pushed. The goal is to catch bugs and integration issues immediately, before they compound. If tests fail on a branch, the developer knows instantly — not three days later when someone else tries to merge.
Continuous Deployment (CD) means automatically deploying code to production (or staging) after CI passes. When a developer merges to the main branch, the new code is live within minutes — automatically, without a human touching a deploy button.
Together, they reduce deployment risk by making deployments small and frequent, and eliminate manual steps that are error-prone and time-consuming.
GitHub Actions Concepts
Before writing YAML, understand the vocabulary:
- Workflow — A YAML file in
.github/workflows/. One repository can have multiple workflows (e.g., one for CI on PRs, one for deployment on merge to main). - Trigger (on:) — What causes the workflow to run: a push, a pull request, a schedule, or a manual dispatch.
- Job — A set of steps that run on a single virtual machine (runner). Jobs within a workflow can run in parallel (default) or in sequence (using
needs:). - Step — A single command or action within a job. Steps run sequentially. If a step fails, the job fails by default.
- Action — A reusable unit of logic, published to the GitHub Marketplace.
actions/checkout@v4is the most commonly used action — it clones your repository into the runner. - Runner — The virtual machine that executes the job. GitHub-hosted runners run Ubuntu, macOS, or Windows.
ubuntu-latestis the default and cheapest. - Secrets — Encrypted variables stored in GitHub Settings → Secrets. Never hardcode credentials in workflow files.
Your First Workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
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 test
That's a complete, working CI workflow. Every push to main or develop, and every pull request targeting main, will install dependencies and run lint + tests. The result (pass or fail) is shown on the PR before merging.
Key detail: npm ci instead of npm install. ci installs exactly what's in package-lock.json, cleans node_modules first, and fails if the lockfile is out of sync. It is faster and more deterministic in CI than npm install.
Adding Environment Variables and Secrets
Tests often need a database connection or API key. Store secrets in GitHub Settings → Secrets and variables → Actions → New repository secret. Reference them in the workflow with ${{ secrets.SECRET_NAME }}:
- name: Run tests
run: npm test
env:
DB_HOST: localhost
DB_NAME: myapp_test
DB_USER: postgres
DB_PASS: ${{ secrets.TEST_DB_PASS }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
Secrets are masked in workflow logs — even if a step accidentally prints them, GitHub replaces the value with ***.
Setting Up a PostgreSQL Service Container
Integration tests that require a real database need a database to connect to. GitHub Actions supports service containers — Docker containers that run alongside the job:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: myapp_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:test_password@localhost:5432/myapp_test
- run: npm test
env:
DATABASE_URL: postgresql://postgres:test_password@localhost:5432/myapp_test
A Complete CI/CD Pipeline with Deployment
Now add a second job that deploys to a VPS via SSH after the tests pass. This workflow runs CI on every push, and only deploys when merging to main:
name: CI / CD
on:
push:
branches: [main, develop]
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
deploy:
needs: test # only runs if test passes
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --production
npm run build
pm2 reload myapp
Required secrets for the deploy job:
SSH_HOST— Your server's IP address or hostnameSSH_USER— The SSH username (e.g.,ubuntuordeploy)SSH_PRIVATE_KEY— The full content of your~/.ssh/id_ed25519private keySSH_PORT— SSH port (22 by default, or your custom port)
On the server, add the corresponding public key to ~/.ssh/authorized_keys for the deploy user.
Matrix Builds: Testing Multiple Versions
If your library or application needs to support multiple Node.js versions, a matrix build runs the tests in parallel across all of them:
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 }}
cache: 'npm'
- run: npm ci
- run: npm test
This creates three parallel jobs — one per Node version — each reporting separately. The workflow fails if any matrix variant fails.
Manual Deployments with workflow_dispatch
Sometimes you want to trigger a deployment manually, with optional parameters:
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
This adds a "Run workflow" button to the Actions tab in GitHub, with a dropdown for environment. Useful for deploying to production only when you're ready, not on every merge.
Caching for Faster Pipelines
The cache: 'npm' option in actions/setup-node automatically caches node_modules based on the lockfile. A cache hit saves 30–90 seconds per run. For Python, the equivalent is cache: 'pip'; for PHP Composer, use actions/cache manually:
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader
Where to Deploy
GitHub Actions handles the pipeline — but you still need a server to deploy to. Our recommended targets for the SSH deploy workflow above:
- DigitalOcean Droplets — $200 free credit for new users, straightforward SSH setup, excellent docs. Most of the examples in this post target a DigitalOcean Ubuntu server.
- Hostinger VPS — KVM-based VPS from $4.99/month, works identically with the SSH deploy workflow.
- Cloudways — if you want managed infrastructure (Nginx pre-configured, SSL automated) and prefer to deploy via git push rather than raw SSH commands.
Pricing
GitHub Actions is free for public repositories with unlimited minutes. For private repositories, the free tier includes 2,000 minutes per month on standard Ubuntu runners. An average workflow run takes 2–5 minutes, so 2,000 minutes covers roughly 400–1,000 workflow runs per month — enough for most small teams. Beyond the free tier, minutes are billed at $0.008/minute for Ubuntu.
CREESOL