An Angular Pipeline with Jenkins: Node Container, npm Cache, Headless Tests and stash/unstash
We build the Angular 22 app in a node:24 container with npm ci, ng lint, Vitest and ng build in parallel with the .NET API, and combine both into one package with stash/unstash. The npm cache volume, Vitest's JUnit reporter, budgets, the missing-package error for browser mode, not stashing a 329 MB node_modules, and parallel stage durations in Pipeline Graph View.
Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), Node.js 24 (the node:24-trixie image, npm 11.19), Angular 22.2, Vitest 5.1, angular-eslint and .NET 10.
Guess First
The Angular build runs on one agent, the .NET build on another, and you want to combine their outputs into a single package in a third stage. A colleague says "easy, let's stash the whole bookstore-web folder at the end of the Web stage". That folder holds a 220 KB dist and a node_modules. How many megabytes do you think node_modules is, and where does every stashed byte go?
Two Applications in Parallel, One Package
Bookstore's Jenkinsfile now builds both applications together. The API (Application Programming Interface) and Web stages run in parallel, then the Package stage combines their outputs:
pipeline {
agent none
options {
timeout(time: 30, unit: 'MINUTES')
timestamps()
buildDiscarder(logRotator(numToKeepStr: '20'))
disableConcurrentBuilds()
}
environment {
VERSION = "1.4.${BUILD_NUMBER}"
}
stages {
stage('Build and Test') {
parallel {
stage('API') {
agent {
docker {
image 'mcr.microsoft.com/dotnet/sdk:10.0'
label 'docker'
args '-v bookstore-nuget:/nuget'
}
}
environment {
NUGET_PACKAGES = '/nuget/packages'
DOTNET_CLI_TELEMETRY_OPTOUT = '1'
DOTNET_NOLOGO = '1'
}
steps {
sh 'dotnet restore Bookstore.slnx'
sh 'dotnet build Bookstore.slnx -c Release --no-restore -p:Version=$VERSION'
sh '''
dotnet test --solution Bookstore.slnx -c Release --no-build \
--results-directory TestResults \
--report-xunit-junit --report-xunit-junit-filename bookstore-api.junit.xml \
--coverage --coverage-output-format cobertura --coverage-output bookstore-api.cobertura.xml
'''
sh 'dotnet publish src/Bookstore.Api -c Release --no-build -p:Version=$VERSION -o out/api'
stash name: 'api-publish', includes: 'out/api/**'
}
post {
always {
junit testResults: 'TestResults/**/*.junit.xml'
recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'TestResults/**/*.cobertura.xml']],
sourceCodeRetention: 'NEVER',
qualityGates: [[threshold: 80.0, metric: 'LINE', baseline: 'PROJECT', criticality: 'UNSTABLE']])
}
}
}
stage('Web') {
agent {
docker {
image 'node:24-trixie'
label 'docker'
args '-v bookstore-npm:/npm'
}
}
environment {
npm_config_cache = '/npm'
NG_CLI_ANALYTICS = 'false'
}
steps {
dir('bookstore-web') {
sh 'npm ci --no-audit --no-fund'
sh 'npx ng lint'
sh 'npx ng test --watch=false --reporters=default --reporters=junit --output-file=test-results/web.junit.xml'
sh 'npx ng build'
sh 'du -sh node_modules dist/bookstore-web/browser'
}
stash name: 'web-dist', includes: 'bookstore-web/dist/bookstore-web/browser/**'
}
post {
always {
junit testResults: 'bookstore-web/test-results/*.junit.xml'
}
}
}
}
}
stage('Package') {
agent { label 'linux' }
steps {
unstash 'api-publish'
unstash 'web-dist'
sh '''
mkdir -p out/api/wwwroot
cp -r bookstore-web/dist/bookstore-web/browser/. out/api/wwwroot/
tar -czf bookstore-$VERSION.tgz -C out/api .
tar -tzf bookstore-$VERSION.tgz | grep wwwroot | head -5
'''
archiveArtifacts artifacts: "bookstore-${VERSION}.tgz", fingerprint: true
}
post {
cleanup {
cleanWs()
}
}
}
}
}
At the top level there is agent none: no executor is held needlessly for the whole pipeline, each stage takes the agent it needs. The API stage is the same as in the previous post, except it now ends with stash instead of an archive. So that the application can also serve the Angular build, I added three lines to Program.cs: app.UseDefaultFiles(), app.UseStaticFiles() and, after the routes, app.MapFallbackToFile("index.html"). That way /api/books goes to the API, and every other address goes to Angular's index.html; we covered the server side of SPA (Single Page Application) routing in the Angular series post "Angular Build and Deploy: ng build, Environment Files, Budgets and SPA Routing".
The Node Container and the npm Cache
The Web stage runs in the official node:24-trixie image. The Node version Angular 22 asks for is ^22.22.3 || ^24.15.0 || >=26.0.0; we do not install Node on the agent, the image tag decides it. The counterpart of the NuGet cache on the .NET side is the npm cache here: npm_config_cache=/npm and a persistent bookstore-npm volume. In the Node image uid 1000 is the node user, and I prepared the volume once the same way:
$ docker run --rm -u 0 -v bookstore-npm:/npm node:24-trixie sh -c 'chown 1000:1000 /npm && ls -ld /npm'
drwxr-xr-x 2 node node 4096 Nov 8 04:37 /npm
Do not confuse the cache with node_modules. npm ci deletes node_modules every time and reinstalls it exactly from package-lock.json; the cache keeps the downloaded package archives and avoids downloading them from the network again. Trying to keep node_modules between builds (leaving it in the workspace or putting it into the cache volume) leads to inconsistent installs when the lock file changes; npm ci plus a cache is the right pair. The first build's log:
[2026-11-08T04:37:36.196Z] + npm ci --no-audit --no-fund
[2026-11-08T04:37:42.722Z] added 419 packages in 6s
[2026-11-08T04:37:42.722Z] npm warn install-scripts 4 packages have install scripts not yet covered by allowScripts:
[2026-11-08T04:37:42.722Z] npm warn install-scripts @parcel/watcher@2.6.0 (install: node scripts/build-from-source.js)
[2026-11-08T04:37:42.722Z] npm warn install-scripts esbuild@0.28.2 (postinstall: node install.js)
[2026-11-08T04:37:42.722Z] npm warn install-scripts lmdb@3.5.6 (install: node-gyp-build-optional-packages)
[2026-11-08T04:37:42.722Z] npm warn install-scripts msgpackr-extract@3.0.7 (install: node-gyp-build-optional-packages)
The warning lines are this npm version listing the packages that run install scripts: packages such as esbuild download the binary for the platform during installation. Do not ignore these lines; from a supply chain point of view, knowing which package runs code during installation is valuable. We discussed the security side of this in the Web Security series post "OWASP (Open Worldwide Application Security Project) A03: Software Supply Chain Failures".
Lint, Test and Build
An Angular 22 project created with ng new comes without a lint configuration; I added it with ng add angular-eslint, which created the ng lint command. On the test side Angular 22's default is Vitest. If no browser is chosen, ng test runs the tests inside Node.js with jsdom; so no browser is needed in the CI (Continuous Integration) container. For the JUnit report Jenkins reads, I enabled Vitest's built-in junit reporter:
[2026-11-08T04:37:42.991Z] + npx ng lint
[2026-11-08T04:37:43.801Z] All files pass linting.
[2026-11-08T04:37:44.871Z] + npx ng test --watch=false --reporters=default --reporters=junit --output-file=test-results/web.junit.xml
[2026-11-08T04:37:46.508Z] + npx ng build
[2026-11-08T04:37:47.320Z] Initial chunk files | Names | Raw size | Estimated transfer size
[2026-11-08T04:37:47.320Z] main-RWCIUYT4.js | main | 198.68 kB | 54.52 kB
[2026-11-08T04:37:47.320Z] styles-5INURTSO.css | styles | 0 bytes | 0 bytes
[2026-11-08T04:37:47.320Z] Application bundle generation complete. [0.730 seconds] - 2026-11-08T04:37:47.253Z
[2026-11-08T04:37:47.320Z] Output location: /home/jenkins/agent/workspace/bookstore_main@2/bookstore-web/dist/bookstore-web
--watch=false is required: in watch mode ng test never ends and the build hangs until the timeout. The start of the JUnit report, in the format Jenkins expects:
<testsuites name="vitest tests" tests="4" failures="0" errors="0" time="0.025574126">
<testsuite name="src/app/app.spec.ts" timestamp="2026-11-08T04:37:02.745Z" hostname="eff54429eedc" tests="2" failures="0" errors="0" skipped="0" time="0.023815459">
The build itself has budgets in effect: the production configuration in angular.json warns at 500 kB and errors at 1 MB for the initial bundle. If a budget is exceeded, ng build returns a non-zero exit code and the stage turns red; so budgets become a quality gate of the pipeline without a separate tool. The tests of both applications are combined in the same build's report:
{"_class":"hudson.tasks.junit.TestResult","failCount":0,"passCount":10,"suites":[{"name":"_home_jenkins_agent_workspace_bookstore_main_tests_Bookstore.Api.Tests_bin_Release_net10.0_Bookstore.Api.Tests.dll"},{"name":"src_app_app.spec.ts"},{"name":"src_app_book-price.spec.ts"}]}
When You Need a Headless Browser
If you want the tests to run in a real browser (--browsers=ChromiumHeadless), you need Vitest's browser mode and a provider package. When I tried it in the node:24-trixie image without adding the packages:
The following packages are required but were not found:
- The "browsers" option requires either "@vitest/browser-playwright", "@vitest/browser-webdriverio", or "@vitest/browser-preview" to be installed.
Please install the missing packages and rerun the test command.
Adding the package is not enough either; Chromium itself and its system libraries must be in the image too. For that you either use Playwright's official image, which has the browsers and their dependencies installed, as the agent, or run npx playwright install --with-deps chromium in your own image. I did not run browser mode in this post; jsdom is enough for Bookstore's tests.
stash and unstash
Parallel stages run in separate workspaces even when they land on the same agent; in the log you can see that the Web stage worked in the bookstore_main@2 folder. The Package stage, in this run, landed on another agent, ssh-agent-1. The way to move files between workspaces in Jenkins is stash/unstash: the files are copied from the agent to the controller, kept there for the duration of the build, and copied back to whichever agent runs unstash.
[2026-11-08T04:37:43.373Z] Stashed 9 file(s)
[2026-11-08T04:37:47.588Z] + du -sh node_modules dist/bookstore-web/browser
[2026-11-08T04:37:47.588Z] 329M node_modules
[2026-11-08T04:37:47.588Z] 220K dist/bookstore-web/browser
[2026-11-08T04:37:47.628Z] Stashed 4 file(s)
[2026-11-08T04:37:48.937Z] Running on ssh-agent-1 in /home/jenkins/agent/workspace/bookstore_main
[2026-11-08T04:37:49.382Z] ./wwwroot/
[2026-11-08T04:37:49.382Z] ./wwwroot/favicon.ico
[2026-11-08T04:37:49.382Z] ./wwwroot/styles-5INURTSO.css
[2026-11-08T04:37:49.382Z] ./wwwroot/main-RWCIUYT4.js
[2026-11-08T04:37:49.382Z] ./wwwroot/index.html
The answer to the opening question: node_modules is 329 MB, dist is 220 KB. Stashing the whole bookstore-web folder means moving 329 MB from the agent to the controller, and from there to the other agent, on every build; the controller's network and disk pay for it every time. Use stash for small, intermediate files (exactly the folder you need, through includes); for large and lasting outputs an artifact repository is the right place.
Reading Parallel Stages in Pipeline Graph View
The Pipeline Graph View plugin shows the stages as a graph on the build page: parallel branches side by side, each with its own duration and status. A summary of the same data that I extracted from the endpoint the plugin uses (/job/bookstore/job/main/4/stages/tree):
Build and Test: success, STAGE, 13890 ms
API: success, PARALLEL, 9625 ms
Web: success, PARALLEL, 13860 ms
Package: success, STAGE, 573 ms
Had they run one after the other, API and Web would have taken about 23.5 seconds in total; in parallel "Build and Test" took 13.9 seconds, as long as the slowest branch. You see this at a glance in the graph: the critical path is the Web branch, so that is where you look first if you want to speed things up. We will come back to this view, which replaces Blue Ocean, in the last post.
In Production
package-lock.json must be in the repository and npm ci must be used. npm install can update the lock file; in CI that means the tested dependencies and the dependencies in the repository diverge.
Do not bake environment settings into the build. Angular's environment files write their values into the bundle at build time; if you build separately for staging and for production, the package you tested and the package that goes live are different. In Bookstore the web app is served from the same origin as the API, so it can call the API through the relative /api address; the environment's address never enters the bundle, and a single build works in every environment. If there are values that must change per environment (an analytics key, for example), a runtime configuration approach that reads them at startup from a file such as /config.json preserves the "build once, deploy many" principle. The single bookstore-1.4.4.tgz this pipeline produces will go unchanged first to staging and then to production in the ninth post.
Pin the image tag. node:24-trixie moves with every new Node 24 release; for fully reproducible builds use a fully versioned tag or a digest.
Parallel branches landing on the same agent compete for resources. In our environment docker-agent-1 had two executors, and both branches ran on the same machine at the same time. For heavy builds consider spreading the branches across different agents with label, or keeping executor counts low.
If one parallel branch fails, the other keeps running by default. If you want fast feedback, use options { parallelsAlwaysFailFast() } or failFast true in the parallel block; we will try this in the ninth post.
Common Pitfalls
Stashing node_modules. Hundreds of megabytes travel through the controller on every build.
Leaving ng test in watch mode. The pipeline hangs until the timeout; do not forget --watch=false.
An image missing browser dependencies. If you will run tests in browser mode, choose an image that contains Chromium and its system libraries; otherwise you will see the error above or, worse, a browser that cannot start because of a missing .so file.
Cache volume ownership. A root-owned volume gives npm running as uid 1000 an EACCES error.
Ignoring budget warnings. The warning threshold does not break the build; seeing the same warning for months means you are quietly approaching the error threshold.
A Small Experiment
★☆☆ Make a deliberate lint error in the Web stage (for example an unused import) and see how ng lint breaks the stage.
★★☆ Lower the initial budget error threshold in angular.json below the current bundle size, run the build and examine how the error looks in the log. Then restore the threshold.
★★★ Deliberately widen the stash to the whole bookstore-web folder and compare the durations of two builds. Then write an alternative that builds API and Web sequentially in the same stage instead of using stash; which one is faster in your environment?
The package is ready but has not gone anywhere yet. In the next post we move to deployment: parallel and failFast, matrix, approval with input, preventing concurrent deploys with Lockable Resources, and deploying to a Linux server with sshagent, rsync and systemctl.