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}withpublishpermission on the@internalscope. - The package already builds cleanly:
backstage-cli package buildproducesdist/. - Write access to the git repository so CI can read tags; a protected
mainbranch 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.