← All docs

Reference deployment — watch the whole product work, end to end

From docs/REFERENCE_DEPLOYMENT.md at v0.1.0 ·View the source on GitHub

QUICKSTART.md shows one binary refusing one package in about a minute. This is the other thing an evaluator needs: the real shape of a deployment — a gate in front of a package registry, a control plane, and a console — so you can watch a refusal happen to a real npm install, pip install and docker pull and then go read that decision in a UI.

It exists so that someone who does not already run a package registry can see all of that.

This is an example, not a product component. The registry in it stands in for your registry, which you already run and already patch. We ship a reference deployment that includes a registry; we do not ship a registry as our component. One value needs changing before this is anything but a laptop demo, and it is marked ⚠️ CHANGE THIS in the compose file: FW_UPSTREAM, pointing at your own registry. The database password is not one of them — it is generated per machine in step 1 and never written into the file.

The topology is the point

developer ──▶ Yellow Jack ──▶ your registry ──▶ the public registry

This example puts Yellow Jack in front of your registry. That is why the registry service publishes no host port here: there is no route to it that does not pass the gate.

The example runs that topology three times over, one gate per ecosystem, each in front of its own stand-in for your registry: npm on :8080, PyPI on :8081, Docker on :8082. Steps 2-7 walk the npm one; steps 8 and 9 show the other two doing the same thing, because an evaluator with a Python or container shop could not use this page when it was npm-only. None of the three registry stand-ins publishes a host port.

In front or behind: the two positions stop different things

position path what it stops what it cannot do
In front (this example) developer → Yellow Jack → your registry the install, including a pull your registry would serve from its own cache; a cached package can still be withdrawn stop a package entering your registry by another route
Behind developer → your registry → Yellow Jack → public registry ingest: a refused package never enters your registry at all see a cache hit, so it cannot withdraw what your registry already holds

Behind needs no change on any developer machine: the only client is your registry, and pointing its upstream at the gate is one line of its own configuration. In front needs clients to reach the gate instead of the registry, which this example arranges by publishing no other route.

Neither position covers a developer who pulls straight from the public internet; that is what egress controls are for, alongside either one. Both positions are exercised on every pipeline by e2e/registry_front.sh — legs 4, 7 and 9 for in front, leg 10 for behind, with leg 9’s control showing what behind cannot do.

1. Start it

