Skip to content
Registry StackDocsDevelopment (unreleased)

Deploy and serve

For the operator

View as Markdown

You have a registry project that passes bregctl check --production, and you want it serving. This page takes you from an empty PostgreSQL server to a breg process that serves the project’s first package and answers GET /ready. The examples use the facility template that bregctl init --template facility-registry facility creates.

Two roles take part. The author prepares a package from the project. The operator, holding the migration credential, activates the package and runs the server. One person may hold both on a pilot; the commands stay the same. A package carries no signature: the migration credential is what authorizes an activation, and the database records every activation in its own ledger.

Deployment commands take absolute paths with no .. component and no symbolic link anywhere in them. That covers --runtime-config, --credentials, --test-receipt, --package, the test receipt’s --output, and the directories the runtime file names. When an output path such as the test receipt’s --output is refused for a symbolic link, the diagnostic names the linked component and the target it resolves to. If the linked component is an ancestor, choose an unused final name under its real directory. If the final component is itself a symbolic link, that final name is occupied by the link; choose another unused, non-symbolic-link name under the real parent.

  • PostgreSQL 17 or newer, reached over TLS, with two login roles: a migration role that owns the schema and applies packages, and a runtime role the serving process uses. A project with a crs84-point field also needs PostGIS 3.5 or newer. Your administrator creates the roles, schemas, and extensions; the product never creates any of them. SQLite is not a target. Ubuntu 24.04 ships PostgreSQL 16 and Debian 12 ships PostgreSQL 15, both too old: install 17 from the PostgreSQL project’s apt repository (PGDG), or run the postgres:17 image (postgis/postgis at PostgreSQL 17 when the project has a crs84-point field).
  • A host for one process. The runtime is a single binary that verifies one package and serves its REST API against one database, on the platforms in platform support.
  • HTTPS in front of it. The process trusts no host or forwarded header. Your reverse proxy or ingress terminates TLS, and generated links use the public origin you configure as listener.publicOrigin.
  • A token issuer. The registry verifies bearer access tokens from one OpenID Connect issuer and issues none. Human login, account lifecycle, and multifactor authentication stay with your identity provider.
  • A backup of the database before every activation. The product restores nothing itself. A destructive change is refused unless you name the backup its reviewed migration requires, as Change an active registry describes.

Records, their history, Proposals, and the outbox of undelivered events all live in PostgreSQL. Webhook delivery runs inside the process from that outbox, so there is no message broker and no cache. The audit journal goes to a local file per process or to standard output. Attachments use PostgreSQL unless you bind S3-compatible storage. A standalone registry needs no other Registry Stack service; a composition adds only the services it uses, such as a Review provider for reviewed Proposals. Authoring commands open no database, so an author works on a laptop until the project is ready to test.

What stays your job once it runs:

  • Secrets. Two database credentials, the audit hash key, the cursor secret, one key per webhook destination, and the issuer’s key set when you pin one. The runtime file names each by reference and refuses a value written inline.
  • Every production change. A one-line access change takes the same path as a new registry: check, test against a schema-test database with real tokens, package, plan, and activate under the migration credential in each environment.
  • The audit journal. Ship each process’s stream to append-only storage and confirm delivery before local retention expires. The product does not provide tamper evidence.
  • Retention and erasure. Erasing a record’s history is an operator command with no undo; see Retain, erase, and audit.
  • Rate limits. The runtime applies none of its own, so put the registry behind one.

No throughput or latency figure is committed. The product bounds what it can by construction: every request, lock, and migration runs under an operator-set timeout, the connection pool has a maximum size, and spatial responses refuse rather than truncate. A load-test harness ships in products/breg/loadtest; run it on a representative Linux host before you cite a number.

