Practice Playwright DevOps NPM Registry
DevOps
Draft
Private preview - DevOps chapter, NPM Registry deep page.
Sidebar wiring lives in the parallel global pass. Not indexed.
Curriculum . Playwright DevOps . Registry

Private NPM Registry - JFrog Artifactory and Sonatype Nexus

Why your Playwright fixtures, page objects, and helper libs should live behind a private registry, and how to set one up two different ways - JFrog Artifactory and Sonatype Nexus. Side-by-side topology, .npmrc anatomy, scoped publish + consume flow, and a working CI handshake. Worked example ships a @tta/cart-helpers package used across the TTACart suite.

2
Registries compared
JFrog Artifactory and Sonatype Nexus, side-by-side.
6
Diagrams
3 mermaid sequences and flowcharts, 3 hand-styled inline SVGs.
8
Drills
Setup, publish, consume, audit, rotate-token, CI, troubleshoot.
1
Worked package
@tta/cart-helpers - login fixture, cart utilities.

01Why a private NPM registry DevOps - registry/01

Public registry.npmjs.org is fast and free, but it solves a different problem than what a test automation team needs in production. Four pressures push automation teams toward a private registry:

  • Air-gapped builds - the CI runner inside a bank or hospital network cannot reach the public internet. It still needs playwright, @playwright/test, and your own helper packages.
  • Version pinning at the org level - one team accidentally bumps chalk to a vulnerable major; every other team also pulls the bad version on next install. A proxy repo gives you a sliding window and a freeze switch.
  • Custom modules - @tta/cart-helpers, @tta/booking-helpers, @tta/network-fixtures. These never go to the public registry but they ship across many Playwright projects.
  • Audit trail - SOC 2, ISO 27001, and most regulated SDLCs need a who/what/when log of every package downloaded by the build farm. The proxy is the natural choke point.

Three repo types you will see in both products

  • Hosted - your own packages live here. Read + write. @tta/[email protected] uploads here.
  • Proxy (or "remote") - a cached mirror of registry.npmjs.org. The first install of [email protected] fetches from npm, every install after that serves the cached tarball.
  • Group (or "virtual") - a logical union that resolves a request in order: try hosted first, fall back to proxy. One URL for the consumer.

02Topology - the one diagram that matters mermaid 1/3

This is the picture you need on a whiteboard before you spin up either product. It is the same shape in both, just named differently.

