Publishing a Custom Backstage Plugin to an Internal Registry

Once a plugin is tested and stable, the next step is distributing it so other teams and the portal app can install it by version rather than copying source. This how-to walks through scoping the package, authenticating against an internal npm registry, and publishing from CI on a git tag. It is the release half of the broader Plugin Testing & Publishing workflow.

Publishing matters because it turns a plugin into an auditable, versioned artifact: consumers pin a range, security teams can scan a known tarball, and rollbacks become a dist-tag move instead of a code revert.

Prerequisites

Publishing is safe only when four things exist: the Node/npm toolchain, an internal registry with a scope upstream, a publish-scoped token in CI, and a package that already builds a clean dist/.

Prerequisites for publishing a plugin The toolchain, an internal registry, a publish token, and a clean build make a plugin publishable. Node 20 / npm 10 internal registry publish token clean dist/ Publishable versioned artifact
A scope upstream on the registry is what lets one .npmrc serve both private and public packages.
  • Node.js 20.x and npm 10.x (or Yarn 4.1+) on both developer machines and CI runners.
  • An internal registry endpoint, e.g. https://npm.internal.corp/, with a configured scope upstream for @internal.
  • A publish-scoped token for that registry exported in CI as ${NPM_TOKEN} with publish permission on the @internal scope.
  • The package already builds cleanly: backstage-cli package build produces dist/.
  • Write access to the git repository so CI can read tags; a protected main branch is recommended.

Exact Configuration

The release path is a fixed chain: scope the package and pin the registry, authenticate the scope in .npmrc, dry-run to check the tarball, derive the version from a changeset, then let CI publish only on a git tag. The diagram traces it.

Tag-gated publish chain Scope and pin, authenticate the .npmrc, dry-run the tarball, version with a changeset, then publish from CI on a tag. Scope + pin publishConfig .npmrc auth ${NPM_TOKEN} Dry-run tarball check Changeset version + tag CI publish on tag only
Gating the publish on a git tag keeps the registry intentional — every version maps to a named, reviewed release.

1. Scope the package and pin the registry

The package name’s scope (@internal) is what routes installs to your private registry. Set publishConfig so a publish always targets the internal endpoint even if a developer’s default registry is npmjs.org.

// plugins/custom/package.json
// Requires @backstage/cli >= 0.26.0
{
  "name": "@internal/backstage-plugin-custom",
  "version": "0.3.1",
  "main": "dist/index.cjs.js",
  "types": "dist/index.d.ts",
  "files": ["dist", "config.d.ts", "README.md"],
  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.internal.corp/"
  },
  "scripts": {
    "build": "backstage-cli package build",
    "prepack": "backstage-cli package prepack",
    "postpack": "backstage-cli package postpack"
  }
}

2. Configure authenticated registry resolution

Create a project .npmrc that maps the scope to the registry and reads the token from the environment. Never commit a literal token — use the ${NPM_TOKEN} placeholder, which npm expands at runtime.

# .npmrc
# Requires npm >= 10.0.0
@internal:registry=https://npm.internal.corp/
//npm.internal.corp/:_authToken=${NPM_TOKEN}
always-auth=true

