GitHub Actions is GitHub's built-in CI/CD platform, letting you define automated workflows directly inside your repository — no separate Jenkins or CircleCI needed. This guide uses this site (a Nuxt 4 + pnpm project) as a real-world example, walking you from concepts to a working CI/CD pipeline that builds and deploys on every push.
Core Concepts
Before writing any config, let's nail down the key terms.
| Term | Description |
|---|---|
| Workflow | A complete automation pipeline, stored as a YAML file under .github/workflows/ |
| Event | What triggers the workflow — push, pull_request, schedule, manual dispatch, etc. |
| Job | An execution unit inside a workflow. Each job runs on an isolated VM. Jobs run in parallel by default |
| Step | A single operation inside a job. Steps run sequentially and can be shell scripts or Actions |
| Action | A reusable module from GitHub Marketplace or your own code, referenced with uses |
| Runner | The VM that executes a job. GitHub provides hosted runners; you can also self-host |
| Secret | An encrypted env var configured in repo Settings, referenced via ${{ secrets.MY_KEY }} |
Workflow File Structure
All workflow files live under .github/workflows/ in the repo root. The filename is up to you; .yml and .yaml both work.
A minimal example:
name: Deploy # Workflow name shown on the Actions page
on: # Trigger conditions
push:
branches:
- main
jobs: # One or more jobs
build: # Job ID — name it anything
runs-on: ubuntu-latest # Which runner to use
steps: # Steps for this job
- name: Checkout code
uses: actions/checkout@v4
- name: Print Node version
run: node -v
Trigger Events
on:
# Trigger on push to specific branches
push:
branches:
- main
- 'release/**' # Glob patterns are supported
paths:
- 'app/**' # Only trigger when these paths change (optional)
# Trigger on pull request activity
pull_request:
branches:
- main
types: [opened, synchronize, reopened]
# Scheduled trigger (cron syntax, UTC timezone)
schedule:
- cron: '0 2 * * *' # Every day at UTC 02:00
# Manual trigger (shows a "Run workflow" button on the Actions page)
workflow_dispatch:
inputs:
environment:
description: Deployment environment
required: true
default: production
type: choice
options:
- production
- staging
Hands-on: CI/CD for This Site
This site is a Nuxt 4 project managed with pnpm and served by PM2. The goals:
- CI (linting): Run ESLint automatically on every PR or push
- CD (deployment): On push to
main, build on the Runner, pack the output into an archive, upload it via SCP, then SSH in to extract and restart PM2
Step 1: Prepare Secrets
Add the following variables in your repo's Settings → Secrets and variables → Actions:
| Secret Name | Value |
|---|---|
SSH_HOST | Server IP or hostname, e.g. 123.45.67.89 |
SSH_PORT | SSH port, default 22 |
SSH_USER | Login username, e.g. root or deploy |
SSH_KEY | Private key contents (cat ~/.ssh/id_ed25519) |
SSH_PATH | Deploy directory on server, e.g. /www/wwwroot/fonnpo-web |
Private key setup: Paste the full content of
~/.ssh/id_ed25519(including the-----BEGIN...and-----END...lines) intoSSH_KEY. Then append the corresponding public key (~/.ssh/id_ed25519.pub) to~/.ssh/authorized_keyson the server.
Step 2: CI Workflow (Linting)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # Cache the pnpm store to speed up installs
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm eslint .
--frozen-lockfile ensures the CI environment installs exactly what's in pnpm-lock.yaml — no silent upgrades.
Step 3: CD Workflow (Deployment)
This site uses the strategy of building on the Runner and uploading the artifact rather than running git pull + build on the server. Benefits: the server doesn't need a Node.js build environment, a failed build never affects the live service, and server load stays low.
name: Deploy to Self-hosted Server
on:
push:
branches: [main]
workflow_dispatch: # Enables manual trigger from the Actions page
jobs:
deploy:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # Force Actions runtime to use Node 24
NODE_OPTIONS: --max-old-space-size=4096 # Prevent OOM on large builds
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Pack release
run: |
tar -czf release.tgz .output ecosystem.config.cjs package.json pnpm-lock.yaml
- name: Upload to server (SCP)
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
source: release.tgz
target: ${{ secrets.SSH_PATH }}
- name: Extract and restart on server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
cd ${{ secrets.SSH_PATH }}
tar -xzf release.tgz
rm -f release.tgz
corepack enable
pnpm install --prod --frozen-lockfile
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
Key points:
envblock:FORCE_JAVASCRIPT_ACTIONS_TO_NODE24aligns the Actions runtime with Node 24;NODE_OPTIONSincreases the heap limit to prevent Nuxt OOM on large projectstarpacking: Only packs.output(Nuxt build artifact),ecosystem.config.cjs,package.json, andpnpm-lock.yaml— source code is not uploadedappleboy/scp-action: Transfers the archive to the server'sSSH_PATHdirectory via SCPpnpm install --prod: Installs only production deps on the server — no build tools, smaller footprintpm2 startOrReload: More robust thanpm2 reload— creates the process if it doesn't exist, smoothly reloads it if it does, with no service interruption;pm2 savepersists the process list so it survives server reboots
Step 4: Combined Workflow (CI then CD)
To deploy only when CI passes, use needs to serialize the jobs:
name: CI / CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm eslint .
deploy:
name: Deploy to production
runs-on: ubuntu-latest
needs: lint # ← Wait for lint to succeed
if: github.ref == 'refs/heads/main' # ← PRs skip deploy; only main branch triggers it
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
NODE_OPTIONS: --max-old-space-size=4096
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Pack release
run: tar -czf release.tgz .output ecosystem.config.cjs package.json pnpm-lock.yaml
- name: Upload to server (SCP)
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
source: release.tgz
target: ${{ secrets.SSH_PATH }}
- name: Extract and restart on server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
cd ${{ secrets.SSH_PATH }}
tar -xzf release.tgz && rm -f release.tgz
corepack enable
pnpm install --prod --frozen-lockfile
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
PRs trigger only lint. Merging to main runs lint first and then deploy — both must be green for a successful run.
Tips & Tricks
Cache Dependencies for Faster Builds
pnpm with actions/setup-node's cache: 'pnpm' automatically caches the store, dramatically cutting pnpm install time:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # Cache key is derived from pnpm-lock.yaml automatically
Environment Variables vs Secrets
env:
NODE_ENV: production # Plaintext — visible in logs
steps:
- run: echo ${{ secrets.TOKEN }} # Secret — automatically masked as *** in logs
Use env for non-sensitive config; use Secrets for passwords, tokens, and private keys.
Manual Deploy Trigger
Sometimes you want to redeploy without pushing code (e.g. after a server restart). Add workflow_dispatch to get a "Run workflow" button on the Actions page:
on:
workflow_dispatch: # Adds "Run workflow" button to the Actions page
push:
branches: [main]
Passing Data Between Jobs
Jobs are isolated. Share data via outputs:
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get-version.outputs.version }}
steps:
- id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"
Trigger Only on Relevant Path Changes
When frontend and backend share a repo, trigger workflows only when the relevant files change:
on:
push:
paths:
- 'app/**'
- nuxt.config.ts
- package.json
- pnpm-lock.yaml
branches: [main]
Viewing Run Results
After pushing, check the Actions tab in your GitHub repo to see all workflow runs. Click into a run to expand each step and read the full logs. Failures are highlighted in red with clear error messages for quick debugging.
The full workflow execution looks like this:
Actions
└── Deploy to Self-hosted Server
├── 🟢 lint (20s)
│ ├── Checkout code
│ ├── Setup pnpm / Node.js
│ ├── pnpm install
│ └── ESLint
└── 🟢 deploy (3min)
├── Checkout code
├── Setup pnpm / Node.js
├── pnpm install
├── pnpm build
├── tar pack release
├── SCP upload to server
└── SSH extract and restart PM2
├── tar -xzf release.tgz
├── pnpm install --prod
├── pm2 startOrReload
└── pm2 save
Once it's configured, every git push is fully automated — no more manually SSHing into the server.
Fonnpo