Base Registry Engine (BReg) is pre-1.0. A minor release may break APIs, package formats, or deployment contracts, and each break carries a BREAKING: entry in products/breg/CHANGELOG.md at the release tag you run. Security fixes land only in the latest release; see API stability and the support window. A pilot holds three conditions: run breg under a supervisor that restarts it, treat the holder of the migration credential as the activation gate, and stay inside the verified deployment profile, which leaves out Kubernetes.

Install the binaries on the host that will serve, and on the operator host that holds the migration credential:

Terminal window
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash
breg --version
bregctl --version

The installer checks every binary against the release SHA256SUMS before anything reaches the install directory, and installs breg and bregctl together or not at all. It does not verify who published the release; for that, follow the release verification procedure and rerun the installer with BREG_ASSET_DIR naming the verified directory. BREG_INSTALL_DIR selects the install directory (default ~/.local/bin). To pin a release, run that release’s breg-<tag>-install.sh from https://github.com/registrystack/registry-stack/releases/download/<tag>/. Binaries are published for linux-amd64, linux-arm64, and macos-arm64.

Test, package, activate, and serve with the same release of both binaries.

Each release also publishes the container image ghcr.io/registrystack/breg:<tag> for linux/amd64. The release manifest beside the binaries, registry-stack-<tag>-release-manifest.json, records the image digest; pin that digest rather than the tag. Serve the container image shows how to run it.

As a PostgreSQL administrator, create the two roles:

CREATE ROLE registry_migration LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
NOINHERIT NOBYPASSRLS PASSWORD '<migration-password>';
CREATE ROLE registry_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
NOINHERIT NOBYPASSRLS PASSWORD '<runtime-password>';

Two separate roles are the split role mode, which production uses. The serving process then cannot write the activation ledger or registry state, and apply and startup both refuse a runtime role that could, naming the grant, ownership, membership, or trigger to remove. Keep the migration credential off the serving host when no activation is in progress.

Create two databases with the same shape: the serving database and the schema-test database. In each, as the administrator (this is the serving database, registry):

CREATE DATABASE registry;
REVOKE ALL ON DATABASE registry FROM PUBLIC;
GRANT CONNECT ON DATABASE registry TO registry_migration, registry_runtime;
\connect registry
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE SCHEMA registry_internal AUTHORIZATION registry_migration;
CREATE SCHEMA registry_data AUTHORIZATION registry_migration;
CREATE SCHEMA registry_source AUTHORIZATION registry_migration;
CREATE SCHEMA registry_derived AUTHORIZATION registry_migration;
CREATE SCHEMA registry_context AUTHORIZATION registry_migration;
REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source,
registry_derived, registry_context FROM PUBLIC;

The package creates every table, function, and policy inside those five schemas. A missing prerequisite surfaces at test or apply, never at runtime.

For a spatial project, add a bounding-box role named after the runtime role with the suffix __spatial_bbox, and install PostGIS in a schema neither registry role owns:

CREATE ROLE registry_runtime__spatial_bbox NOLOGIN NOSUPERUSER NOCREATEDB
NOCREATEROLE NOINHERIT NOBYPASSRLS;
GRANT registry_runtime__spatial_bbox TO registry_migration
WITH INHERIT FALSE, SET TRUE, ADMIN FALSE;
-- in each database
CREATE SCHEMA registry_spatial_ext AUTHORIZATION postgres;
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA registry_spatial_ext;
REVOKE CREATE ON DATABASE registry FROM PUBLIC, registry_migration, registry_runtime;
REVOKE ALL ON SCHEMA registry_spatial_ext FROM PUBLIC;
GRANT USAGE ON SCHEMA registry_spatial_ext
TO registry_migration, registry_runtime, registry_runtime__spatial_bbox;

Size connections, timeouts, and statement statistics once the registry serves; see Set a PostgreSQL baseline.

The runtime file binds one package to one database, one token issuer, and one listener. It is a deployment artifact: keep it with the operator, outside the project, and never put a credential in it. Every credential is a reference such as secret:file/<name>, which resolves to a file under the secret root.

