← All docs

Yellow Jack with Nexus Repository

From docs/integrations/registry-nexus.md at v0.1.0 ·View the source on GitHub

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:

  1. Add is-number to the deny list. The next install through Nexus still succeeds. The gate has no record of the request. Nexus served it from its cache.
  2. 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.
  3. 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:

  1. 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.
  2. Set FW_FILES_UPSTREAM to the repository URL (the same value as FW_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. With idna on the deny list, pip install idna==3.10 through 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-data on persistent storage.
  • Remove -Dapplication-port=8281 and the trial-sized heap. Use Nexus’s own sizing advice.
  • Behind Nexus: set each gate’s FW_PUBLIC_URL to the address Nexus reaches it at.
  • In front of Nexus: set FW_PUBLIC_URL to the address clients reach the gate at, behind TLS. Keep clients off the *-public repositories.
  • 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.