Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/llms.txt
Full corpus: https://docs.registrystack.org/llms-full.txt

# Deploy Relay and Notary standalone with your own data

> Hand-write a minimal Registry Relay configuration for your own CSV or XLSX dataset, run the container, verify protected reads, and add a standalone Registry Notary alongside.

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](../publish-spreadsheet-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](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md)
production hardening checklist.

## 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.
- `curl` for 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

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](https://github.com/jeremi/registry-relay/releases); 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](https://github.com/jeremi/registry-relay/blob/main/docs/security-assurance.md)
and the [build and release section](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md#build-and-release)
of the operations runbook.

Pull the version you intend to run:

```sh
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](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md#build-and-release)
of the operations runbook; the source build needs the pinned sibling checkouts it documents.

## Lay out the deployment directory

Create a working directory with your data and a config file:

```text
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](#run-the-container) below.

## Write the minimal Relay config

Start from the canonical
[`config/example.yaml`](https://github.com/jeremi/registry-relay/blob/main/config/example.yaml)
and the
[configuration guide](https://github.com/jeremi/registry-relay/blob/main/docs/configuration.md),
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.

```yaml
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
```

:::caution
The `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](#provision-the-api-key-fingerprint-and-commitment).
:::

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:

  ```yaml
  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](#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](https://github.com/jeremi/registry-relay/blob/main/docs/configuration.md).

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

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

The command emits four shell-friendly lines:

```text
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:

```sh
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](https://github.com/jeremi/registry-relay/blob/main/docs/configuration.md#api-keys)
of the configuration guide documents the underlying contract.

## Run the container

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

```sh
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:

```sh
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.

## Verify the deployment

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

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md)
of the operations runbook explains the readiness contract.

## 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](#go-deeper-on-notary) cover it.

### 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](https://github.com/jeremi/registry-notary/releases); 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.

```sh
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.

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

```yaml
    - 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`:

```sh
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.

```sh
export RELAY_SOURCE_TOKEN=<api_key value from the generate-api-key output>
```

### Restart Relay on a shared network

The Notary container must reach the Relay container directly; the host-loopback port publish
from [Run the container](#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:

```sh
docker network create registry-net
```

```sh
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

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:

```sh
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:

```sh
export REGISTRY_NOTARY_AUDIT_HASH_SECRET="$(openssl rand -hex 32)"
```

### 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](../../products/registry-notary/operator-config-reference/),
with the source connection pointed at the Relay above through its `registry_data_api` contract:

```yaml
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 and verify Notary

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

```sh
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:

```sh
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:

```sh
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](../verify-claim-registry-api/), which runs
the same claim lifecycle against a `registryctl` sample project.

### 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](../../products/registry-notary/operator-config-reference/):
  every config block, the secrets inventory, and the rollout checklist.
- [Signing key provider](../../products/registry-notary/signing-key-provider/): SD-JWT VC
  signing-key configuration, rotation, and PKCS#11 setup, for when you add credential issuance.
- [Source and claim modeling](../../products/registry-notary/source-claim-modeling-guide/):
  designing source connectors and claim boundaries beyond the single claim above.

The
[Operating with Registry Notary section](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md#operating-with-registry-notary)
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.

## Next

- [Relay configuration guide](https://github.com/jeremi/registry-relay/blob/main/docs/configuration.md):
  the full block-by-block reference for aggregates, OIDC, Postgres, and provenance.
- [Relay operations runbook](https://github.com/jeremi/registry-relay/blob/main/docs/ops.md):
  the production hardening checklist to work through before exposing the deployment.
- [Consultation flow](../../explanation/consultation-flow/): how Relay binds a source, applies
  scopes, serves entity routes, and audits every request.
- [Registry Relay API reference](../../reference/apis/registry-relay/): the full route inventory.