runtime.yaml
apiVersion: id.registrystack.org/formats/breg/runtime/v1alpha1
kind: BRegRuntimeConfig
listener:
bind: 127.0.0.1:8080
identity:
environment: production
instanceId: facility-registry
databaseId: facility-registry-db
databaseInitializationEnvironment: production
secretProviders:
file:
root: /etc/breg/secrets
database:
runtimeUrlRef: secret:file/runtime-database-url
migrationUrlRef: secret:file/migration-database-url
trustedRootCertificateRef: secret:file/database-ca
pool:
maximumConnections: 4
roles:
migration: registry_migration
runtime: registry_runtime
package:
root: /var/lib/breg/build-1/package
authentication:
oidc:
issuer: https://issuer.example.com
audience: urn:breg:facility-registry
allowedAlgorithm: EdDSA
accessTokenType: JWT
scopeClaim: scope
scopeSeparator: " "
allowedClients: [operator-editor, registrar, reader]
maximumTokenLifetimeSeconds: 3600
leewayMilliseconds: 60000
authorityClaims:
principal: registry_principal
purpose: registry_purpose
audit:
hashKeyRef: secret:file/audit-key
path: /var/log/breg/audit.jsonl
cursor:
secretRef: secret:file/cursor-key

The configuration reference lists every key. What the schema cannot tell you:

  • identity names the database this file may serve. The first activation records databaseId in the database, and every later plan, apply, and startup refuses a file that names another. instanceId stamps every webhook event this runtime captures, so keep it stable while deliveries are pending.
  • database names the two connection URLs and the two roles. The runtime requires TLS on both connections and verifies the server’s certificate and host name. trustedRootCertificateRef names PEM root certificates to trust in place of the platform roots, for a server whose certificate a private authority issued; omit it when a public authority issued it.
  • package.root is the package directory package published. Once you have the package, you may add package.expectedDigest with the reported package digest, and startup then refuses any other package at that path.
  • authentication.oidc.allowedClients lists the OAuth clients whose tokens the registry accepts, or unrestricted. A project that names clients itself, for example in a profile’s requesterClients, needs each of them listed, so unrestricted does not start with such a project.
  • authentication.oidc.issuer must be https; plain http is accepted only on an IPv4 loopback host. The key set comes from the issuer’s discovery document unless jwksSource names a URI or pins a static document.

Three optional sections matter only for some projects. taskGrantStatus pins, per task-grant issuer such as Casework or a chat assistant’s task authority, the endpoint and the registry’s own credentials it uses to check that a task grant is still current. wasmExecution sets the backend and the module and guest-memory ceilings for WASM handlers. authentication.oidc.assertionIssuers lists, per client, the assertion authorities that client may exchange a subject token from; once written, a client it does not list may exchange from none. Webhook destinations are bound in Bind webhook receivers, field encryption in Field encryption for restricted fields, and the metrics listener in Scrape metrics.

Create the files as the user that runs breg and the operator commands, because the resolver refuses a file owned by anyone else. Each must be a regular file with mode 0400 or 0600, with no other hard link and no symbolic link to it. A name starts with a lowercase letter and uses lowercase letters, digits, ., _, and -. The bytes are used exactly as written, so write them without a trailing newline:

Terminal window
install -d -m 0700 /etc/breg/secrets
(umask 077; openssl rand -hex 32 | tr -d '\n' > /etc/breg/secrets/audit-key)
(umask 077; openssl rand -hex 32 | tr -d '\n' > /etc/breg/secrets/cursor-key)
(umask 077; printf '%s' 'postgresql://registry_runtime:<runtime-password>@db.example.org:5432/registry' \
> /etc/breg/secrets/runtime-database-url)
(umask 077; printf '%s' 'postgresql://registry_migration:<migration-password>@db.example.org:5432/registry' \
> /etc/breg/secrets/migration-database-url)
(umask 077; cp /path/to/database-root-ca.pem /etc/breg/secrets/database-ca)

