logo Fonnpo

Contact Me
May 31, 2026 4 min read Fonnpo

GitHub Actions: A Practical Guide

Core concepts, events, and caching — CI/CD for a Nuxt project with auto-deploy via SSH.

#DevOps#CI/CD#GitHub

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.

TermDescription
WorkflowA complete automation pipeline, stored as a YAML file under .github/workflows/
EventWhat triggers the workflow — push, pull_request, schedule, manual dispatch, etc.
JobAn execution unit inside a workflow. Each job runs on an isolated VM. Jobs run in parallel by default
StepA single operation inside a job. Steps run sequentially and can be shell scripts or Actions
ActionA reusable module from GitHub Marketplace or your own code, referenced with uses
RunnerThe VM that executes a job. GitHub provides hosted runners; you can also self-host
SecretAn 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:

.github/workflows/deploy.yml
        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

Yaml
        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:

  1. CI (linting): Run ESLint automatically on every PR or push
  2. 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 NameValue
SSH_HOSTServer IP or hostname, e.g. 123.45.67.89
SSH_PORTSSH port, default 22
SSH_USERLogin username, e.g. root or deploy
SSH_KEYPrivate key contents (cat ~/.ssh/id_ed25519)
SSH_PATHDeploy 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) into SSH_KEY. Then append the corresponding public key (~/.ssh/id_ed25519.pub) to ~/.ssh/authorized_keys on the server.

Step 2: CI Workflow (Linting)

.github/workflows/ci.yml
        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 .

    
Collapse

--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.

.github/workflows/deploy.yml
        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

    
Collapse

Key points:

  • env block: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 aligns the Actions runtime with Node 24; NODE_OPTIONS increases the heap limit to prevent Nuxt OOM on large projects
  • tar packing: Only packs .output (Nuxt build artifact), ecosystem.config.cjs, package.json, and pnpm-lock.yaml — source code is not uploaded
  • appleboy/scp-action: Transfers the archive to the server's SSH_PATH directory via SCP
  • pnpm install --prod: Installs only production deps on the server — no build tools, smaller footprint
  • pm2 startOrReload: More robust than pm2 reload — creates the process if it doesn't exist, smoothly reloads it if it does, with no service interruption; pm2 save persists 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:

.github/workflows/ci-cd.yml
        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

    
Collapse

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:

Yaml
        - uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: pnpm # Cache key is derived from pnpm-lock.yaml automatically

    

Environment Variables vs Secrets

Yaml
        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:

Yaml
        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:

Yaml
        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:

Yaml
        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:

Text
        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