A GitHub Action that runs an npm build checks out the repo, sets up Node with actions/setup-node, installs with npm ci rather than npm install, and runs npm run build. That is the five-line version every tutorial shows. The parts that make it a workflow you can trust are the ones usually skipped: caching so the install does not take two minutes on every push, a version matrix if the project supports more than one Node version, uploading the build as an artifact, and a step that actually deploys it once the build passes on the default branch.
The official GitHub docs give you a correct, minimal workflow, and Stack Overflow is full of people whose build completes without errors but produces no output, usually because the working directory or the artifact step is wrong. Neither gap is really about npm or Actions; it is about treating CI as a pipeline with a destination, not a single green checkmark. Here is the whole pipeline.
Table of contents
- The minimum workflow, and why npm ci over npm install
- Caching dependencies so builds do not crawl
- A Node version matrix, when you need one
- Failing fast and uploading the build as an artifact
- Deploying the output on merge
- Where the build actually runs after merge
- How this fits the rest of the stack
- FAQ
The minimum workflow, and why npm ci over npm install
A working build workflow triggers on push and pull request, checks out the code, installs a Node version, and runs the build.
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: "20"
- run: npm ci
- run: npm run build
npm ci instead of npm install matters more in CI than anywhere else. It installs exactly what package-lock.json specifies, fails if the lockfile and package.json disagree, and deletes node_modules first, so a stale cache from a previous run cannot silently pass a build that would fail on a clean checkout.
Caching dependencies so builds do not crawl
actions/setup-node has a built-in cache option keyed to your lockfile and package manager, which is the simplest way to speed up the install step without a separate caching action.
- uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
- run: npm ci
The cache key changes automatically when package-lock.json changes, so a dependency bump gets a fresh install rather than a stale cache silently reused. For a mid-sized project this alone turns a two-minute install into ten seconds on most runs.
A Node version matrix, when you need one
If the project is a library that other people’s projects depend on, or you support more than one Node LTS line, a matrix runs the same job across versions in parallel rather than as separate workflows.
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
For an application deployed on one Node version, a matrix is unnecessary weight; run it only for shared packages where works on the version I use is not actually the requirement.
Failing fast and uploading the build as an artifact
—if-present on the build script avoids a hard failure on projects where the build step is optional, but for a real deploy pipeline you want the opposite: a missing build script or empty output should fail the job loudly, not silently pass and produce nothing later.
Uploading the output as an artifact separates did it build from did it deploy, which matters when a later job, or a separate deploy workflow, needs the files without rebuilding them.
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
if-no-files-found: error
if-no-files-found: error is the fix for the exact Stack Overflow complaint about builds that complete without errors and produce nothing: a wrong path here fails the job instead of quietly uploading an empty artifact.
Deploying the output on merge
A build workflow that only runs on every push and pull request is a check, not a pipeline. Add a job, gated to the default branch, that downloads the artifact and deploys it, or triggers the platform’s own build-and-deploy from a merge.
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: echo "deploy dist/ to your host here"
What replaces that last line depends on the host. A platform that builds directly from the repository on push, rather than accepting an uploaded artifact, does not need this job at all, only the build job as a pull request check; the deploy is triggered by the merge itself.
Where the build actually runs after merge
A GitHub Action is the right place to validate a build on every pull request: catch a broken build before it merges, not after. Whether it should also be the thing that deploys depends on the host. Many static and service hosts, including RunxBuild, build from the connected repository directly on push, which means the Action’s job is verification and the host’s job is the actual build-and-publish, so the same npm run build does not need to run twice.
Keeping CI as the gate and the host as the builder avoids a common failure mode: an Action that deploys an artifact built in one environment while the host’s own build, on a different environment, is what actually needs to succeed.
How this fits the rest of the stack
Whichever half deploys the build, model the hosting cost before the workflow is the only thing you have tuned: a static site’s bandwidth and a service’s plan both show up in the RunxBuild hosting calculator. Static site builds on RunxBuild covers what happens when the platform builds directly from your repository on push, and the dashboard is where that connection gets made.
Useful related references:
- actions/setup-node: The GitHub Actions Node Setup That Does Not Fight Your Deploy
- CI/CD Pipeline Tools: 7 That Matter in 2026 (and How to Pick)
- GitHub Pipelines Log Retention: The Default, the Override, and the Audit Trail That Survives a Year
- Static site builds on RunxBuild
FAQ
Should I use npm ci or npm install in GitHub Actions?
Use npm ci. It installs exactly what package-lock.json specifies, fails if the lockfile is out of sync with package.json, and always starts from a clean node_modules, which makes CI builds reproducible in a way npm install does not guarantee.
Why does my GitHub Action say the build succeeded but there’s no output?
Usually the build command ran in the wrong working directory, or the artifact upload step points at the wrong path. Add if-no-files-found: error to the upload-artifact step so an empty or missing output fails the job instead of passing silently.
Do I need a Node version matrix for a normal app?
No. A matrix is for packages that need to work across several Node versions for other people’s projects. An application you deploy on one Node version should just pin that version with actions/setup-node and skip the matrix.
How do I cache node_modules in GitHub Actions?
Use the built-in cache option on actions/setup-node, set to your package manager. It keys the cache to your lockfile automatically, so a dependency change gets a fresh install instead of a stale, silently reused cache.
Should GitHub Actions deploy the build, or just verify it?
Depends on the host. If your host builds directly from the repository on push, let the Action verify pull requests only and let the host build and deploy on merge, so the build does not run twice in two different environments.