The audit key and the cursor secret each need at least 32 bytes. Percent-encode a password that carries reserved characters. The user in each URL must equal the role the file names under database.roles. The URL needs no sslmode parameter: the runtime requires TLS itself.

In a container platform that injects secrets as environment variables, declare secretProviders.environment and write secret:env/<NAME> instead.

The registry verifies a bearer access token before it reads any claim. Configure the issuer so that every token for this deployment satisfies the table. Fixture claims from journeys.yaml are never accepted by a running server.

Token partRequirement
alg, typ, kid headersalg equals allowedAlgorithm; typ matches accessTokenType case-insensitively, and at+jwt and application/at+jwt are accepted for each other; kid is in the key set and absent from deniedKids.
issEquals issuer.
audA string equal to audience, or an array of 1 to 16 distinct strings that contains it.
exp, iat, nbfThe lifetime is at most maximumTokenLifetimeSeconds (at most 7200); clock skew up to leewayMilliseconds (whole seconds, at most 300000) is tolerated.
azp or client_idListed in allowedClients, unless that is unrestricted.
scopeThe one claim named by scopeClaim covers every required permission: a string split on scopeSeparator, or an array of strings. Permissions from several claims are not merged.
principal claimThe claim named by authorityClaims.principal carries the identity recorded in audit and workflow decisions. It must match each profile’s principalClaim.
purpose claimThe claim named by authorityClaims.purpose is required when the profile lists requiredPurposes.
assignment claimsEach row boundary or claim-backed lookup names its own claim. An equals boundary needs a scalar; an in boundary needs an array.

Use a resource audience dedicated to this registry API, distinct from your application’s login client. An ID token issued to the login client is not an API credential. A longer token lifetime extends how long an issued token may carry permissions you have since withdrawn.

Choose the principal claim deliberately. sub works when both the runtime file and the profiles select it, and the server never falls back to another claim when the selected one is missing. An issuer’s sub usually belongs to that issuer alone, so if you may ever change issuer, prefer an institutional identifier both issuers carry in a custom claim, and name who guarantees it is unique and never reassigned. The registry treats the value as opaque and links no accounts across issuers; changing it changes who is recognized as a submitter or a previous reviewer.

Use the same principal claim in an ownership row boundary when a record’s owner field stores that identifier, and separate claims for district, tenant, or team membership. Have the issuer derive these claims from trusted assignments, never from values the caller supplies. A client-credentials token represents a service, not a person signing in.

Rotating keys and changing the issuer are in Rotate keys and change the issuer.

These three commands turn the project into a package the database can activate. Run check with the runtime file you will serve, so it also checks that file against the project:

Terminal window
bregctl check facility --runtime-config /etc/breg/runtime.yaml --production
Production check passed; registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158.

Warnings follow the success line; read each one.

test compiles the project, applies it to an empty schema-test database, measures the schema it produces, runs every journey in tests/journeys.yaml over real HTTP with real tokens, and writes a receipt that package binds to. It needs three inputs:

  1. A test runtime file: a copy of the serving file whose database URLs name the schema-test database and whose package.root is an empty directory.
  2. A real access token from your issuer for each journey principal, stored as a secret file.
  3. A credentials document of kind: BRegSchemaTestCredentials whose bindings list gives, for each journey step, its journeyId, its stepId, and a credential of type: bearer whose tokenRef names that token’s secret file (keys).
Terminal window
bregctl test facility \
--runtime-config /srv/registry/runtime-test.yaml \
--credentials /srv/registry/schema-test-credentials.yaml \
--output /srv/registry/schema-test-receipt.json
Fixture run passed. 1 journey.
profile production
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158
schema fingerprint sha256:99bc1da109508fdab4a69c0d0dc6c00efa0cd1c5cff05d4f81ca1460be91ff74
successful journeys register-facility
receipt sha256 33a29c7dfdb50ccee78033963d1beaa1cf4939c0a2d4dc77097bd3c9988fef6b
receipt bytes 755

