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

  • 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

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

# 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

Edge Cases & Troubleshooting

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.