ShortIQ

ShortIQ

DevOps

How to Set Up GitHub Actions CI/CD for a Node.js App

A step-by-step guide to building a GitHub Actions CI/CD pipeline for a Node.js application — covering test automation, Docker builds, and deployment to a VPS or cloud server.

May 29, 2026ShortIQ Team

Advertisement

What This Guide Covers

This guide walks through building a complete GitHub Actions CI/CD pipeline for a Node.js application. By the end you will have a workflow that runs your test suite on every pull request, builds and tags a Docker image on merge to main, and deploys to a VPS using SSH — all without touching your server manually.

The setup uses GitHub Actions native features: workflow YAML, secrets, caching, and environment protection rules. No third-party CI service is required. If you already have a Node.js project with a working test suite, you can adapt the examples here in under an hour.

Prerequisites: a Node.js project on GitHub with npm scripts for test and build, a VPS or cloud instance (Ubuntu 22.04 or 24.04) with SSH access, and Docker installed on the server if you want the Docker deployment path.

Understanding GitHub Actions Workflow Structure

A GitHub Actions workflow is a YAML file stored in .github/workflows/ in your repository. GitHub detects these files automatically and runs them based on the triggers you define. Each workflow contains one or more jobs. Jobs run in parallel by default unless you declare dependencies with the needs keyword.

Each job runs on a runner — a virtual machine GitHub provides (ubuntu-latest, windows-latest, macos-latest) or one you host yourself. Steps within a job execute sequentially. A step is either a shell command (run) or a pre-built action from the GitHub Marketplace (uses).

The three most important top-level keys are name (display name), on (triggers), and jobs. A basic CI workflow for Node.js triggers on push and pull_request events targeting the main branch, runs on ubuntu-latest, checks out the code, installs Node.js, installs dependencies, and runs tests.

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  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 test

Caching Dependencies to Speed Up Builds

The actions/setup-node@v4 action has built-in caching via the cache: npm option. When enabled it caches the npm cache directory keyed by the hash of package-lock.json. On subsequent runs where no dependencies changed, the install step takes 5–10 seconds instead of 30–60 seconds.

For monorepos or projects with multiple package.json files, you may need to set cache-dependency-path to point to the lockfile. If you use pnpm or yarn, set cache to pnpm or yarn respectively and make sure the package manager is installed before the setup-node step.

You can also cache the node_modules directory directly using actions/cache, but this is generally less reliable than caching the npm cache because node_modules contains platform-specific binaries that may not be compatible across different runner images. The npm cache approach is safer and handles platform differences automatically.

yaml
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: '**/package-lock.json'

Running Tests and Linting in Parallel

For larger projects, splitting test and lint into separate parallel jobs reduces total pipeline time. Add a second job with the same checkout and install steps, but run npm run lint instead of npm test. Both jobs start simultaneously and GitHub reports them separately in the pull request checks.

Use a matrix strategy when you need to test against multiple Node.js versions. The matrix.node-version array runs the entire job once per version. This is useful if your package targets Node 18 and 20, or if you want to ensure compatibility before upgrading.

For test coverage, add a coverage step after the test run and upload the report as a workflow artifact. The actions/upload-artifact action stores the report so you can download it from the Actions tab for up to 90 days. If you use Codecov or Coveralls, their official GitHub Actions handle uploading in one step.

yaml
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 -- --coverage

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

Building and Pushing a Docker Image on Merge

Add a separate deploy job that triggers only on push to main (not on pull requests) and requires the test job to pass first using needs: test. This job logs in to your container registry, builds the Docker image, tags it with the Git SHA for traceability, and pushes it.

GitHub Container Registry (ghcr.io) is the simplest option because authentication uses the built-in GITHUB_TOKEN secret — no external credentials needed. For Docker Hub, add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN as repository secrets in Settings → Secrets and variables → Actions.

Tag your images with both the Git SHA and latest. The SHA tag gives you a permanent reference for rollbacks. The latest tag lets deployment scripts always pull the most recent build without knowing the exact SHA. Never use latest as your only tag — you lose traceability when something breaks.

yaml
  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

Deploying to a VPS via SSH

After the Docker image is pushed, add a deploy job that SSHes into your server, pulls the new image, and restarts the container. Store your server IP, SSH username, and SSH private key as GitHub repository secrets. Never put SSH keys or server credentials directly in workflow YAML.

Generate a dedicated deployment SSH key pair for CI: ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key. Add the public key to ~/.ssh/authorized_keys on your server and add the private key content as a repository secret named DEPLOY_SSH_KEY. Use a separate key pair for deployments rather than your personal key so you can rotate or revoke it independently.

The appleboy/ssh-action is the simplest way to run SSH commands from a workflow step. Pass your server host, username, private key secret, and the shell commands to run. For a Docker Compose setup, the commands are typically: docker pull the new image, docker compose up -d to restart, and docker image prune -f to clean old images.

yaml
  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: production

    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker compose -f /opt/myapp/docker-compose.yml up -d
            docker image prune -f

Using Environments and Protection Rules

