How to Set Up CI/CD with GitHub Actions in 15 Minutes
π 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: pushandon: 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 Name | Value |
|---|---|
DEPLOY_HOST | Your server's IP address or domain |
DEPLOY_USER | The SSH user (usually root or deploy) |
DEPLOY_KEY | Your 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.jsonengines field should match the workflow - Missing dependencies β
npm cirequires an exactpackage-lock.json. If you don't have one, usenpm installinstead
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:
| Plan | Free Minutes/Month | Additional Cost |
|---|---|---|
| Free (public repos) | Unlimited | N/A |
| Free (private repos) | 2,000 | $0.008/min |
| Team | 3,000 | $0.008/min |
| Enterprise | 50,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:
| Tool | Free Tier | Minute Limit | Integration |
|---|---|---|---|
| GitHub Actions | Yes (2,000 min/mo) | Per-plan | Deep GitHub integration |
| GitLab CI | Yes (400 min/mo) | Shared runners | GitLab-native |
| CircleCI | Yes (6,000 min/mo) | 1 concurrent job | GitHub/Bitbucket |
| Jenkins | Self-hosted | Unlimited | Plugin ecosystem |
| Railway.app?ref=devtoolrank) | Yes ($5 credit) | Usage-based | Auto-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 β