Generate this machine’s database password once. No credential is written in the compose file — an example exists to be copied, and a literal in it would be one shared password on every deployment that started from this page (#22).

YJ_ENV_FILE=deploy/reference/.env sh scripts/gen-env.sh
docker compose -f deploy/reference/docker-compose.yml up --build -d

The generated value lands in deploy/reference/.env, which is git-ignored and which compose reads automatically because it sits beside the compose file. Skip that first command and the second one refuses to start, naming it:

error while interpolating services.postgres.environment.POSTGRES_PASSWORD:
  required variable POSTGRES_PASSWORD is missing a value:
  not set - run `YJ_ENV_FILE=deploy/reference/.env sh scripts/gen-env.sh` to generate it

The gate states what it is enforcing before it serves anything:

operator allow-list "/lists/allow.txt": 0 package(s) allowed without scoring [npm], re-read every 5s
operator deny-list "/lists/deny.txt": 1 package(s) blocked outright [npm], re-read every 5s
Yellow Jack starting on :8080
  ecosystem:         npm
  upstream:          http://custreg:4873
  public URL:        http://localhost:8080
  score threshold:   0.0
  scorecard mode:    off

Which signal is deciding here: the operator lists and the release-age floor. Both are local, and neither needs an account, a scanner or a network call of ours. scorecard mode: off means exactly what it says: the gate never resolves a source repository and never asks for a score, so the threshold line above is inert in this deployment. An allowed package’s audit reason reads “served: scoring is disabled (FW_SCORECARD_MODE=off), and it is on neither operator list nor in the known-malware feed” — the gate telling you it did not ask, which is a different statement from “it asked and the package passed”.

(Until off existed this walkthrough ran stub with a threshold of 0.0, and audit reasons read score 7.5 >= threshold 0.0. That was never the same thing: stub fabricates a flat 7.5 for every repository, and it now reports not ready so it cannot reach a deployment someone copies. If your own stack still shows that banner line, switch it to off.)

2. Watch it refuse a package

curl -i http://localhost:8080/left-pad
HTTP/1.1 403 Forbidden
X-Yellowjack-Kind: operator-denied
X-Yellowjack-Rule: deny-list:left-pad
X-Yellowjack-Source: operator deny list
X-Yellowjack-Reason: 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
X-Yellowjack-Next-Step: ... Waiting will not clear it and neither will re-running; it is
  reversible by whoever maintains this firewall's deny list

Four headers, and each answers a different question a developer actually asks: what kind of refusal, which rule fired, who decided, and what to do now.

3. Watch it allow one — and prove where it came from

curl -o /dev/null -w '%{http_code}\n' http://localhost:8080/is-number
200

A gate that blocks everything is not a gate, so this step is what makes step 2 mean something. Now the part worth doing, which shows the topology rather than asserting it:

docker compose -f deploy/reference/docker-compose.yml exec custreg ls /verdaccio/storage
is-number

is-number is in your registry’s own storage. left-pad is not. The allowed pull went through and was cached; the refused one never reached your registry at all.

4. Point a real client at it

On this machine:

npm --registry http://localhost:8080 install is-number
added 1 package in 3s

The refused package fails the way a developer will actually meet it:

npm error code E403
npm error 403 Forbidden - GET http://localhost:8080/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 ...

Running the client in a container instead? Set the address the client will use, or the install fails with ECONNREFUSED on a URL that looks perfectly correct:

YJ_PUBLIC_URL=http://firewall:8080 docker compose -f deploy/reference/docker-compose.yml up -d
docker run --rm --network reference_default -e npm_config_registry=http://firewall:8080 \
  node:22-alpine sh -c 'cd /tmp && npm init -y >/dev/null && npm install is-number'
added 1 package in 3s

The gate mints artifact URLs from FW_PUBLIC_URL, so it has to be reachable from where the client runs, not from where the gate runs. A container resolves localhost to itself. This is the single most likely thing to trip you up, which is why it is a setting here rather than a fixed value.

5. Read the decisions in the console

open http://localhost:8085/audit      # loopback only, on purpose

Both pulls are there — the refusal with its reason, the allow with its score. The console is published on 127.0.0.1 and so is the control plane behind it; the only service reachable from the network is the gate itself, because it is the only one a developer’s client has to reach.

To enable the write path (approve, deny, edit the lists), set both credentials:

CONSOLE_AUTH_USER=admin CONSOLE_AUTH_PASS=... \
  docker compose -f deploy/reference/docker-compose.yml up -d console

With them unset the console runs read-only, which is the right default while you are evaluating.

What a ruling reaches. Approve lets through a package the gate’s policy would have refused. Deny refuses it on every path — including a package the policy would allow on its own (D272, 2026-09-19, reversing that half of D11). The console states this at the moment you record a ruling.

The one limit. A human ruling lives in the approval service, and a gate that cannot reach that service falls back to policy — so a deny recorded here is not in force during an approval outage. A deny-list entry is: the deny.txt this example mounts, or /lists on the console image that can edit it. Use the list for a block that must hold regardless.

History, not current behaviour: until D272 a deny reached only the refuse path, so a deny on a package the policy allowed was recorded, displayed, and never enforced. #132 is the measured trace of that, and it is why the reach is spelled out here.

6. Try it in front of your build system without blocking anything

YJ_MODE=report docker compose -f deploy/reference/docker-compose.yml up -d firewall
MODE:  *** REPORT ONLY — NOTHING IS BLOCKED *** verdicts are computed and logged

The same denied package now returns 200 with 31,412 bytes, and the log carries what would have happened instead:

REPORT MODE: WOULD HAVE REFUSED left-pad -> package "left-pad" is on this organisation's
  deny list ... (relayed anyway; set FW_MODE=enforce to refuse)

This is the honest way to meet the biggest objection to a blocking gate, which is that nobody switches one on in front of a working build system cold. Run it in report mode, read a week of logs, then decide.

7. The time gate, and watch it hold a release back

The lists above are your own policy. The release-age floor is the other half, and it needs no list at all: it refuses versions that are too new to have been looked at by anyone. It is on by default, at 14 days (YJ_MIN_RELEASE_AGE_DAYS=0 turns it off). The measurement below was taken with it set to 7:

YJ_MIN_RELEASE_AGE_DAYS=7 docker compose -f deploy/reference/docker-compose.yml up -d firewall

What you will see is not a 403, and that is the design. The package still resolves; the versions younger than the floor are simply absent from what we hand the client, so npm install react quietly gets the newest release that has had seven days of daylight instead of failing the build. Measured on this stack against the live registry:

versions offered newest version dated
through Yellow Jack, floor at 7 days 2,934 2026-09-04
straight from the registry 2,945 2026-09-11

11 versions withheld, and the newest one we serve is exactly seven days older than the newest one upstream. That is the whole control: an attacker who publishes a poisoned release has to keep it undetected for a week before any of your builds can reach it.

A refusal would have been the easier thing to build and the wrong one. A gate that fails the build gets removed; a gate that steers resolution to a slightly older release gets kept.

8. The same topology, for PyPI

The PyPI gate listens on :8081, in front of a stand-in for your PyPI registry — a caching proxy of the public /simple/ index and the JSON API (/pypi/<name>/json, where the gate reads upload times for the cooldown), which is the shape a Nexus or Artifactory remote repository takes. Its deny list has one entry, requests. Point pip at it:

pip install --index-url http://localhost:8081/simple/ six
pip install --index-url http://localhost:8081/simple/ requests
Successfully installed six-1.17.0
ERROR: Ignored the following yanked versions: 0.2.0, 0.2.1, ... 2.32.5
ERROR: Could not find a version that satisfies the requirement requests (from versions: none)
ERROR: No matching distribution found for requests

A PyPI refusal does not look like an npm one, on purpose. A 403 on the index makes pip route around it and silently backtrack other packages to older, often CVE-laden versions. So the gate serves the real index with every release marked yanked (PEP 592) and the reason attached to each — the “protect and inform” posture (D22). Read it with curl and the reason is right there in the page, not just in a header:

curl -s http://localhost:8081/simple/requests/ | grep -o 'data-yanked="[^"]*"' | head -1
data-yanked="package "requests" is on this organisation's deny list; the index was
  fetched to list the releases to yank, and no artifact bytes were. This is a local
  policy decision, not a published malware advisory -- ask whoever maintains the
  firewall's deny list"

Two things pip’s output above does not say, and this page should. First, pip drops the reason on this path and prints “No matching distribution found” instead — a developer reads that as the package does not exist (#79, measured; no wording of ours changes it). Second, notice what the reason says about upstream contact: the index was fetched, the bytes were not. That is exactly what happened, and it is a different claim from the npm one in step 3. Yanking every release needs the real release list, so the refused package’s /simple/ page does reach your registry’s cache; its wheel never does. Prove both halves:

docker compose -f deploy/reference/docker-compose.yml exec custreg-pypi \
  sh -c 'grep -rl "simple/six/" /var/cache/nginx | wc -l; grep -rl "simple/requests/" /var/cache/nginx | wc -l'
docker compose -f deploy/reference/docker-compose.yml logs firewall-pypi | grep -c '_files/requests'
1
1
0

Both index pages are cached; zero artifact fetches for the refused one. And an exact pin does not get around it — the bytes are gated at the fetch:

pip install --index-url http://localhost:8081/simple/ 'requests==2.31.0'
ERROR: 403 Client Error: Forbidden for url: http://localhost:8081/_files/requests/packages/...

Where the bytes come from. The stand-in proxies the index and the JSON API; wheel links inside it still point at PyPI’s CDN, and the gate relays those through its own /_files/ path. So artifact bytes are gated but do not pass through this stand-in. If your registry also mirrors the files, point FW_FILES_UPSTREAM at it.

9. The same topology, for Docker

The OCI gate listens on :8082, in front of a registry:2 pull-through cache of Docker Hub — the shape Harbor’s and Nexus’s proxies take. Its deny list has one entry, library/busybox, which applies to every tag of that repository (#128).

The address is localhost on purpose, and it is not a placeholder for a hostname. Docker treats a loopback registry (localhost, 127.0.0.1) as plain HTTP automatically; any other hostname or IP needs --insecure-registry in every developer’s daemon configuration before a pull will even start. A real deployment terminates TLS at the gate and the question disappears, so the demo stays on loopback rather than teaching you a flag you should not need.

docker pull localhost:8082/library/alpine:3.20
docker pull localhost:8082/library/busybox:latest
Status: Downloaded newer image for localhost:8082/library/alpine:3.20
Error response from daemon: unknown: failed to resolve reference
  "localhost:8082/library/busybox:latest": unexpected status from HEAD request to
  http://localhost:8082/v2/library/busybox/manifests/latest: 403 Forbidden

That is what a daemon on the containerd image store prints (Docker Desktop, and new Docker Engine installations): it resolves a tag with a HEAD, which has no body, so the reason travels only in headers, and docker does not print them. A daemon on the older graphdriver store (docker info shows overlay2 and no driver-type row; Docker Engine 27 as installed by most distributions) fetches the manifest with a GET and does print the body — the whole reason — but leads with a guess of its own:

Error response from daemon: pull access denied for localhost:8082/library/busybox,
  repository does not exist or may require 'docker login': denied: blocked by firewall:
  package "library/busybox:latest" is on this organisation's deny list; refused ...

Neither half of that preamble is true, and it is printed before the text that corrects it. Both renderings are measured and pinned by e2e/blockreason_test.go, one leg per store. On either store the headers carry the verdict; ask for them:

curl -I http://localhost:8082/v2/library/busybox/manifests/latest
HTTP/1.1 403 Forbidden
X-Yellowjack-Kind: operator-denied
X-Yellowjack-Rule: deny-list:library/busybox:latest
X-Yellowjack-Reason: package "library/busybox:latest" is on this organisation's deny
  list; refused without contacting upstream. ...

Here the npm claim from step 3 holds exactly, and the registry proves it:

docker compose -f deploy/reference/docker-compose.yml exec custreg-oci \
  ls /var/lib/registry/docker/registry/v2/repositories/library/
alpine

alpine is in your registry. busybox never reached it. And the pull that succeeded went through the gate, not around it — its log carries the verdict for the tag and then for the digest the tag resolved to:

HEAD [verdict-allowed] library/alpine:3.20 -> allowed=true score=7.5 hasScore=true
GET  [verdict-allowed] library/alpine@sha256:6243dec9... -> allowed=true ...

Two things about image policy that differ from npm, on purpose

An operator-list entry covers the whole repository — every tag. library/busybox and library/busybox:1.36 are enforced identically: both refuse every tag. An advisory condemns a repository, not whichever tag was asked for, so the match strips the tag — but the second line does not look like it says that, so the gate says so when it loads one. Append library/nginx:1.27-alpine to deploy/reference/lists/oci/deny.txt; the next pull at least five seconds later re-reads the file (the reload is checked on lookup, not on a timer), refuses library/nginx at every tag, and the firewall-oci log says:

operator deny-list line 37: "library/nginx:1.27-alpine" names a tag or digest, but an
entry here applies to the WHOLE repository "library/nginx" — every tag of it, not just
the one written. It is enforced that way, and this is not an error. Write
"library/nginx" if that is what you meant, and note that per-tag entries are not
supported.

The allow list has one exception, since #155: an entry may name exactly one image by digest (library/nginx@sha256:…), and that one is exact rather than widened. A tag still cannot, because a tag is mutable — “allow whatever this tag points at next” is not a decision anyone made. So the same warning on the allow list ends by showing the digest form instead of saying exact entries are unsupported.

There is no release-age floor for images, and that is a measurement, not a gap. Step 7’s time gate needs a publication time the publisher does not control. A container registry offers none: a manifest response carries no Last-Modified, and the only date in the pull path is created in the image’s own config, written by whoever built it. Of 7 real images sampled on #127, 4 report 1970-01-01T00:00:00Z — every distroless one, because reproducible builds zero it on purpose. A window keyed on that field would read those as decades old and would clear any cooldown the moment a publisher edited one string. So the gate does not apply the window to OCI at all, firewall-oci sets no FW_MIN_RELEASE_AGE_DAYS, and referencedeploy_test.go fails if the example gains the knob before #127 closes.

Changing policy without a restart

Both lists are re-read every five seconds, and the check happens on lookup: a gate that receives no traffic re-reads on its next request, not on a timer. Append a name to deploy/reference/lists/npm/deny.txt, wait, and the next pull is refused; delete it and service resumes. A failed read keeps the last good list rather than failing open, which matters because a blanked deny list that fails open denies nothing.

Measured across replicas (e2e/ha_drill.sh leg 9, #131): three gates sharing one list directory, one entry added the way the console adds it (a temp file, then a rename), every replica touched just before the edit so its last check is the worst case. Every replica enforced the edit 4.7 s after it, within one poll sample of each other; a replica restarted in the same instant came up on the new list in 0.5 to 2.1 s; a replica mounting a copy nobody edited never changed, and the drill reports it rather than averaging it away. That is the compose shape. On Kubernetes the list is a ConfigMap and helm upgrade is the edit, so the kubelet’s own sync period sits in front of the same five seconds. Measured on a three-node kind cluster with one gate pod per node (e2e/helm_list_drill.sh, #131): the pods enforced the edit 50 to 76 s after the upgrade, each on its own node’s schedule, so for 10 to 20 s two replicas of one deployment answered the same request differently; a pod that mounted the list through subPath never saw the edit at all, which is why the chart mounts the directory; a pod created after the edit was refusing 9 s after its predecessor was deleted.

Two behaviours worth knowing, both measured rather than assumed:

  • The deny list beats the allow list, and the gate says so. A name on both is refused with rule deny-list:<name>, and the log names the override: POLICY "<name>" is on BOTH operator lists -- REFUSED: the deny list outranks the allow list. Until #124 that override was silent and the rule name was the only clue.
  • An allow-list entry DOES override a published malware advisory, by default (D367: the administrator’s decision outranks the advisory). Every such pull is served, logged as [administrator-override] and recorded in the audit record’s override field. Remove advisory from FW_ADMIN_OUTRANKS if you want advisories to win instead. Your deny list still beats both.

What is deliberately not turned on

how to turn it on
known-malware feed from public OSV data build it with sh scripts/dev.sh malwarefeed, then set FW_MALWARE_LIST
real OpenSSF Scorecard scoring FW_SCORECARD_MODE=api or local, and raise FW_SCORE_THRESHOLD
refuse the bytes of an unscorable package, not just its metadata FW_BYTE_GATE=enforce

The malware feed is off because it needs a data file you generate; the rest is off so the walkthrough above behaves the same way every time you run it. The time gate in step 7 is the exception: it ships on, at 14 days, because it is the only control here that needs neither a list you maintain nor a scanner, and it only holds back versions younger than the window, so every step above still resolves. Every setting in the product is listed in CONFIGURATION.md.

Tear it down

docker compose -f deploy/reference/docker-compose.yml down -v

-v also drops the database volume, so the next run starts with an empty audit trail.