GitHub Environments let you add protection rules to deployment jobs — for example, requiring a manual approval before deploying to production. Create the environment in Settings → Environments, add required reviewers, and reference it in your workflow job with environment: production. The job pauses and waits for approval before running.

Environment secrets are scoped to a specific environment and are only available to jobs that reference that environment. Use environment secrets for production credentials (database URLs, API keys) so they cannot be accessed by pull request workflows from forks, which only have access to repository-level secrets if you explicitly allow it.

For branch protection, require the CI workflow status checks to pass before merging. Go to Settings → Branches → Branch protection rules, add a rule for main, and check "Require status checks to pass before merging." Select the specific job names from your workflow (test, lint) as required checks. This prevents merging code that breaks tests.

Handling Environment Variables and Secrets Securely

Never hardcode environment variables like database URLs, API keys, or JWT secrets in workflow YAML. Store them as GitHub repository secrets and inject them using the env key or directly in run steps with ${{ secrets.SECRET_NAME }}. Secrets are masked in logs — GitHub replaces them with *** if they appear in output.

For non-secret configuration that differs between environments (like NODE_ENV or API base URLs), use GitHub Environment variables rather than secrets. Environment variables are visible in the Actions UI, which makes debugging easier. Reserve secrets for values that must stay hidden.

To pass secrets to your application at runtime on the server, write them to a .env file during deployment rather than passing them through Docker build arguments. Build arguments can appear in image layers and in the build cache. The safest pattern is to mount a .env file from the server file system into the container at runtime using Docker Compose volumes.

yaml
      - name: Write production env file
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cat > /opt/myapp/.env << 'EOF'
            DATABASE_URL=${{ secrets.DATABASE_URL }}
            JWT_SECRET=${{ secrets.JWT_SECRET }}
            NODE_ENV=production
            EOF

Common GitHub Actions Mistakes and How to Fix Them

Using actions/checkout without specifying a version (uses: actions/checkout instead of uses: actions/checkout@v4) means your workflow always uses the latest major version, which can introduce breaking changes silently. Always pin to a major version tag or a specific commit SHA for stability.

Running npm install instead of npm ci in CI environments installs packages differently and can produce inconsistent results. npm ci is designed for CI: it installs exactly what is in package-lock.json, fails if the lockfile is missing or out of sync with package.json, and is faster because it skips the dependency resolution step.

Forgetting the if: github.ref == refs/heads/main condition on deploy jobs means your pipeline attempts to deploy on every pull request. Use the if condition or trigger the deploy workflow separately on push to main only. Deploying on every PR push wastes minutes and can cause race conditions if multiple PRs are open simultaneously.

Not setting timeout-minutes on long-running jobs means a stuck workflow occupies a runner indefinitely and consumes your monthly free minutes. Add timeout-minutes: 15 to jobs and timeout-minutes: 5 to individual steps that should complete quickly. GitHub will cancel the job automatically and mark it as failed.

  • Always pin action versions: actions/checkout@v4, not actions/checkout
  • Use npm ci not npm install in CI workflows
  • Add if: github.ref == refs/heads/main to deployment jobs
  • Set timeout-minutes on every job to prevent stuck runners
  • Use environment secrets for production credentials, not repository secrets

FAQ

How do I trigger a GitHub Actions workflow only on push to main?

Set the on trigger to push with branches: [main]. To also run on pull requests without deploying, add the if: github.ref == refs/heads/main condition on the deploy job specifically, so tests run on PRs but deployment only runs on direct pushes to main.

How do I store secrets in GitHub Actions?

Go to your repository Settings → Secrets and variables → Actions → New repository secret. Reference them in your workflow YAML with ${{ secrets.SECRET_NAME }}. GitHub masks secret values in logs automatically. For environment-specific secrets, use GitHub Environments instead of repository secrets.

What is the difference between npm install and npm ci in GitHub Actions?

npm ci installs exactly what is in package-lock.json, fails if the lockfile is out of sync, does not write to package-lock.json, and is faster. npm install can modify the lockfile and resolve dependencies differently. Always use npm ci in CI workflows for reproducible builds.

How do I deploy to a VPS from GitHub Actions?

Store your server IP, SSH username, and SSH private key as repository secrets. Use the appleboy/ssh-action in a deploy job to run commands on the server — typically pulling the latest Docker image and restarting the container with docker compose up -d. Add needs: test so deployment only runs after tests pass.

How do I cache node_modules in GitHub Actions?

Use actions/setup-node@v4 with cache: npm. This caches the npm cache directory keyed by package-lock.json hash. Subsequent runs where dependencies have not changed skip the full install. Avoid caching node_modules directly as it contains platform-specific binaries.

Related free tools

If you want to turn this topic into action, use one of ShortIQ's free tools for campaign planning, UTM structure, or QR distribution.

Continue Reading

Explore more guides on link shortener SaaS strategy, Bitly alternatives, and white label link management.

Was this article helpful?

Tell us if this guide solved the problem or what was still missing. We use this to improve the blog and only follow up if you explicitly allow it.

We use this to improve tutorials, examples, and technical depth.