Preview release. These docs are a work in progress. Pages are still being written, links may break, and structure may shift without notice. Treat everything here as a draft and report issues on GitHub.
Deploy Relay and Notary standalone with your own data
Use this guide when you operate your own registry data and want to run Registry Relay (and,
optionally, Registry Notary) as self-hosted services against that data, rather than the
registryctl sample project.
The sample-based tutorial,
Publish a spreadsheet as a secured registry API,
generates a project from the built-in benefits sample.
There is no registryctl initializer for your own dataset today: registryctl init relay
accepts only --sample benefits.
For your own CSV or XLSX data you hand-write the Relay configuration, starting from the
canonical example, and run the published container directly.
This guide targets a single-node, self-hosted deployment for evaluation and pilot use. It is not a formal production deployment profile. Deployment-profile formalization (Compose and Kubernetes shapes, sizing, and a hardening baseline) is tracked separately and is not part of this guide. Before exposing any deployment beyond local evaluation, work through the Relay operations runbook production hardening checklist.
Before you start
Section titled “Before you start”You will need:
- A Docker Compose provider (Docker Desktop, OrbStack, Colima, or Podman) or a host where you can run the Registry Relay binary.
- Your registry data as a CSV, XLSX, or Parquet file, with a stable primary-key column.
curlfor verification.
This guide uses synthetic placeholders.
Replace every example.gov value, dataset id, field name, and credential with your own.
Get the Registry Relay image
Section titled “Get the Registry Relay image”Registry Relay release images publish to ghcr.io/jeremi/registry-relay from stable vX.Y.Z
tags.
Consume a version tag or an image digest, not latest, so your deployment is reproducible and
you can roll back.
The published versions are listed on the
Relay releases page; at the time of writing
the current stable is v0.2.1, so replace vX.Y.Z with that (or a newer release) throughout
this page.
For the image signing policy, see the Relay
security assurance notes
and the build and release section
of the operations runbook.
Pull the version you intend to run:
docker pull ghcr.io/jeremi/registry-relay:vX.Y.ZThe container runs registry-relay --config /etc/registry-relay/config.yaml by default.
You can override the config path with the --config flag or the REGISTRY_RELAY_CONFIG
environment variable.
If you build from source instead of consuming a published image, follow the build and release section of the operations runbook; the source build needs the pinned sibling checkouts it documents.
Lay out the deployment directory
Section titled “Lay out the deployment directory”Create a working directory with your data and a config file:
my-registry/ config.yaml your hand-written Relay config data/ social_registry.csv your own exportRelay reads source files at the path declared in config.yaml.
Mount the data directory read-only and keep a separate writable cache directory; see
Run the container below.
Write the minimal Relay config
Section titled “Write the minimal Relay config”Start from the canonical
config/example.yaml
and the
configuration guide,
which is the authoritative reference for every block.
A minimal deployment needs five blocks: server (a listener), catalog (public metadata
base), auth (one auth mode), audit (a sink and hash secret), and at least one
entry in datasets. Every other root block is optional.
The example below exposes one dataset backed by a CSV file with a person entity.
Adapt the ids, fields, and scopes to your data.
server: bind: 0.0.0.0:8080 cache_dir: /var/lib/registry-relay/cache
catalog: title: Example Registry Relay base_url: https://data.example.gov publisher: Example Ministry
auth: mode: api_key api_keys: - id: program_system fingerprint: provider: env name: PROGRAM_SYSTEM_API_KEY_HASH commitment: sha256:0000000000000000000000000000000000000000000000000000000000000000 scopes: - social_registry:metadata - social_registry:rows
datasets: - id: social_registry title: Social Registry description: Registry of households participating in Program X owner: Example Ministry sensitivity: personal access_rights: restricted update_frequency: monthly tables: - id: individuals_table materialization: snapshot source: type: file path: /var/lib/registry-relay/data/social_registry.csv format: csv: header_row: 1 refresh: mode: mtime interval: 1h primary_key: individual_id schema: strict: true fields: - name: individual_id type: string nullable: false - name: household_id type: string nullable: false - name: municipality_code type: string nullable: true entities: - name: person title: Person description: A person enrolled in Program X table: individuals_table fields: - name: id from: individual_id - name: household_id from: household_id - name: municipality_code from: municipality_code access: metadata_scope: social_registry:metadata read_scope: social_registry:rows api: default_limit: 50 max_limit: 100 require_purpose_header: true allowed_filters: - field: id ops: [eq, in] - field: household_id ops: [eq, in]
audit: sink: stdout format: jsonl hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRETThe commitment value above is an all-zero placeholder. Relay fails closed at startup until you
replace it with the real commitment emitted by generate-api-key in the
next section.
Key points the configuration guide spells out in full:
-
Storage stays private.
tables[].id(individuals_table) never appears in a public URL. Callers address thepersonentity, not the table or the CSV column names. -
Explicit field projection. When
fieldsis present, only the listed fields are exposed. For personal data, always list fields explicitly rather than exposing every column. -
Scopes are
<dataset_id>:<suffix>. A principal holdingsocial_registry:metadatacannot read rows unless it also holdssocial_registry:rows. -
CSV vs XLSX source. For a CSV with a header row, set
format.csv.header_row: 1. For an XLSX workbook, replace the source format with the worksheet name:source:type: filepath: /var/lib/registry-relay/data/social_registry.xlsxformat:xlsx:sheet: Individualsheader_row: 1 -
audit.hash_secret_envmust name an environment variable holding at least 32 bytes of deployment-specific random secret material. Relay fails closed at startup if it is missing or weak. Generate it once withopenssl rand -hex 32(see Run the container) and keep it stable in your secret store, so audit subject hashes stay comparable across restarts.
For aggregates, relationships, OIDC auth, Postgres sources, live materialization, and provenance signing, follow the corresponding sections of the configuration guide.
Provision the API-key fingerprint and commitment
Section titled “Provision the API-key fingerprint and commitment”The config stores a committed fingerprint reference, never a raw API key.
For each api_keys entry you supply two things at runtime:
- The environment variable named by
fingerprint.name(herePROGRAM_SYSTEM_API_KEY_HASH), whose value is the fingerprint of the raw key, in the formsha256:<64 lowercase hex chars>. - The
fingerprint.commitmentfield in the YAML, a governed commitment over the product, credential type, credential id, and fingerprint.
The Relay binary generates all of these together.
Run it through the same container image, passing the id from your api_keys entry:
docker run --rm ghcr.io/jeremi/registry-relay:vX.Y.Z generate-api-key --id program_systemThe command emits four shell-friendly lines:
api_key_id=program_systemapi_key=<send-this-raw-key-to-the-client>fingerprint=sha256:<store-this-in-the-secret-store>commitment=sha256:<paste-this-into-config>Use the values as follows:
- Store the emitted
fingerprintin your secret store under the configuredfingerprint.name(herePROGRAM_SYSTEM_API_KEY_HASH). - Replace the all-zero
commitmentplaceholder in the YAML with the emittedcommitment. - Give the raw
api_keyonly to the authorized client. Relay never stores it.
The verification commands later on this page send the raw key as $RAW_KEY.
Keep it available in the shell you will verify from:
export RAW_KEY=<api_key value from the generate-api-key output>The commitment binds the key’s fingerprint to the product, the credential type, and the
credential id, so the --id you pass must match the api_keys entry’s id exactly.
If you later rotate the key or rename the entry, rerun the command and update both the secret
and the YAML commitment together.
The API keys section
of the configuration guide documents the underlying contract.
Run the container
Section titled “Run the container”First generate the audit hash secret. Any source of 32 or more random bytes works; with OpenSSL:
export REGISTRY_RELAY_AUDIT_HASH_SECRET="$(openssl rand -hex 32)"Store it in your secret store and reuse the same value across restarts: audit subject hashes are derived from it, so rotating it breaks the linkage between older and newer audit records.
Run Relay with your config and data mounted, the fingerprint and audit secret supplied as environment variables, and a writable cache:
mkdir -p cache
docker run --rm \ -p 127.0.0.1:8080:8080 \ -v "$(pwd)/config.yaml:/etc/registry-relay/config.yaml:ro" \ -v "$(pwd)/data:/var/lib/registry-relay/data:ro" \ -v "$(pwd)/cache:/var/lib/registry-relay/cache" \ -e PROGRAM_SYSTEM_API_KEY_HASH="sha256:<your fingerprint>" \ -e REGISTRY_RELAY_AUDIT_HASH_SECRET="$REGISTRY_RELAY_AUDIT_HASH_SECRET" \ ghcr.io/jeremi/registry-relay:vX.Y.ZRelay exits non-zero if config parsing or validation fails, if a required API-key fingerprint
environment variable is missing, or if a listener cannot bind.
The container’s recommended source-data mount is /var/lib/registry-relay/data; match the
path values in your config to wherever you mount the data.
The cache mount matches server.cache_dir in the config above; without it, ingestion fails on
the first cache write because the source mount is read-only.
Operational logs go to stderr as readable text; set -e REGISTRY_RELAY_LOG_FORMAT=json to emit
JSON Lines for a log collector.
Verify the deployment
Section titled “Verify the deployment”/healthz and /ready are unauthenticated.
/healthz is liveness only; /ready returns 200 only once configured sources have ingested
successfully.
curl -i http://127.0.0.1:8080/healthzcurl -i http://127.0.0.1:8080/readyBoth return 200 OK on a healthy, ready instance.
Protected routes require a credential. Call the dataset discovery route without one and confirm Relay denies it:
curl -i http://127.0.0.1:8080/v1/datasetsRelay returns 401 Unauthorized.
Now call the same route with the raw key whose fingerprint you provisioned:
curl -i \ -H "Authorization: Bearer $RAW_KEY" \ http://127.0.0.1:8080/v1/datasetsRelay returns 200 OK and lists the dataset visible to that principal’s metadata scope.
Read entity records.
This entity sets require_purpose_header: true, so the request must carry a Data-Purpose
header and a declared filter:
curl -sS -G \ -H "Authorization: Bearer $RAW_KEY" \ -H "Data-Purpose: https://example.gov/purpose/eligibility" \ --data-urlencode "household_id=hh-1001" \ http://127.0.0.1:8080/v1/datasets/social_registry/entities/person/recordsThe Data-Purpose value is free-form for Relay: it is recorded verbatim in the audit log, not
validated against a vocabulary. Mint stable purpose URIs under a namespace you control (the
example uses https://example.gov/purpose/eligibility) and agree the purpose set with your
clients as part of the data-sharing agreement, since the audit trail is only as reviewable as
the purposes are consistent.
Relay also serves a DCAT catalog document at /metadata/catalog.
It requires the same metadata scope as /v1/datasets and returns a standards-shaped
catalog description (rather than the dataset id list) that catalog tooling can consume:
curl -sS \ -H "Authorization: Bearer $RAW_KEY" \ http://127.0.0.1:8080/metadata/catalogIf you point API tooling at the deployment, note that /openapi.json is auth-gated by default
(openapi_requires_auth: true), so Swagger-style clients need a credential too.
If /ready returns 503, the source failed to ingest; check the container logs for the failing
table and the declared schema. The
readiness and probes section
of the operations runbook explains the readiness contract.
Add a standalone Registry Notary alongside
Section titled “Add a standalone Registry Notary alongside”Registry Relay is the protected consultation API; Registry Notary is the separate claim evaluation, credential issuance, and attestation service. Relay does not verify claims. When a claim profile needs registry data, Notary calls Relay as an HTTP source.
This section runs a minimal claim-evaluation Notary next to the Relay deployed above: one caller key, one source connection pointed at the Relay, and one boolean claim. Credential issuance (signing keys, credential profiles, OID4VCI) is a further configuration layer; the reference pages at the end of this section cover it.
Get the Registry Notary image
Section titled “Get the Registry Notary image”Registry Notary release images publish to ghcr.io/jeremi/registry-notary from stable vX.Y.Z
tags.
As with Relay, consume a version tag or an image digest, not latest.
The published versions are listed on the
Notary releases page; at the time of
writing the current stable is v0.3.1, so replace vX.Y.Z with that (or a newer release) in
the commands below.
docker pull ghcr.io/jeremi/registry-notary:vX.Y.ZThe container listens on port 8080 and runs
registry-notary --config /etc/registry-notary/config.yaml by default.
The image is distroless: there is no shell, and you run CLI subcommands by appending them to
docker run, as below.
Register a Relay key for Notary
Section titled “Register a Relay key for Notary”On the Relay side, Notary is an ordinary API client, so it gets its own credential, scoped to
only what its claims need.
Add a second entry to auth.api_keys in the Relay config.yaml, granting only the entity’s
read scope:
- id: notary_source fingerprint: provider: env name: NOTARY_SOURCE_API_KEY_HASH commitment: sha256:0000000000000000000000000000000000000000000000000000000000000000 scopes: - social_registry:rowsGenerate the key material the same way as before, with the matching --id:
docker run --rm ghcr.io/jeremi/registry-relay:vX.Y.Z generate-api-key --id notary_sourceReplace the placeholder commitment with the emitted one, store the emitted fingerprint for
the Relay container’s NOTARY_SOURCE_API_KEY_HASH environment variable, and keep the raw
api_key: it becomes Notary’s source token below.
export RELAY_SOURCE_TOKEN=<api_key value from the generate-api-key output>Restart Relay on a shared network
Section titled “Restart Relay on a shared network”The Notary container must reach the Relay container directly; the host-loopback port publish from Run the container is not visible from inside another container. Create a user-defined Docker network and re-run Relay with a name on that network, keeping everything else from the earlier command and adding the new fingerprint variable:
docker network create registry-netdocker run --rm \ --name relay \ --network registry-net \ -p 127.0.0.1:8080:8080 \ -v "$(pwd)/config.yaml:/etc/registry-relay/config.yaml:ro" \ -v "$(pwd)/data:/var/lib/registry-relay/data:ro" \ -v "$(pwd)/cache:/var/lib/registry-relay/cache" \ -e PROGRAM_SYSTEM_API_KEY_HASH="sha256:<your fingerprint>" \ -e NOTARY_SOURCE_API_KEY_HASH="sha256:<notary_source fingerprint>" \ -e REGISTRY_RELAY_AUDIT_HASH_SECRET="$REGISTRY_RELAY_AUDIT_HASH_SECRET" \ ghcr.io/jeremi/registry-relay:vX.Y.ZOn this network, Notary reaches Relay at http://relay:8080; the host keeps using
http://127.0.0.1:8080.
Provision the Notary caller key and audit secret
Section titled “Provision the Notary caller key and audit secret”Notary authenticates its own callers (the program systems or verifier services that ask it to evaluate claims) with the same fingerprint pattern as Relay. Generate a caller key with the Notary CLI:
docker run --rm ghcr.io/jeremi/registry-notary:vX.Y.Z hash-api-key --print-secretThe command prints two lines: api_key=<raw key for the calling system> and
hash=sha256:<fingerprint>.
Store the hash for the REGISTRY_NOTARY_API_KEY_HASH environment variable and give the raw key
only to the authorized caller.
Unlike Relay’s generate-api-key, hash-api-key does not emit a commitment; the Notary
operator configuration reference uses the all-zero commitment value shown in the config below.
Notary also needs its own audit hash secret, with the same generation and stability rules as Relay’s:
export REGISTRY_NOTARY_AUDIT_HASH_SECRET="$(openssl rand -hex 32)"Write the minimal Notary config
Section titled “Write the minimal Notary config”Save this as notary-config.yaml next to the Relay config.
It follows the minimal machine config in the
operator configuration reference,
with the source connection pointed at the Relay above through its registry_data_api contract:
server: bind: 0.0.0.0:8080
auth: mode: api_key api_keys: - id: verifier_service fingerprint: provider: env name: REGISTRY_NOTARY_API_KEY_HASH commitment: sha256:0000000000000000000000000000000000000000000000000000000000000000 scopes: - social_registry:evidence_verification
audit: sink: stdout hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET
evidence: enabled: true service_id: example.registry-notary api_base_url: https://notary.example.gov source_connections: relay_instance: base_url: http://relay:8080 token_env: RELAY_SOURCE_TOKEN claims: - id: person-is-registered title: Person is registered version: 2026-06 subject_type: person value: type: boolean inputs: - name: target.identifiers.individual_id type: string source_bindings: social_registry: connector: registry_data_api connection: relay_instance required_scope: social_registry:evidence_verification dataset: social_registry entity: person lookup: input: target.identifiers.individual_id field: id op: eq cardinality: one rule: type: exists source: social_registry formats: - application/vnd.registry-notary.claim-result+jsonHow the two services connect:
- The source connection is the Relay client.
base_urlis the Relay address on the shared network, andtoken_envnames the environment variable holding the rawnotary_sourcekey. Raw tokens never go in the YAML. - The lookup maps claim inputs to Relay filters. The binding queries the
personentity of thesocial_registrydataset withid=<caller-supplied individual_id>, so the lookupfieldmust be one of the entity’sallowed_filtersin the Relay config. - Scopes are checked on both sides, separately.
required_scopeand the caller’sscopeslist are Notary scopes (operator-defined<namespace>:<operation>strings); thesocial_registry:rowsscope on thenotary_sourcekey is a Relay scope. The Notary caller never holds Relay credentials. - Purpose propagates. The caller declares a purpose when it asks Notary to evaluate the
claim, and Notary forwards it to Relay as the
Data-Purposeheader, which satisfies the entity’srequire_purpose_header: trueand lands in both audit logs.
Run and verify Notary
Section titled “Run and verify Notary”Run the container on the same network, publishing it on a different host port:
docker run --rm \ --network registry-net \ -p 127.0.0.1:8081:8080 \ -v "$(pwd)/notary-config.yaml:/etc/registry-notary/config.yaml:ro" \ -e REGISTRY_NOTARY_API_KEY_HASH="sha256:<hash from hash-api-key>" \ -e REGISTRY_NOTARY_AUDIT_HASH_SECRET="$REGISTRY_NOTARY_AUDIT_HASH_SECRET" \ -e RELAY_SOURCE_TOKEN="$RELAY_SOURCE_TOKEN" \ ghcr.io/jeremi/registry-notary:vX.Y.ZCheck liveness:
curl -i http://127.0.0.1:8081/healthzThen validate the configuration, env-backed secrets, and source wiring with Notary’s built-in diagnostic:
docker run --rm \ --network registry-net \ -v "$(pwd)/notary-config.yaml:/etc/registry-notary/config.yaml:ro" \ -e REGISTRY_NOTARY_API_KEY_HASH="sha256:<hash from hash-api-key>" \ -e REGISTRY_NOTARY_AUDIT_HASH_SECRET="$REGISTRY_NOTARY_AUDIT_HASH_SECRET" \ -e RELAY_SOURCE_TOKEN="$RELAY_SOURCE_TOKEN" \ ghcr.io/jeremi/registry-notary:vX.Y.Z \ doctor --config /etc/registry-notary/config.yamldoctor reports missing environment variables, malformed fingerprints, and broken source
references; explain-config prints the resolved configuration and the env vars it requires.
For exercising the claim end to end (request shapes, outcomes, and credential issuance on top),
work through Verify a claim with Registry Notary, which runs
the same claim lifecycle against a registryctl sample project.
Go deeper on Notary
Section titled “Go deeper on Notary”The full Notary configuration surface (replay protection, credential status, self-attestation, OID4VCI, federation) is deeper than this guide. Notary’s own operator documentation is the authoritative own-keys path:
- Registry Notary operator configuration reference: every config block, the secrets inventory, and the rollout checklist.
- Signing key provider: SD-JWT VC signing-key configuration, rotation, and PKCS#11 setup, for when you add credential issuance.
- Source and claim modeling: designing source connectors and claim boundaries beyond the single claim above.
The
Operating with Registry Notary section
of the Relay runbook documents the credential handshake the two services use, which the steps
above followed: a narrowly scoped Relay key for the Notary source caller, a separate Notary
caller key, and _env secret indirection on both sides so raw tokens stay out of YAML.
- Relay configuration guide: the full block-by-block reference for aggregates, OIDC, Postgres, and provenance.
- Relay operations runbook: the production hardening checklist to work through before exposing the deployment.
- Consultation flow: how Relay binds a source, applies scopes, serves entity routes, and audits every request.
- Registry Relay API reference: the full route inventory.