GitHub Actions: Private npm Dependencies over SSH
This document explains how to configure a GitHub Action so npm ci can install private npm dependencies that are referenced through Git SSH URLs.
Example dependency URL:
ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
Typical failure when SSH access is broken:
npm ERR! code 128
npm ERR! An unknown git error occurred
npm ERR! command git --no-replace-objects ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
npm ERR! ERROR: Repository not found.
npm ERR! fatal: Could not read from remote repository.
Mental model
There are two repositories involved:
Consuming repository
Runs the GitHub Action
Stores the private SSH key in an Actions secret
Private dependency repository
Contains the package npm needs to install
Stores the matching public SSH key as a deploy key
For example:
Consuming repository
secrets.BYLT_ACTIONS_SSH_KEY
|
| private key loaded by webfactory/ssh-agent
v
npm ci
|
| git ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
v
Private dependency repository
BBCnv/bbcdev.front-end-kit
Public key added under Settings → Deploy keys
GitHub Actions does not automatically get access to every private repository in the same organization. If one private repo depends on another private repo, the workflow still needs credentials that can read the dependency repo.
Recommended setup
Use a dedicated read-only deploy key for the private dependency repository.
This is better than relying on a personal user SSH key because:
- it is explicit;
- it is tied to the repository dependency;
- it does not depend on a human user staying in the organization;
- it is easy to rotate;
- it is read-only by default.
First-time setup
1. Generate a dedicated SSH key pair
Run this locally:
ssh-keygen -t ed25519 -C "GitHub Actions access to private npm dependency" -f ./BYLT_ACTIONS_SSH_KEY -N ""
This creates two files:
BYLT_ACTIONS_SSH_KEY ← private key
BYLT_ACTIONS_SSH_KEY.pub ← public key
Do not commit either file.
2. Add the public key to the private dependency repo
Go to the private repo that npm needs to fetch.
Example:
BBCnv/bbcdev.front-end-kit
Then open:
Settings → Deploy keys → Add deploy key
Use a clear title, for example:
BYLT Actions private npm access
Paste the contents of the public key:
cat ./BYLT_ACTIONS_SSH_KEY.pub
Leave this unchecked:
Allow write access
For npm install access, read-only access is enough.
3. Add the private key to the consuming repo secret
Go to the repository where the GitHub Action runs:
Settings → Secrets and variables → Actions
Create or update this secret:
BYLT_ACTIONS_SSH_KEY
Paste the full private key:
cat ./BYLT_ACTIONS_SSH_KEY
The value must include the full block:
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
Do not paste the .pub file here.
4. Load the SSH key before npm ci
In the GitHub Actions workflow, load the key before installing dependencies:
- name: Setup SSH key for private repo access
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.BYLT_ACTIONS_SSH_KEY }}
- name: Install dependencies
run: npm ci
The SSH setup step must run before npm ci, because npm invokes Git internally when it encounters SSH-based package dependencies.
Recommended workflow snippet
This is a practical version with an explicit access test before npm ci.
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key for private repo access
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.BYLT_ACTIONS_SSH_KEY }}
- name: Test SSH access to GitHub private dependencies
run: |
echo "Testing GitHub SSH authentication..."
ssh -T git@github.com || true
echo ""
echo "Testing access to private npm dependency..."
git ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
- name: Read Node version from .nvmrc
id: nvmrc
run: echo "node_version=$(cat .nvmrc | tr -d 'v')" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ steps.nvmrc.outputs.node_version }}
cache: npm
- name: Install dependencies
run: npm ci
The diagnostic step is optional, but useful. It makes private repo access failures obvious before npm wraps the error.
Minimal workflow snippet
If you do not want the diagnostic step permanently:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key for private repo access
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.BYLT_ACTIONS_SSH_KEY }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.5
cache: npm
- name: Install dependencies
run: npm ci
Recovery procedure when it breaks
Use this when the workflow suddenly starts failing at npm ci.
1. Identify the failing dependency
Look for this line in the GitHub Actions logs:
npm ERR! command git --no-replace-objects ls-remote ssh://git@github.com/ORG/REPO.git
Example:
npm ERR! command git --no-replace-objects ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
This tells you which private repo npm cannot access.
2. Check the Actions secret
In the consuming repo:
Settings → Secrets and variables → Actions
Confirm this secret exists:
BYLT_ACTIONS_SSH_KEY
You cannot view the current value, so if in doubt, rotate it.
3. Check the deploy key on the private dependency repo
In the private dependency repo:
Settings → Deploy keys
Confirm the matching public key exists.
If there is no deploy key, create a new key pair and add the public key there.
4. Rotate the key if needed
Generate a new key:
ssh-keygen -t ed25519 -C "GitHub Actions access to private npm dependency" -f ./BYLT_ACTIONS_SSH_KEY -N ""
Add:
BYLT_ACTIONS_SSH_KEY.pub
to:
Private dependency repo → Settings → Deploy keys
Add:
BYLT_ACTIONS_SSH_KEY
to:
Consuming repo → Settings → Secrets and variables → Actions → BYLT_ACTIONS_SSH_KEY
Then rerun the workflow.
Common errors and what they mean
ERROR: Repository not found
Example:
npm ERR! command git --no-replace-objects ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
npm ERR! ERROR: Repository not found.
npm ERR! fatal: Could not read from remote repository.
Usually means one of these:
- the SSH key does not have access to the private repo;
- the deploy key was removed;
- the private key secret was replaced with the wrong key;
- the repo was renamed, moved, or deleted;
- the dependency URL has a typo;
- the key belongs to a GitHub user who no longer has access.
How to detect:
- name: Test SSH access to private dependency
run: |
ssh -T git@github.com || true
git ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
How to fix:
- confirm the repo URL is correct;
- confirm the public key is added to the dependency repo as a deploy key;
- confirm the private key is stored in the consuming repo as
BYLT_ACTIONS_SSH_KEY; - rotate the key if unsure.
Permission denied (publickey)
Example:
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
Usually means GitHub did not accept the SSH key at all.
Possible causes:
- the secret contains the wrong key;
- the secret contains the public key instead of the private key;
- the private key was pasted incompletely;
- the matching public key is not registered with GitHub;
- the SSH agent step did not run before
npm ci.
How to fix:
- verify the
webfactory/ssh-agentstep is beforenpm ci; - rotate the key;
- ensure the private key is in the Actions secret;
- ensure the public key is in the dependency repo deploy keys.
Host key verification failed
Example:
Host key verification failed.
fatal: Could not read from remote repository.
Usually means the runner does not trust GitHub’s SSH host key.
This is uncommon with normal GitHub Actions setups, but can happen with custom SSH configuration.
Possible fix:
- name: Add GitHub to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan github.com >> ~/.ssh/known_hosts
Place this before npm ci.
npm WARN EBADENGINE Unsupported engine
Example:
npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE package: 'vite@7.1.12',
npm WARN EBADENGINE required: { node: '^20.19.0 || >=22.12.0' },
npm WARN EBADENGINE current: { node: 'v20.12.2', npm: '10.5.0' }
npm WARN EBADENGINE }
This is separate from SSH access.
It means the Node.js version used by the workflow is older than what one or more packages require.
If the workflow reads from .nvmrc, update .nvmrc.
Example:
20.19.5
Then commit the change.
If the workflow hardcodes Node:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.5
cache: npm
How to tell if the SSH key is valid but lacks repo access
Add this temporary step:
- name: Test SSH access to GitHub private dependencies
run: |
echo "Testing GitHub SSH authentication..."
ssh -T git@github.com || true
echo ""
echo "Testing access to private npm dependency..."
git ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
Possible result:
Hi some-user! You've successfully authenticated, but GitHub does not provide shell access.
ERROR: Repository not found.
fatal: Could not read from remote repository.
This means:
The SSH key is valid, but the GitHub user/key does not have access to the private repo.
Best fix:
Use a dedicated deploy key instead of a user-owned SSH key.
Notes about deploy keys
Deploy keys are SSH keys attached directly to a GitHub repository.
For this use case:
public key → private dependency repo → Deploy keys
private key → consuming repo → Actions secret
Deploy keys are read-only by default.
Use read-only deploy keys for npm installs.
Do not enable write access unless the workflow needs to push to the dependency repo.
Notes about same-organization repositories
Being in the same GitHub organization is not enough.
A workflow running in one private repo does not automatically get read access to another private repo in the same organization.
You still need one of the following:
- a deploy key on the private dependency repo;
- a GitHub App token with access to the dependency repo;
- a fine-grained personal access token with access to the dependency repo;
- a machine user with SSH access to the dependency repo.
For this setup, the deploy key approach is usually the simplest.
Checklist for future incidents
When npm ci fails with Git SSH errors:
[ ] Find the failing repo in the npm error line. [ ] Confirm the dependency URL is correct. [ ] Confirm the consuming repo has BYLT_ACTIONS_SSH_KEY. [ ] Confirm the private dependency repo has a deploy key. [ ] Confirm the workflow loads webfactory/ssh-agent before npm ci. [ ] Add temporary git ls-remote diagnostic if unclear. [ ] Rotate the key if the current setup cannot be verified. [ ] Update .nvmrc if EBADENGINE warnings appear.
Example final working setup
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key for private repo access
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.BYLT_ACTIONS_SSH_KEY }}
- name: Test SSH access to private npm dependency
run: |
ssh -T git@github.com || true
git ls-remote ssh://git@github.com/BBCnv/bbcdev.front-end-kit.git
- name: Read Node version from .nvmrc
id: nvmrc
run: echo "node_version=$(cat .nvmrc | tr -d 'v')" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ steps.nvmrc.outputs.node_version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Build for production
run: npm run build
After the setup has proven stable, the diagnostic step can be removed or kept permanently.