Yellow Jack with Nexus Repository
This guide connects Yellow Jack to Sonatype Nexus Repository, for npm, PyPI and Docker. It covers both places the gate can sit: behind Nexus, as the upstream of a Nexus proxy repository, and in front of Nexus, as the address your clients use.
Tested. e2e/registry_nexus.sh runs the files this guide uses
(docs/integrations/nexus/) against a real Nexus container, with real npm, pip and docker
clients. It checks that each request reached the gate through Nexus (behind) or reached
Nexus through the gate (in front), not only that the client was refused. It also checks the
cache behaviour described below, and that the same installs succeed when the deny entry is
removed. Last run: 2026-10-05.
| Nexus image | sonatype/nexus3:3.96.4@sha256:5f48f9085096e96f4111ec07d79d57ca5a4a1da5a2003a3028a0b60c62bea7d1 (Community Edition 3.96.4-01) |
| Trial stack | docs/integrations/nexus/docker-compose.yml |
| Nexus setup | docs/integrations/nexus/configure.sh and the JSON files beside it |
| Clients | node:22-alpine (npm 10.9), python:3.12-alpine (pip), Docker Engine 29.5 |
What works
| Nexus behind the gate | Gate in front of Nexus | |
|---|---|---|
| npm | Tested | Tested |
| PyPI | Tested | Not supported (see PyPI in front of Nexus) |
| Docker | Tested | Tested |
Which position to choose
The two positions stop different things.
Nexus behind the gate. Clients point at Nexus, as they already do. A Nexus proxy repository uses the gate as its remote URL. Nothing changes on a developer machine.
- The gate sees every request Nexus does not answer from its cache. A refused package never enters Nexus.
- The gate does not see a request Nexus answers from its cache. A package Nexus has already cached is still served after you deny it. Nexus does not ask the gate. To stop it, remove the cached copy (see Withdraw a cached package).
- The client sees Nexus’s error, not the gate’s reason. npm prints
404 Not Found.
Gate in front of Nexus. Clients point at the gate. The gate’s upstream is a Nexus repository.
- The gate sees every request, including ones Nexus would answer from its cache. A deny applies at once, cached or not.
- The client sees the gate’s reason.
- Clients must not have another route to Nexus, or they can go around the gate.
Neither position covers a client that goes straight to the public registry.
1. Start the trial stack
The trial stack runs Nexus, the approval service, three gates behind Nexus and three gates in front of it. Run from the repository root:
docker compose -f docs/integrations/nexus/docker-compose.yml up -d --build
Nexus takes two to four minutes to start. Wait until
docker compose -f docs/integrations/nexus/docker-compose.yml ps shows it healthy.
Ports, all on 127.0.0.1:
| Port | Service |
|---|---|
| 8281 | Nexus: UI, REST API, and the npm and PyPI repositories |
| 8282 | Nexus: the docker-gated repository |
| 8290 | Approval service (the gates’ audit records) |
| 8291 | Gate in front of Nexus, npm |
| 8292 | Gate in front of Nexus, PyPI (measured, not supported) |
| 8293 | Gate in front of Nexus, Docker |
The gates behind Nexus publish no port. Only Nexus can reach them.
Why Nexus listens on 8281, not 8081. A gate in front writes artifact URLs from
FW_PUBLIC_URL, and the client fetches them. So the URL must work where the client runs.
The trial stack publishes every port at the same number it uses inside its container, so
http://localhost:<port> means the same service on your machine and inside the test’s
containers. -Dapplication-port=8281 in INSTALL4J_ADD_VM_PARAMS moves Nexus. You do not
need this in a real deployment.
2. Configure Nexus
configure.sh makes every change through the Nexus REST API. Get the first admin password,
then run it:
export NEXUS_PASSWORD="$(docker compose -f docs/integrations/nexus/docker-compose.yml \
exec -T nexus sh -c 'cat /nexus-data/admin.password')"
sh docs/integrations/nexus/configure.sh
1. SSRF protection: gate-npm, gate-pypi and gate-oci allowed
2. anonymous read access on
3. realms: NexusAuthenticatingRealm, DockerToken
4. repositories behind the gate:
npm-gated: created
pypi-gated: created
docker-gated: created
5. repositories the front gates sit in front of:
npm-public: created
pypi-public: created
docker-public: created
6. EULA: NOT accepted. Nexus will refuse package files until it is.
It is safe to run twice. A repository that exists is updated. The steps, as REST calls
(API=http://localhost:8281/service/rest/v1, all with -u admin:$NEXUS_PASSWORD -H 'Content-Type: application/json'):
1. Let Nexus reach the gates. Nexus refuses a remote URL that resolves to a private
address unless its host name is allowed. PUT $API/security/ssrf-protection with
ssrf-protection.json:
{
"enabled": true,
"allowedIPs": [],
"allowedDomains": ["gate-npm", "gate-pypi", "gate-oci"]
}
2. Anonymous read. PUT $API/security/anonymous with
{"enabled":true,"userId":"anonymous","realmName":"NexusAuthorizingRealm"}. Skip this if
your clients log in to Nexus.
3. The Docker Bearer Token realm. PUT $API/security/realms/active with
["NexusAuthenticatingRealm","DockerToken"]. Without it Nexus answers an anonymous
client’s GET /v2/ on a Docker repository port with 401 Unauthorized.
4. The repositories behind the gate. POST $API/repositories/<format>/proxy with
repositories/<name>.json. The remote URL is the gate:
| Repository | Format | Remote URL | Clients use |
|---|---|---|---|
npm-gated |
npm | http://gate-npm:8080 |
http://localhost:8281/repository/npm-gated/ |
pypi-gated |
pypi | http://gate-pypi:8080 |
http://localhost:8281/repository/pypi-gated/simple/ |
docker-gated |
docker | http://gate-oci:8080 |
localhost:8282 |
repositories/npm-gated.json:
{
"name": "npm-gated",
"online": true,
"storage": { "blobStoreName": "default", "strictContentTypeValidation": true },
"proxy": { "remoteUrl": "http://gate-npm:8080", "contentMaxAge": 1440, "metadataMaxAge": 1440 },
"negativeCache": { "enabled": true, "timeToLive": 1440 },
"httpClient": { "blocked": false, "autoBlock": true }
}
docker-gated.json adds its own port and says the remote is a plain registry:
"docker": { "v1Enabled": false, "forceBasicAuth": false, "httpPort": 8282 },
"dockerProxy": { "indexType": "REGISTRY" }
5. The repositories the front gates use. The same shape, with the public registry as the remote:
| Repository | Format | Remote URL | Gate’s FW_UPSTREAM |
|---|---|---|---|
npm-public |
npm | https://registry.npmjs.org |
http://nexus:8281/repository/npm-public |
pypi-public |
pypi | https://pypi.org |
http://nexus:8281/repository/pypi-public |
docker-public |
docker | https://registry-1.docker.io (index type HUB, port 8283) |
http://nexus:8283 |
6. The Community Edition licence. Nexus Community Edition serves metadata but refuses every package file until its EULA is accepted. npm then fails like this:
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:8281/repository/npm-gated/is-number/-/is-number-7.0.0.tgz
and Nexus’s response body says You must accept the End User License Agreement (EULA) through the onboarding wizard or REST API before proceeding. Read the EULA at
https://links.sonatype.com/products/nxrm/ce-eula. To accept it, sign in to the Nexus UI
as admin and follow the onboarding wizard, or run:
NEXUS_ACCEPT_EULA=yes sh docs/integrations/nexus/configure.sh
GET $API/system/eula returns the disclaimer text. The script posts it back with
"accepted" : true.
3. Nexus behind the gate
The gates’ settings
Each gate’s upstream is the public registry, its default. FW_PUBLIC_URL is the address
Nexus uses to reach the gate, because Nexus fetches the artifact URLs the gate writes.
The npm gate (gate-npm):
FW_ECOSYSTEM=npm
# FW_UPSTREAM is left at its default, https://registry.npmjs.org
FW_PUBLIC_URL=http://gate-npm:8080
FW_SCORECARD_MODE=off
FW_ALLOW_LIST=/lists/allow.txt
FW_DENY_LIST=/lists/deny.txt
The PyPI gate (gate-pypi) is the same with FW_ECOSYSTEM=pypi and
FW_PUBLIC_URL=http://gate-pypi:8080. Its upstreams stay at their defaults,
https://pypi.org and https://files.pythonhosted.org. The Docker gate (gate-oci) uses
FW_ECOSYSTEM=oci and FW_PUBLIC_URL=http://gate-oci:8080, with Docker Hub as its default
upstream. Each deny list has one entry: left-pad for npm, idna for PyPI and
library/busybox (every tag) for Docker.
Scoring is off in the trial, so the lists decide. See CONFIGURATION.md for every setting.
npm
Point npm at Nexus:
npm install --registry http://localhost:8281/repository/npm-gated/ is-number
added 1 package in 3s
npm install --registry http://localhost:8281/repository/npm-gated/ left-pad
npm error code E404
npm error 404 Not Found - GET http://localhost:8281/repository/npm-gated/left-pad - Package 'left-pad' not found
npm error 404
npm error 404 'left-pad@*' is not in this registry.
Nexus turned the gate’s 403 into a 404, and the reason is lost. The gate’s log has it:
GET [verdict-blocked] left-pad -> allowed=false score=0.0 hasScore=false (package "left-pad" is on this organisation's deny list; refused without contacting upstream. ...)
The same command pointed at https://registry.npmjs.org/ installs left-pad. The gate only
covers clients that go through it.
PyPI
pip install --index-url http://localhost:8281/repository/pypi-gated/simple/ six
Successfully installed six-1.17.0
pip install --index-url http://localhost:8281/repository/pypi-gated/simple/ idna
ERROR: Ignored the following yanked versions: 0.2, 0.3, ... 3.10
ERROR: Could not find a version that satisfies the requirement idna (from versions: none)
ERROR: No matching distribution found for idna
The gate marks every release of a denied package as yanked, and Nexus passes that through. An exact pin is refused when pip fetches the file:
pip install --index-url http://localhost:8281/repository/pypi-gated/simple/ idna==3.10
ERROR: 404 Client Error: Not Found for url: http://localhost:8281/repository/pypi-gated/packages/idna/3.10/idna-3.10-py3-none-any.whl.metadata
pip ignores a plain-HTTP index on a host other than localhost. With the host name nexus
it printed:
WARNING: The repository located at nexus is not a trusted or secure host and is being ignored. Serve Nexus over HTTPS, or for a trial add --trusted-host.
Docker
docker pull localhost:8282/library/alpine:3.20
Status: Downloaded newer image for localhost:8282/library/alpine:3.20
docker pull localhost:8282/library/busybox:latest
Error response from daemon: unknown: failed to resolve reference "localhost:8282/library/busybox:latest": unexpected status from HEAD request to http://localhost:8282/v2/library/busybox/manifests/latest: 403 Forbidden
Nexus passes the gate’s 403 through for Docker. This daemon uses the containerd image store,
which resolves a tag with HEAD, so the reason is not printed. A client that uses GET
prints it. With crane:
Error: GET http://nexus:8082/v2/library/busybox/manifests/latest: DENIED: blocked by firewall: package "library/busybox:latest" is on this organisation's deny list; ...
(That line is from an earlier run, where the repository’s port was 8082.)
What the gate sees
Each verdict goes to the approval service with the address the request came from. Behind Nexus, that address is Nexus’s:
curl -s "http://localhost:8290/v1/events?package=library/busybox:latest&ecosystem=oci"
[{"id":12,"package":"library/busybox:latest","ecosystem":"oci","action":"block","reason":"package \"library/busybox:latest\" is on this organisation's deny list; ...","source_ip":"172.22.0.3",...}]
172.22.0.3 was the Nexus container on that run (docker inspect shows it). Nexus’s own
/nexus-data/log/outbound-request.log lists each request it sent to a gate, with the
status the gate returned.
Withdraw a cached package
This was measured with is-number, after an install through npm-gated had cached it:
- Add
is-numberto the deny list. The next install through Nexus still succeeds. The gate has no record of the request. Nexus served it from its cache. - Invalidate the repository’s cache
(
POST $API/repositories/npm-gated/invalidate-cache). The next install still succeeds. This time Nexus did ask the gate, the gate refused, and Nexus served its stale copy anyway. - Delete the cached copy. The next install fails with
404 Not Found.
Delete the tarballs, not only the metadata. Before step 3 the cached tarball URL
(.../npm-gated/is-number/-/is-number-7.0.0.tgz) still answered 200, and an install from a
lockfile fetches that URL directly, without reading the metadata.
withdraw.sh does step 3. It deletes the package’s components (the tarballs), its metadata
document, and then invalidates the cache:
sh docs/integrations/nexus/withdraw.sh npm-gated is-number
deleted 1 component(s) named is-number from npm-gated
deleted 1 metadata document(s) at /is-number
invalidated the cache of npm-gated
It uses GET $API/search?repository=<repo>&name=<name> and DELETE $API/components/<id>
for the tarballs, GET $API/assets?repository=<repo> and DELETE $API/assets/<id> for the
asset whose path is /<name>, and the invalidate call above. It is written for npm
repositories, and only the npm case was tested.
To unblock a package, invalidate the cache after you remove the entry. In the run above,
after is-number was refused, Nexus kept answering 404 from its negative cache once the
entry was gone. After invalidate-cache, the install succeeded.
4. Gate in front of Nexus
The gates’ settings
FW_UPSTREAM is the Nexus repository. FW_PUBLIC_URL is the address clients use to
reach the gate.
The npm gate (front-npm):
FW_LISTEN_ADDR=:8291
FW_ECOSYSTEM=npm
FW_UPSTREAM=http://nexus:8281/repository/npm-public
FW_PUBLIC_URL=http://localhost:8291
The Docker gate (front-oci):
FW_LISTEN_ADDR=:8293
FW_ECOSYSTEM=oci
FW_UPSTREAM=http://nexus:8283
FW_PUBLIC_URL=http://localhost:8293
Both also set FW_SCORECARD_MODE=off and the two list paths, as above. The Nexus repository
URL is the one in its url field, without a trailing slash. Nexus’s tarball URLs point at
Nexus. The gate rewrites them to its own FW_PUBLIC_URL, so tarballs come back through it.
npm
npm install --registry http://localhost:8291/ is-number
added 1 package in 5s
npm install --registry http://localhost:8291/ left-pad
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:8291/left-pad - blocked by firewall: package "left-pad" is on this organisation's deny list; refused without contacting upstream. This is a local policy decision, not a published malware advisory -- ask whoever maintains the firewall's deny list. This package is on your organisation's own deny list -- a local policy decision, not a published advisory about the package. Waiting will not clear it and neither will re-running; it is reversible by whoever maintains this firewall's deny list
Nexus’s /nexus-data/log/request.log shows the allowed package fetched from the gate’s
address, and nothing for left-pad. The gate refused it without asking Nexus.
A package Nexus has cached is refused as soon as it is denied. This was measured with
is-number, cached in npm-public by the install above: after the deny entry, the next
install through the gate failed with the gate’s 403. No Nexus step is needed.
Docker
docker pull localhost:8293/library/alpine:3.20
Status: Downloaded newer image for localhost:8293/library/alpine:3.20
docker pull localhost:8293/library/busybox:latest
Error response from daemon: unknown: failed to resolve reference "localhost:8293/library/busybox:latest": unexpected status from HEAD request to http://localhost:8293/v2/library/busybox/manifests/latest: 403 Forbidden
Nexus’s request log shows the alpine manifest requests from the gate’s address, and nothing
for busybox. The anonymous pull through the gate worked with the DockerToken realm on.
docker treats localhost registries as plain HTTP. Any other host name needs TLS, or an
insecure-registries entry in every client’s daemon configuration.
PyPI in front of Nexus
Not supported. Use Nexus behind the gate for PyPI. Two things were measured with the
gate in front of pypi-public:
- The release-age window refuses every package. The window reads upload times from
<upstream>/pypi/<name>/json, and a Nexus PyPI proxy does not serve that path (it answers 404). The gate then refuses rather than serve an index it could not check, so every release is marked yanked. Its log says:age floor: upload times for "six" unavailable, blocking rather than serving an unfiltered index: metadata returned status 404. - Set
FW_FILES_UPSTREAMto the repository URL (the same value asFW_UPSTREAM, as the trial stack does). Nexus’s index links to its files with relative URLs (../../packages/idna/3.10/...). The gate resolves them and routes them through its own file path, where each file is judged as the package it belongs to. Withidnaon the deny list,pip install idna==3.10through the gate is refused. Without the setting, the gate cannot fetch those files itself, so it refuses every one of them and names the setting:refused by the firewall: this package's index links to files on nexus:8281, which is not where this gate fetches files from (FW_FILES_UPSTREAM is https://files.pythonhosted.org)...
The first point is why this position stays unsupported. The test keeps it true: if it changes, the test fails and this section must change.
Keep clients off Nexus
In front only works if clients cannot reach the *-public repositories directly. In the
trial stack they can: port 8281 serves npm-public too. In a deployment, do not give
clients a network route to Nexus’s port.
Both topologies in this guide use anonymous read on the Nexus repositories. Behind Nexus,
the gate needs no Nexus account at all. If you turn anonymous read off in front of Nexus,
note how the gate handles credentials: it relays the developer’s own Authorization header
to Nexus, and FW_UPSTREAM_AUTH or FW_UPSTREAM_AUTH_FILE covers only the gate’s own probe
requests (see CONFIGURATION.md). Avoid putting a user name and
password in the FW_UPSTREAM URL: the gate masks them in its own log, but the URL is
still a credential sitting in your configuration. None of this was tested with Nexus here.
Common failures
Each message below was produced against the trial stack.
Creating a repository whose remote is a gate fails:
[{"id":"HelperBean.proxy.remoteUrl","message":"Proxy URL blocked: Host resolves to private/local IP address(es): 172.21.0.3 (private network address). To allow connections, update the SSRF protection settings via the API at /v1/security/ssrf-protection."}]
Nexus’s SSRF protection is on by default. Add the gate’s host name to allowedDomains
(step 1).
npm fails with 403 Forbidden on a tarball, through Nexus. The EULA is not accepted
(step 6).
An anonymous client gets 401 Unauthorized from a Nexus Docker repository. The
DockerToken realm is not active (step 3).
A denied package still installs through Nexus. Nexus had it cached. See Withdraw a cached package.
A package still fails after you remove its deny entry. Nexus remembered the 404. Invalidate the repository’s cache.
Moving from the trial to a deployment
- Run Nexus with TLS and a real host name, and give the gates real host names. Add each gate host name to the SSRF allow-list.
- Change the admin password, and keep
/nexus-dataon persistent storage. - Remove
-Dapplication-port=8281and the trial-sized heap. Use Nexus’s own sizing advice. - Behind Nexus: set each gate’s
FW_PUBLIC_URLto the address Nexus reaches it at. - In front of Nexus: set
FW_PUBLIC_URLto the address clients reach the gate at, behind TLS. Keep clients off the*-publicrepositories. - Use a persistent approval database (
APPROVAL_DATABASE_URL). The trial keeps audit records in memory.
To stop the trial stack: docker compose -f docs/integrations/nexus/docker-compose.yml down -v.
Yellow Jack