How to Set Up CI/CD with GitHub Actions in 15 Minutes

Tutorial Β· 9 min read πŸ”„ Affiliate Links

πŸ” Want the best deal? Check current prices and availability.

Compare Prices β†’

When you buy through links on our site, we may earn a commission.

So you've got a project on GitHub and you're tired of manually deploying every time you push a commit. Maybe you're running npm run build && rsync from your laptop like it's 2012. I've been there.

GitHub Actions is baked right into every repository on GitHub, and it's genuinely good. Not just "good for a built-in tool"β€”actually good. In about 15 minutes, you can have a working CI/CD pipeline that builds your code, runs tests, and deploys to production.

Let me walk you through it step by step, with a real example that deploys a Node.js app to a VPS. Along the way, I'll show you the gotchas that the official docs gloss over.

What We're Building

We're setting up a pipeline for a simple Node.js/Express app. Here's what it'll do:

  • On every push – Run tests and linting
  • On merge to main – Build the app, run full test suite, then deploy to a Linux server
  • Send notifications – Ping a Slack webhook if something breaks

The whole thing lives in a .github/workflows/ folder in your repo. No extra services, no Jenkins plugins, no YAML spaghetti.

Prerequisites

Before we start, you'll need:

  • A GitHub repository with your project
  • A server that speaks SSH (DigitalOcean droplet, Linode, AWS EC2, or even a Raspberry Pi)
  • A domain or IP address pointing to that server
  • Basic comfort editing YAML

If you don't have a server yet, DigitalOcean has $6/month droplets that work perfectly for this. Linode starts at $5/month.

Step 1: Set Up Your GitHub Repository

This part is quick. Navigate to your project on GitHub, click the Actions tab, and you'll see a bunch of starter workflows. Ignore those for nowβ€”we're building our own.

Create a new directory in your repo:

mkdir -p .github/workflows

Then create a file called deploy.yml inside that directory.

Step 2: Write the CI/CD Workflow

Open deploy.yml and add this:

name: CI/CD Pipeline

on:

push:

branches: [main]

pull_request:

branches: [main]

jobs:

test:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Setup Node.js

uses: actions/setup-node@v4

with:

node-version: '18'

- name: Install dependencies

run: npm ci

- name: Run linting

run: npm run lint

- name: Run tests

run: npm test

deploy:

needs: test

runs-on: ubuntu-latest

if: github.ref == 'refs/heads/main' && github.event_name == 'push'

steps:

- uses: actions/checkout@v4

- name: Setup Node.js

uses: actions/setup-node@v4

with:

node-version: '18'

- name: Install dependencies

run: npm ci

- name: Build app

run: npm run build

- name: Deploy to server

uses: appleboy/[email protected]

with:

host: ${{ secrets.DEPLOY_HOST }}

username: ${{ secrets.DEPLOY_USER }}

key: ${{ secrets.DEPLOY_KEY }}

script: |

cd /var/www/myapp

git pull origin main

npm ci --production

pm2 restart ecosystem.config.js

Let me explain what's happening here:

  • on: push and on: pull_request – Triggers the workflow. The test job runs on both pushes and PRs, but deploy only runs on pushes to main.
  • actions/checkout@v4 – Checks out your code. Always use the latest major version.
  • appleboy/ssh-action – An excellent community action for SSH deployments. Much cleaner than configuring an SSH client manually.
  • Secrets – Everything stored in ${{ secrets.X }} comes from your repo's settings. Never hardcode credentials.

Step 3: Create Deployment Secrets

This is where people get tripped up. You need to store your server credentials securely.

Go to your repository on GitHub:

  • Settings β†’ Secrets and variables β†’ Actions
  • Click "New repository secret"

Add these three secrets:

Secret NameValue
DEPLOY_HOSTYour server's IP address or domain
DEPLOY_USERThe SSH user (usually root or deploy)
DEPLOY_KEYYour private SSH key (the one with no .pub extension)

Important: The SSH key you use must be in PEM format. If you generated your key with ssh-keygen -m PEM, it'll work. Modern OpenSSH defaults won't. Run this if you need to convert:

ssh-keygen -p -m PEM -f ~/.ssh/id_rsa

And Make sure the public key is in ~/.ssh/authorized_keys on your server.

Step 4: Set Up Your Server

SSH into your server and prepare the deployment directory:

mkdir -p /var/www/myapp

cd /var/www/myapp

git init

git remote add origin [email protected]:yourusername/yourrepo.git

Install PM2 if you haven't already:

npm install -g pm2

Create an ecosystem.config.js file in your project root:

module.exports = {

apps: [{

name: 'myapp',

script: './dist/server.js',

instances: 1,

exec_mode: 'fork',

env: {

NODE_ENV: 'production',

PORT: 3000

}

}]

};

This tells PM2 how to run your app and enables auto-restart.

Step 5: Push and Test

Commit your workflow file and push to main:

git add .github/workflows/deploy.yml

git commit -m "Add CI/CD pipeline"

git push origin main

Go to the Actions tab in your repo. You'll see the workflow start running. Click into it to watch the logs.

The first run might fail. That's normal. Common issues:

  • SSH key format – Make sure it's PEM, not OpenSSH
  • Node version mismatch – Your local package.json engines field should match the workflow
  • Missing dependencies – npm ci requires an exact package-lock.json. If you don't have one, use npm install instead

Making It Real: Adding Tests, Notifications, and Caching

The basic workflow above works, but it's slow and silent. Let's fix that.

Cache Node Modules

Add this to both the test and deploy jobs, right after checking out the code:

- name: Cache Node modules