test leaves its objects in the schema-test database, and a second run refuses with schema-test database is not clean. Drop and recreate that database before every run. A token that has expired fails a step as the fixture request could not be constructed; mint fresh tokens and rerun.

package seals the candidate and binds it to the receipt:

Terminal window
bregctl package facility \
--test-receipt /srv/registry/schema-test-receipt.json \
--output /var/lib/breg/build-1
Sealed and published a deployment package.
profile production
package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158
package files 28

The package directory is build-1/package. package refuses a receipt taken for other sources, and refuses an output directory that already holds a package, so give each candidate its own build directory. Never edit files inside a published package; build another one.

Rehearse the activation with the migration credential. plan makes every check apply makes, under the same lock, and changes nothing:

Terminal window
bregctl plan --runtime-config /etc/breg/runtime.yaml --package /var/lib/breg/build-1/package
Planned the first activation on this registry; nothing was changed. Run `bregctl apply --initial --package DIR` to activate it.
activation initial
package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158
role mode split
plan kind initial
change count 0
reviewed migration count 0
checks passed migration-role, runtime-write-authority, prerequisites, uninitialized-database

Then activate. --initial is for the first package in a database that has never had one:

Terminal window
bregctl apply --initial --runtime-config /etc/breg/runtime.yaml --package /var/lib/breg/build-1/package
Activated the first package on this registry.
activation initial
package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
schema fingerprint sha256:99bc1da109508fdab4a69c0d0dc6c00efa0cd1c5cff05d4f81ca1460be91ff74
activation id 41f33802-b5f2-4bec-94e0-1d8272839c03

status reads the ledger without taking the lock:

Terminal window
bregctl status --runtime-config /etc/breg/runtime.yaml
The database runs its active package and is ready.
package id facility-registry
database id facility-registry-db
active package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
activation id 41f33802-b5f2-4bec-94e0-1d8272839c03
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158
role mode split
schema fingerprint sha256:99bc1da109508fdab4a69c0d0dc6c00efa0cd1c5cff05d4f81ca1460be91ff74
maintenance status ready
activation 1
activation id 41f33802-b5f2-4bec-94e0-1d8272839c03
package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158
plan kind initial
migration kind compiled-additive
outcome applied
role mode split
started at 2026-10-17T11:31:02.621523Z
applied at 2026-10-17T11:31:24.938465Z

Each later activation adds an entry with its own outcome.

verify checks the package files against their sums and, when set, package.expectedDigest, and re-derives every generated artifact, without opening the database:

Terminal window
bregctl verify --runtime-config /etc/breg/runtime.yaml
Verified the package against the runtime it is bound to.
assurance runtime-bound
package digest sha256:ae245974f8da79cd441669fbe0e2461ceb26e216cd1f7f25a7240fd131d20fee
registry id facility-registry
registry version 0.1.0
registry revision sha256:4877be0dd9ded0447278206d829445780bf93a6cee356bc1526773003acad158

doctor opens every startup dependency without binding the listener, stops at the first one that refuses, and otherwise lists them all, then its advisories. These include the PostgreSQL advisories and the authentication.insecure-http warning when the runtime file sets authentication.oidc.allowInsecureHttp: true. Advisories never change the exit status:

Terminal window
bregctl doctor --runtime-config /etc/breg/runtime.yaml
10 dependency checks passed.
runtimeConfig pass
package pass
database pass
audit pass
cursor pass
authentication.oidc pass
eventDestinations pass
reviewBindings pass
authentication pass
fieldEncryption pass
roleMode split
Advisories:
information postgres.connections.budget
one replica's runtime pool takes at most half of the connections PostgreSQL leaves
for ordinary roles
poolMaxSize 4
maxConnections 100
superuserReservedConnections 3
reservedConnections 0
usableConnections 97
information postgres.pg-stat-statements.unavailable
pg_stat_statements is not installed in this database, so per-statement timings are
unavailable when diagnosing load