flowchart LR
  DEV[Developer laptop] -->|npm install @tta/...| GROUP[Group / Virtual repo]
  CI[CI runner - GitHub Actions] -->|npm install| GROUP
  GROUP -->|scoped @tta/*| HOSTED[Hosted repo - your packages]
  GROUP -->|everything else| PROXY[Proxy - cache of npmjs.org]
  PROXY -.->|cache miss| NPMJS[(registry.npmjs.org)]
  HOSTED -.->|publish only| RELEASE[CI release job]
  classDef ours fill:#dbeafe,stroke:#3b82f6
  classDef cache fill:#fef3c7,stroke:#d97706
  classDef pub fill:#fee2e2,stroke:#ef4444
  class HOSTED ours
  class PROXY cache
  class NPMJS pub
              
Topology - one URL (the group repo) hides both the cache and your private packages.

The win: the developer and the CI runner only ever talk to one URL. The group repo dispatches the request to the right backend. If you take down the hosted repo for maintenance, the proxy still serves public packages. If npmjs.org has an outage, the proxy keeps serving cached versions.

03JFrog Artifactory - npm-virtual on top vendor 1/2

Artifactory ships a one-click "Create npm repository" wizard. It uses these names:

  • npm-local (or whatever you name it) - the local / hosted repo. Your @tta/* packages publish here.
  • npm-remote - the remote repo pointing at https://registry.npmjs.org. Acts as the cache.
  • npm-virtual - the virtual repo, ordered: npm-local first, then npm-remote. This is the URL your .npmrc points at.

Install URL pattern

artifactory - .npmrc
# Artifactory virtual repo URL pattern
registry=https://artifactory.tta-corp.example/artifactory/api/npm/npm-virtual/
# Scoped registry override for @tta/* (optional but explicit)
@tta:registry=https://artifactory.tta-corp.example/artifactory/api/npm/npm-virtual/
//artifactory.tta-corp.example/artifactory/api/npm/npm-virtual/:_authToken=${ARTIFACTORY_TOKEN}
always-auth=true

Publish URL pattern

Publish goes to the local repo, not the virtual one.

artifactory - publish
npm publish \
  --registry=https://artifactory.tta-corp.example/artifactory/api/npm/npm-local/ \
  --access restricted

04Sonatype Nexus - npm-group on top vendor 2/2

Nexus 3 uses the same three-repo idea with slightly different naming:

  • npm-hosted - the hosted repo, with a deployment policy of "Allow redeploy" or "Disable redeploy".
  • npm-proxy - remote storage URL https://registry.npmjs.org.
  • npm-group - the group repo. Ordered list of members: npm-hosted, then npm-proxy.

Install URL pattern

nexus - .npmrc
# Nexus group repo URL pattern
registry=https://nexus.tta-corp.example/repository/npm-group/
@tta:registry=https://nexus.tta-corp.example/repository/npm-group/
//nexus.tta-corp.example/repository/npm-group/:_authToken=${NEXUS_TOKEN}
always-auth=true

Publish URL pattern

nexus - publish
npm publish \
  --registry=https://nexus.tta-corp.example/repository/npm-hosted/ \
  --access restricted

05Side-by-side comparison vs table

AspectJFrog ArtifactorySonatype Nexus
UI flavourModern SPA, "Set Me Up" generator gives you the .npmrc blockFunctional but older feel, IQ for security tab is paid
Repo type nameslocal / remote / virtualhosted / proxy / group
HA modelActive-active with shared DB + storage (Enterprise tier)Active-passive primarily; clustering is Pro tier add-on
Free tierJFrog Cloud free up to 2 GBNexus Repository OSS - free, self-hosted, full npm format
Pricing tier for npmPro / Enterprise X / Enterprise +OSS (free) / Pro (per-user) / Sonatype Lifecycle
Scoped package URL/artifactory/api/npm/npm-virtual/@tta/pkg/repository/npm-group/@tta/pkg
Auth token genUser profile -> "Generate Identity Token"User profile -> NPM Bearer Token
ReplicationNative event-based replication for HASmart Proxy + Blob Store replication
Vuln scanBuilt-in JFrog Xray (Enterprise X+)Sonatype Lifecycle add-on (paid)
Where Playwright landsCached in npm-remote on first installCached in npm-proxy on first install

Heuristic: if you already pay for JFrog for Maven and Docker artifacts, add the npm format - no new vendor. If you are starting from zero and want a free OSS path, Nexus Repository OSS is the path of least resistance for a Playwright team.

06.npmrc anatomy + token resolution svg 1/3

The same .npmrc file works against both registries - only the URL changes. Four lines do the real work:

  1. registry= - the default registry for any unscoped package (chalk, zod, etc.)
  2. @tta:registry= - the scoped registry for everything that starts with @tta/. Lets you route only your packages to the private repo and leave unscoped packages on the public.
  3. //registry-host/path/:_authToken=... - the host-specific auth header. The slashes must match the registry URL exactly, including trailing slash.
  4. always-auth=true - send the auth token on GET too. Default is "only on PUT". Private registries usually require auth even for reads.
npm install @tta/cart-helpers .npmrc lookup 1. scope match? @tta:registry 2. else default registry= 3. resolve URL host 4. find _authToken match HTTPS GET Authorization: Bearer ${TOKEN} Slash matching rule registry : https://nexus.example/repository/npm-group/ token key : //nexus.example/repository/npm-group/:_authToken= ^^^^ must match host and full path If you add or remove a trailing slash, npm sends NO auth header. .npmrc resolution
How npm picks the right registry URL and the right token, line by line.

Scoped registry routing - the win

You almost always want scoped routing, not a global private registry. That way chalk still comes from the cache of the public registry, and only @tta/* reaches into the hosted repo. Two benefits: (1) faster cold install because most traffic still hits a CDN-fronted cache, (2) zero risk of an outage in the private registry taking down installs of public packages.

npm install chalk + @tta/cart-helpers npm scope router if pkg starts with @tta/ use @tta:registry else if pkg unscoped use default registry= Routing happens per-package, not per-install. @tta/cart-helpers -> hosted private repo chalk -> public cache / proxy
Scoped registries split traffic by package name prefix. Best of both worlds.

07Publish flow - @tta/cart-helpers mermaid 2/3

The publish flow is identical between Artifactory and Nexus from the developer side. Only the URL changes. We package @tta/cart-helpers - a shared login + cart fixture used by every TTACart Playwright project at https://app.thetestingacademy.com/playwright/ttacart/.

sequenceDiagram
  autonumber
  participant Dev as Developer
  participant NPM as npm CLI
  participant REG as Hosted repo
  Dev->>NPM: 1. npm version patch
  NPM-->>Dev: 1.0.0 -> 1.0.1
  Dev->>NPM: 2. npm publish --access restricted
  NPM->>NPM: 3. read .npmrc, find @tta scope
  NPM->>REG: 4. PUT /@tta/cart-helpers/-/cart-helpers-1.0.1.tgz
  REG-->>NPM: 5. 201 Created
  NPM-->>Dev: 6. + @tta/[email protected]
              
Publish sequence - same six steps for either vendor.

Package skeleton

package.json
{
  "name": "@tta/cart-helpers",
  "version": "1.0.1",
  "description": "Shared login + cart fixture for TTACart Playwright suites",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "publishConfig": {
    "registry": "https://nexus.tta-corp.example/repository/npm-hosted/",
    "access": "restricted"
  },
  "peerDependencies": { "@playwright/test": "^1.49.0" }
}

publishConfig.registry pins the publish URL inside the package itself, so a typo in the user-level .npmrc cannot accidentally push your private package to registry.npmjs.org. Highly recommended.

08Consume in a Playwright project mermaid 3/3

sequenceDiagram
  autonumber
  participant CI as GitHub Actions
  participant NPM as npm
  participant GROUP as Group / Virtual repo
  participant HOSTED as Hosted repo
  participant PROXY as Proxy repo
  CI->>NPM: 1. npm ci
  NPM->>GROUP: 2. GET @tta/cart-helpers
  GROUP->>HOSTED: 3. lookup (scope match)
  HOSTED-->>GROUP: 4. tarball stream
  GROUP-->>NPM: 5. @tta/cart-helpers-1.0.1.tgz
  NPM->>GROUP: 6. GET @playwright/test
  GROUP->>PROXY: 7. lookup (unscoped)
  PROXY-->>GROUP: 8. cached or fetch upstream
  GROUP-->>NPM: 9. @playwright/test-1.49.0.tgz
              
Consume sequence - one URL hides both backends. Cached on second run.

Project package.json + .npmrc

playwright-project / package.json
{
  "name": "ttacart-e2e",
  "devDependencies": {
    "@playwright/test": "^1.49.0",
    "@tta/cart-helpers": "^1.0.1",
    "@tta/network-fixtures": "^0.4.0"
  },
  "scripts": {
    "test": "playwright test",
    "test:ci": "playwright test --reporter=blob"
  }
}
playwright-project / .npmrc
# Everything public still works, only @tta is rerouted.
@tta:registry=https://nexus.tta-corp.example/repository/npm-group/
//nexus.tta-corp.example/repository/npm-group/:_authToken=${NPM_TOKEN}
always-auth=true

First run downloads ~120 MB into node_modules/; subsequent runs hit the lock file and the group repo cache. Browsers are still fetched via npx playwright install - that is a separate binary cache, not handled by the npm registry.

09CI integration - GitHub Actions secret svg 3/3

In CI, the registry token lives in a GitHub Actions secret called NPM_TOKEN. The actions/setup-node action consumes it via the registry-url input. The token never appears in logs because Actions masks any value passed via secrets.*.

.github/workflows/playwright.yml
name: Playwright
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://nexus.tta-corp.example/repository/npm-group/
          scope: '@tta'
          always-auth: 'true'
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - run: npx playwright install --with-deps
      - run: npm test
GitHub repo Settings -> Secrets NPM_TOKEN = ******** Actions runner NODE_AUTH_TOKEN = $NPM_TOKEN setup-node writes .npmrc Group repo npm ci Authorization header attached Mask + redaction rules - Secrets are masked in logs: *** *** *** replaces every occurrence. - Echoing the secret with echo still prints stars, not the value. - A workflow that prints ::set-output token=... will be redacted automatically. - Rotate the token by clicking "Regenerate" in Artifactory / Nexus and updating the secret. Never paste the token into a script file, even temporarily. Use the env block.
How the token flows from GitHub secret to the registry HTTP request without ever appearing in logs.

10Troubleshooting - 401 / 403 / cache / scope runbook

401 Unauthorized

  • Token missing or empty. Check echo $NPM_TOKEN in CI.
  • Trailing slash mismatch between registry= and //host/path/:_authToken.
  • Token expired - regen and update the secret.
  • always-auth=true missing - install GETs go through without the header.

403 Forbidden

  • Token exists but the user role has no read perm on the repo.
  • Trying to publish to a hosted repo with "Disable redeploy" set, version already exists.
  • Scope claim missing - Nexus role allows @team-a/* but you are pushing @tta/*.

Cache poisoning

  • Cached tarball corrupted by a partial proxy fetch.
  • Symptom: EINTEGRITY mismatch between lock file and what proxy serves.
  • Fix: invalidate cache in the proxy repo settings for that package.
  • Prevent: enable "remote storage SHA verification" in both vendors.

Scope mismatch

  • Package @tta/cart-helpers shows "Not found" but exists in the hosted repo.
  • Cause: @tta:registry not set, npm fell back to registry.npmjs.org which 404s.
  • Fix: add the scoped line to .npmrc.