uses: actions/cache@v3

with:

path: ~/.npm

key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

restore-keys: |

${{ runner.os }}-node-

This saves about 30 seconds per run by avoiding redundant npm installs.

Slack Notifications

Add a notification step to your deploy job:

- name: Notify Slack on failure

if: failure()

uses: slackapi/[email protected]

with:

payload: |

{

"text": "Deployment failed for ${{ github.repository }}"

}

env:

SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

You'll need to create a Slack webhook in your workspace and add it as a secret. This runs only if the deployment step fails.

Environment-Specific Config

If you need different settings for staging vs production, you can use environment variables:

- name: Deploy to staging

if: github.ref == 'refs/heads/staging'

uses: appleboy/[email protected]

with:

host: ${{ secrets.STAGING_HOST }}

# ...

Or you can use GitHub Environments, which let you set approval requirements and specific secrets per environment.

Pricing: What Does It Actually Cost?

GitHub Actions gives you free minutes based on your plan:

PlanFree Minutes/MonthAdditional Cost
Free (public repos)UnlimitedN/A
Free (private repos)2,000$0.008/min
Team3,000$0.008/min
Enterprise50,000$0.004/min

For context: Our example workflow takes about 3-4 minutes to run. That's roughly 300 runs per month on the free tier for private repos. Most indie developers won't hit the limit unless they're pushing dozens of times per day.

Alternative tools worth comparing:

ToolFree TierMinute LimitIntegration
GitHub ActionsYes (2,000 min/mo)Per-planDeep GitHub integration
GitLab CIYes (400 min/mo)Shared runnersGitLab-native
CircleCIYes (6,000 min/mo)1 concurrent jobGitHub/Bitbucket
JenkinsSelf-hostedUnlimitedPlugin ecosystem
Railway.app?ref=devtoolrank)Yes ($5 credit)Usage-basedAuto-deploys from GitHub

If you're already on GitHub, Actions is the obvious choice. But CircleCI offers more free minutes if you're just experimenting.

Pros and Cons

What Works Well

  • Zero setup – It's already in your repo. No installing agents, no webhook configuration.
  • YAML is readable – Once you get past the learning curve, workflows are simple to read and modify.
  • Community actions – The marketplace has actions for deploying to AWS, Firebase, Docker, pretty much everything.
  • Secrets management – Built-in and encrypted. No .env files floating around in CI.
  • Matrix builds – Testing against Node 16, 18, and 20 in parallel is a few lines of YAML.

The Frustrating Parts

  • Debugging is painful – You can't step through a workflow locally. You push, wait 3 minutes, and hope.
  • Logs expire – After 90 days, job logs disappear. Good luck auditing what happened 6 months ago.
  • YAML indentation errors – One misaligned space and your workflow silently does nothing. There's no good linting for this.
  • Self-hosted runners are finicky – If you need your own hardware, the setup process is clunky compared to GitLab's runner.
  • Marketplace quality varies – Some actions are abandoned or have security issues. Vet what you use.

Verdict

GitHub Actions wins if you're already using GitHub. The integration is too convenient to ignore, and the free tier handles most indie projects without costing a dime.

But if you need unlimited private repos and more CI minutes, GitLab's free tier is more generous. And if you're doing heavy matrix testing across hundreds of commits, CircleCI's parallel execution is smoother.

For the typical solo developer or small team building a SaaS app or side project:

Use GitHub Actions. Set up the workflow I showed you above, add caching and notifications, and you're done. You'll spend more time deciding which YAML editor to use than actually building your pipeline.

If you want a deployment platform that handles the server side too, Railway.app?ref=devtoolrank) connects to your repo and deploys automatically without SSH or PM2 config. It's more expensive than a $6 VPS, but the convenience might be worth it.

Frequently Asked Questions

How do I run workflows locally?

You can't natively, but act lets you run GitHub Actions locally using Docker containers. It's not perfectβ€”some actions don't translate wellβ€”but it's useful for quick YAML validation.

Can I deploy to multiple environments (staging, production)?

Yes. Define separate jobs with different conditions:

deploy-staging:

if: github.ref == 'refs/heads/develop'

deploy-production:

if: github.ref == 'refs/heads/main'

You can also use GitHub Environments under Settings β†’ Environments for manual approvals.

What's the difference between npm ci and npm install?

npm ci installs exactly what's in your package-lock.json, ignoring ^ and ~ version ranges. It's faster and ensures reproducible builds. npm install updates the lockfile, which is fine but less predictable for CI.

My deploy keeps failing with "Host key verification failed"

Add this to your SSH action:

with:

host: ${{ secrets.DEPLOY_HOST }}

username: ${{ secrets.DEPLOY_USER }}

key: ${{ secrets.DEPLOY_KEY }}

port: 22

You can safely disable strict host key checking for automated deploys:

  script_stop: true

How do I protect sensitive data in my workflow?

Never put secrets in your YAML directly. Use GitHub's encrypted secrets (Settings β†’ Secrets and variables β†’ Actions). For values that change between environments, use GitHub Environments which allow secrets per-environment.

Can I use GitHub Actions with non-GitHub repos?

Not natively. Actions only triggers from GitHub events. If your code lives on GitLab or Bitbucket, you'd need to mirror the repo to GitHub or use their native CI tools instead.


Ready to build your pipeline? Get started with GitHub Actions for free.

πŸ” Want the best deal? Check current prices and availability.

Compare Prices β†’
D

Dev Tool Rank Editorial Team

We're a team of tech enthusiasts who test and review tools so you don't have to. Our reviews are independent β€” we only recommend what we'd actually use ourselves.