For installs (developers and CI consuming the plugin), the same .npmrc is sufficient: any @internal/* dependency resolves from the internal registry while everything else falls through to the public default.

3. Verify the tarball contents before publishing

# Requires npm >= 10.0.0
yarn workspace @internal/backstage-plugin-custom build
npm publish --dry-run --workspace @internal/backstage-plugin-custom

The dry run prints the file list. Confirm it contains dist/, config.d.ts, package.json, and README.md — and crucially not src/ or test files. The files array plus prepack enforce this.

4. Drive versioning with Changesets

Manual npm version bumps drift from reality. Add a changeset describing the change; the version and changelog are computed on release.

# Requires @changesets/cli >= 2.27.0
yarn changeset            # pick patch/minor/major, write a summary
yarn changeset version    # applies bumps to package.json + CHANGELOG.md
git commit -am "Version packages"
git tag @internal/[email protected]

5. Publish from CI on a tag

Gate the actual publish behind a git tag so only intentional releases reach the registry. The token is injected as a secret, never stored in the repo.

# .github/workflows/publish-plugin.yml
# Requires actions/setup-node >= v4, npm >= 10.0.0
name: Publish Plugin
on:
  push:
    tags:
      - '@internal/backstage-plugin-*@*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://npm.internal.corp/'
      - run: yarn install --immutable
      - run: yarn workspace @internal/backstage-plugin-custom build
      - run: yarn workspace @internal/backstage-plugin-custom test
      - name: Publish
        run: npm publish --workspace @internal/backstage-plugin-custom
        env:
          NPM_TOKEN: ${{ secrets.INTERNAL_NPM_TOKEN }}

6. Consume the published plugin

In the portal app, add the dependency and pin a caret range so patch releases flow in automatically:

# Requires the project .npmrc from step 2 to be present
yarn workspace app add @internal/backstage-plugin-custom@^0.3.1

Validation

Prove the release four ways: the version is on the registry, latest points at it, a clean install resolves it end-to-end, and the tarball contains no source files.

Four post-publish checks The version is present, the latest dist-tag is correct, a clean install resolves it, and no source shipped. Version present npm view latest correct dist-tags Clean install resolves internal No src shipped tar count 0
The clean-install check is the real proof — it exercises the same resolution path a consuming team will hit.
# Requires npm >= 10.0.0

# 1. Confirm the version landed on the registry
npm view @internal/backstage-plugin-custom version --registry https://npm.internal.corp/
# expected: 0.3.1

# 2. List published versions and dist-tags
npm view @internal/backstage-plugin-custom dist-tags --registry https://npm.internal.corp/
# expected: { latest: '0.3.1' }

# 3. Install into a clean dir to prove resolution works end-to-end
mkdir /tmp/verify && cd /tmp/verify && npm init -y
npm install @internal/backstage-plugin-custom
# expected: added 1 package, resolved from npm.internal.corp

# 4. Confirm only built files shipped
tar -tf $(npm pack @internal/backstage-plugin-custom 2>/dev/null | tail -1) | grep -c '^package/src/'
# expected: 0

The publish failures divide into auth/routing problems (401, wrong registry) and packaging problems (source shipped, immutability, bad latest). The diagram sorts them so the fix is either a credential/.npmrc change or a packaging/tag change.

Publish failures by class Unauthorized and wrong-registry are auth/routing issues; shipped source and bad latest are packaging issues. Auth / routing 401 on publish resolves npmjs → token + scope map Packaging source shipped bad latest → prepack + dist-tag
Never republish a version — the registry is immutable; fix a bad release by moving the latest dist-tag.
Symptom Root Cause Resolution
npm ERR! 401 Unauthorized on publish ${NPM_TOKEN} unset or lacks publish scope Verify the secret is injected in CI and the token has publish rights on @internal; check always-auth=true is set
403 Forbidden — cannot overwrite Version already published; registry is immutable Bump the version (immutability is correct); never republish the same version
Install resolves from npmjs.org, not internal Missing scope-to-registry mapping Ensure @internal:registry=... line exists in the consumer’s .npmrc
Consumer gets .ts files / type errors prepack skipped, source published Confirm prepack script runs and files lists only dist; re-run npm publish --dry-run
latest points at a broken version Bad release promoted to latest npm dist-tag add @internal/[email protected] latest to roll the tag back

Frequently Asked Questions

Can I use the same workflow for Verdaccio, Artifactory, and CodeArtifact?

Yes. The package config and .npmrc pattern are identical; only the registry URL and how the token is minted differ. CodeArtifact issues a short-lived token via aws codeartifact get-authorization-token that you export as ${NPM_TOKEN} in CI; Artifactory and Verdaccio use long-lived tokens stored as secrets.

Why publish from a tag instead of every merge to main?

Tag-gated publishing keeps the registry intentional and auditable — every published version maps to a named tag and changelog entry. Publishing on every merge produces noisy version churn and makes rollbacks ambiguous.