Monitor and recover explains each advisory and every refusal code doctor can print.

Terminal window
breg --runtime-config /etc/breg/runtime.yaml

Startup refuses a package the ledger does not name as active, and logs the role mode it serves in. Check readiness before admitting traffic:

Terminal window
curl -fsS http://127.0.0.1:8080/ready
{"status":"ready"}

GET /health and GET /healthz report liveness only. Neither probe reflects the background workers that deliver webhooks and advance reviews: when one stops, breg exits with status 1, so run it under a supervisor that restarts it. Watch the background workers covers what to alert on.

The image runs breg --runtime-config /etc/breg/runtime.yaml as the non-root user UID 65532, exposes port 8080, and carries the matching bregctl at /usr/local/bin/bregctl. It has no shell, no writable directory, and no health check of its own.

The container serves only a package that activation already recorded. Write a second runtime file for it, changing exactly four things from the host’s: listener.bind (to 0.0.0.0:8080), secretProviders.file.root (to /etc/breg/secrets), package.root (to /var/lib/breg/package), and the database host name in the connection URLs, because the container mounts its own paths and reaches the database over its own network.

UID 65532 must own the secrets directory, every secret file in it, and the audit directory, with mode 0700 on each directory. The mounted runtime file and package need only be readable by it:

Terminal window
chown -R 65532:65532 /srv/registry/container/secrets
chmod 0700 /srv/registry/container/secrets
install -d -m 0700 -o 65532 -g 65532 /var/log/breg
chown -R 65532:65532 /var/log/breg

Startup refuses an audit directory or audit file owned by anyone else, including a directory Docker created as root for a missing mount and one a host-run breg or bregctl left behind. Once the container owns /var/log/breg, a host-run bregctl is refused it in turn, so plan and apply from the image.

Then mount the inputs read-only and the audit directory writable:

Terminal window
docker run --restart on-failure \
-p 8080:8080 \
-v /srv/registry/container/runtime.yaml:/etc/breg/runtime.yaml:ro \
-v /var/lib/breg/build-1/package:/var/lib/breg/package:ro \
-v /srv/registry/container/secrets:/etc/breg/secrets:ro \
-v /var/log/breg:/var/log/breg:rw \
"$BREG_IMAGE"

BREG_IMAGE is the image digest from the release manifest. Run bregctl verify and bregctl doctor against this exact runtime file before you rely on its GET /ready. A fault that recurs on every start makes the container restart repeatedly; the restart policy’s backoff bounds how often.

You can plan, apply, and read the ledger from the same image you serve, without installing bregctl on an operator host. Override the entrypoint and pass the arguments Activate and serve uses, with the container’s paths:

Terminal window
docker run --rm \
--entrypoint /usr/local/bin/bregctl \
-v /srv/registry/container/runtime.yaml:/etc/breg/runtime.yaml:ro \
-v /var/lib/breg/build-1/package:/var/lib/breg/package:ro \
-v /srv/registry/container/migration-secrets:/etc/breg/secrets:ro \
-v /var/log/breg:/var/log/breg:rw \
"$BREG_IMAGE" \
plan --runtime-config /etc/breg/runtime.yaml --package /var/lib/breg/package

Run apply and status the same way. These commands connect with the migration credential, so mount a secret directory that holds it, and keep that credential out of the serving container’s secret directory. Give UID 65532 that directory and its files the same way as the serving container’s. apply writes audit entries beside audit.path, which is why the audit directory is writable here too.

Kubernetes is outside the verified deployment profile. A ConfigMap or Secret volume mounted the default way presents each key as a symbolic link, which the runtime refuses for both the runtime file and secret files; inject secrets through secret:env/<NAME> instead.