Skip to content
Registry Stack Docs Latest

Deploy Relay and Notary standalone with your own data

View as Markdown

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.

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.
  • curl for verification.

This guide uses synthetic placeholders. Replace every example.gov value, dataset id, field name, and credential with your own.

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:

Terminal window
docker pull ghcr.io/jeremi/registry-relay:vX.Y.Z

The 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.

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 export

Relay 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.

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_SECRET

Key points the configuration guide spells out in full:

  • Storage stays private. tables[].id (individuals_table) never appears in a public URL. Callers address the person entity, not the table or the CSV column names.

  • Explicit field projection. When fields is 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 holding social_registry:metadata cannot read rows unless it also holds social_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: file
    path: /var/lib/registry-relay/data/social_registry.xlsx
    format:
    xlsx:
    sheet: Individuals
    header_row: 1
  • audit.hash_secret_env must 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 with openssl 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:

  1. The environment variable named by fingerprint.name (here PROGRAM_SYSTEM_API_KEY_HASH), whose value is the fingerprint of the raw key, in the form sha256:<64 lowercase hex chars>.
  2. The fingerprint.commitment field 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:

Terminal window
docker run --rm ghcr.io/jeremi/registry-relay:vX.Y.Z generate-api-key --id program_system

The command emits four shell-friendly lines:

api_key_id=program_system
api_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 fingerprint in your secret store under the configured fingerprint.name (here PROGRAM_SYSTEM_API_KEY_HASH).
  • Replace the all-zero commitment placeholder in the YAML with the emitted commitment.
  • Give the raw api_key only 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:

Terminal window
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.

First generate the audit hash secret. Any source of 32 or more random bytes works; with OpenSSL:

Terminal window
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:

Terminal window
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.Z

Relay 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.

/healthz and /ready are unauthenticated. /healthz is liveness only; /ready returns 200 only once configured sources have ingested successfully.

Terminal window
curl -i http://127.0.0.1:8080/healthz
curl -i http://127.0.0.1:8080/ready

Both 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:

Terminal window
curl -i http://127.0.0.1:8080/v1/datasets

Relay returns 401 Unauthorized.

Now call the same route with the raw key whose fingerprint you provisioned:

Terminal window
curl -i \
-H "Authorization: Bearer $RAW_KEY" \
http://127.0.0.1:8080/v1/datasets

Relay 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:

Terminal window
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/records

The 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:

Terminal window
curl -sS \
-H "Authorization: Bearer $RAW_KEY" \
http://127.0.0.1:8080/metadata/catalog

If 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.

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.

Terminal window
docker pull ghcr.io/jeremi/registry-notary:vX.Y.Z

The 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.

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:rows

Generate the key material the same way as before, with the matching --id:

Terminal window
docker run --rm ghcr.io/jeremi/registry-relay:vX.Y.Z generate-api-key --id notary_source

Replace 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.

Terminal window
export RELAY_SOURCE_TOKEN=<api_key value from the generate-api-key output>

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:

Terminal window
docker network create registry-net
Terminal window
docker 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.Z

On 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:

Terminal window
docker run --rm ghcr.io/jeremi/registry-notary:vX.Y.Z hash-api-key --print-secret

The 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:

Terminal window
export REGISTRY_NOTARY_AUDIT_HASH_SECRET="$(openssl rand -hex 32)"

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+json

How the two services connect:

  • The source connection is the Relay client. base_url is the Relay address on the shared network, and token_env names the environment variable holding the raw notary_source key. Raw tokens never go in the YAML.
  • The lookup maps claim inputs to Relay filters. The binding queries the person entity of the social_registry dataset with id=<caller-supplied individual_id>, so the lookup field must be one of the entity’s allowed_filters in the Relay config.
  • Scopes are checked on both sides, separately. required_scope and the caller’s scopes list are Notary scopes (operator-defined <namespace>:<operation> strings); the social_registry:rows scope on the notary_source key 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-Purpose header, which satisfies the entity’s require_purpose_header: true and lands in both audit logs.

Run the container on the same network, publishing it on a different host port:

Terminal window
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.Z

Check liveness:

Terminal window
curl -i http://127.0.0.1:8081/healthz

Then validate the configuration, env-backed secrets, and source wiring with Notary’s built-in diagnostic:

Terminal window
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.yaml

doctor 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.

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:

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.