Parrhesia

Parrhesia Logo

Parrhesia is a Nostr relay server written in Elixir/OTP.

BETA CONDITION – BREAKING CHANGES MAY STILL HAPPEN!

Supported storage backends:

Advanced Nostr features:

It exposes:

Listeners can run in plain HTTP, HTTPS, mutual TLS, or proxy-terminated TLS modes. The current TLS implementation supports:

Supported NIPs

Current supported_nips list:

1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 66, 70, 77, 86, 98

43 is advertised when the built-in NIP-43 relay access flow is enabled. Parrhesia generates relay-signed 28935 invite responses on REQ, validates join and leave requests locally, and publishes the resulting signed 8000, 8001, and 13534 relay membership events into its own local event store.

50 uses ranked PostgreSQL full-text search over event content by default. Parrhesia applies the filter limit after ordering by match quality, and falls back to trigram-backed substring matching for short or symbol-heavy queries such as search-as-you-type prefixes, domains, and punctuation-rich tokens.

66 is advertised when the built-in NIP-66 publisher is enabled and has at least one relay target. The default config enables it for the public relay URL. Parrhesia probes those target relays, collects the resulting NIP-11 / websocket liveness data, and then publishes the signed 10166 and 30166 events locally on this relay.

Parrhesia protocol extension: SYNC-PAGE

SYNC-PAGE is a Parrhesia-specific, non-NIP websocket frame for efficient relay-to-relay backfill. It pages stored events with (created_at, event_id) keyset cursors and can return batched EVENTS frames for high-volume catch-up. Parrhesia advertises support in NIP-11 as limitation.parrhesia_sync_page: true; workers only use it when a peer advertises that flag and otherwise fall back to standard REQ overlap sync. Operators can hide the advertisement with :features.parrhesia_sync_page / PARRHESIA_FEATURES_PARRHESIA_SYNC_PAGE.

See docs/sync.md for the wire shape and operational details.

Requirements


Command runner (just)

This repo includes a justfile that provides a grouped command/subcommand CLI over common mix tasks and scripts.

just
just help bench
just help e2e

Run locally

1) Prepare the database

Parrhesia uses these defaults in dev:

Create the DB and run migrations/seeds:

mix setup

2) Start the server

mix run --no-halt

The default public listener binds to http://localhost:4413.

WebSocket clients should connect to:

ws://localhost:4413/relay

Useful endpoints


Test suites

Primary test entrypoints:

The node-sync harnesses are driven by:

just e2e node-sync runs two real Parrhesia nodes against separate PostgreSQL databases, verifies catch-up and live sync, restarts one node, and verifies persisted resume behavior. just e2e node-sync-docker runs the same scenario against the release Docker image. just e2e node-sync-three-node-docker exercises high-volume paged relay sync across a three-node gap topology.

GitHub CI currently runs the non-Docker node-sync e2e on the main Linux matrix job. The Docker node-sync e2e remains an explicit/manual check because it depends on release-image build/runtime fidelity and a working Docker host.


Embedding in another Elixir app

Parrhesia is usable as an embedded OTP dependency, not just as a standalone relay process. The intended in-process surface is Parrhesia.API.*, especially:

For host-managed HTTP/WebSocket ingress mounting, use Parrhesia.Plug.

Start with:

Important caveats for host applications:

Official embedding boundary

For embedded use, the stable boundaries are:

If your host app owns the public HTTPS endpoint, keep this as the baseline runtime config:

config :parrhesia, :listeners, %{}

Notes:

The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes.


Production configuration

Minimal setup

Before a Nostr client can publish its first event successfully, make sure these pieces are in place:

  1. PostgreSQL is reachable from Parrhesia. Set DATABASE_URL and create/migrate the database with Parrhesia.Release.migrate() or mix ecto.migrate.

    PostgreSQL is the supported production datastore. The in-memory backend is intended for non-persistent runs such as tests and benchmarks.

  2. Parrhesia listeners are configured for your deployment. The default config exposes a public listener on plain HTTP port 4413, and a reverse proxy can terminate TLS and forward WebSocket traffic to /relay. Additional listeners can be defined in config/*.exs.

  3. :relay_url matches the public relay URL clients should use. Set PARRHESIA_RELAY_URL to the public relay URL exposed by the reverse proxy. In the normal deployment model, this should be your public wss://.../relay URL.

  4. The database schema is migrated before starting normal traffic. The app image does not auto-run migrations on boot.

That is the actual minimum. With default policy settings, writes do not require auth, event signatures are verified, and no extra Nostr-specific bootstrap step is needed before posting ordinary events.

In prod, these environment variables are used:

config/runtime.exs reads these values at runtime in production releases.

Runtime env naming

For runtime overrides, use the PARRHESIA_... prefix:

Examples:

export PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES=true
export PARRHESIA_METRICS_ALLOWED_CIDRS="10.0.0.0/8,192.168.0.0/16"
export PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY=drop_oldest

Listeners themselves are primarily configured under config :parrhesia, :listeners, .... The current runtime env helpers tune the default public listener and the optional dedicated metrics listener, including their connection ceilings.

For settings that are awkward to express as env vars, mount an extra config file and set PARRHESIA_EXTRA_CONFIG to its path inside the container.

Config reference

CSV env vars use comma-separated values. Boolean env vars accept 1/0, true/false, yes/no, or on/off.

Parrhesia supports role-based server identities. Normal relay use defaults to the :relay role; sync workers use :node for NIP-42 peer authentication when that role is configured.

config :parrhesia, :identities,
relay: [private_key: shared_relay_private_key],
node: [private_key: per_node_private_key]

Top-level :parrhesia

Atom keyENVDefaultNotes
:relay_urlPARRHESIA_RELAY_URLws://localhost:4413/relayAdvertised relay URL and auth relay tag target
:metadata.hide_version?PARRHESIA_METADATA_HIDE_VERSIONtrueHides the relay version from outbound User-Agent and NIP-11 when enabled
:acl.protected_filtersPARRHESIA_ACL_PROTECTED_FILTERS[]JSON-encoded protected filter list for sync ACL checks
:identities.relay.pathPARRHESIA_RELAY_IDENTITY_PATHnilOptional path for the public relay identity
:identities.relay.private_keyPARRHESIA_RELAY_IDENTITY_PRIVATE_KEYnilOptional inline private key for the public relay identity
:identities.relay.auto_generate?PARRHESIA_RELAY_IDENTITY_AUTO_GENERATEfalse in prod, true in dev/testAllow the relay identity to be generated and persisted
:identities.node.pathPARRHESIA_NODE_IDENTITY_PATHnilOptional path for the per-node sync identity
:identities.node.private_keyPARRHESIA_NODE_IDENTITY_PRIVATE_KEYnilOptional inline private key for the per-node sync identity
:identities.node.auto_generate?PARRHESIA_NODE_IDENTITY_AUTO_GENERATEfalse in prodAllow the node identity to be generated and persisted
:moderation_cache_enabledPARRHESIA_MODERATION_CACHE_ENABLEDtrueToggle moderation cache
:enable_expiration_workerPARRHESIA_ENABLE_EXPIRATION_WORKERtrueToggle background expiration worker
:nip43config-file drivensee table belowBuilt-in NIP-43 relay access invite / membership flow
:nip66config-file drivensee table belowBuilt-in NIP-66 discovery / monitor publisher
:sync.persist_state?PARRHESIA_SYNC_PERSIST_STATEtruePersist sync server/runtime state in PostgreSQL (sync_servers, sync_server_runtime)
:sync.start_workers?PARRHESIA_SYNC_START_WORKERStrueStart outbound sync workers on boot
:database.storage_circuit_enabled?PARRHESIA_DB_STORAGE_CIRCUIT_ENABLEDfalseEnables DB outage fast-fail circuit behavior for Postgres-backed event paths (opt-in)
:database.storage_circuit_failure_thresholdPARRHESIA_DB_STORAGE_CIRCUIT_FAILURE_THRESHOLD3Consecutive connectivity failures required before opening the storage circuit
:database.storage_circuit_cooldown_msPARRHESIA_DB_STORAGE_CIRCUIT_COOLDOWN_MS500Cooldown period before allowing DB operations again after circuit open
:database.storage_circuit_startup_grace_msPARRHESIA_DB_STORAGE_CIRCUIT_STARTUP_GRACE_MS10000Startup grace period before DB failures can trip the circuit
:limitsPARRHESIA_LIMITS_*see table belowRuntime override group
:policiesPARRHESIA_POLICIES_*see table belowRuntime override group
:listenersconfig-file drivensee notes belowIngress listeners with bind, transport, feature, auth, network, and baseline ACL settings
:retentionPARRHESIA_RETENTION_*see table belowPartition lifecycle and pruning policy
:featuresPARRHESIA_FEATURES_*see table belowRuntime override group
:storage.events-Parrhesia.Storage.Adapters.Postgres.EventsConfig-file override only
:storage.moderation-Parrhesia.Storage.Adapters.Postgres.ModerationConfig-file override only
:storage.groups-Parrhesia.Storage.Adapters.Postgres.GroupsConfig-file override only
:storage.admin-Parrhesia.Storage.Adapters.Postgres.AdminConfig-file override only

Parrhesia.Repo

Atom keyENVDefaultNotes
:urlDATABASE_URLrequiredExample: ecto://USER:PASS@HOST/DATABASE
:pool_sizePOOL_SIZE32DB connection pool size
:queue_targetDB_QUEUE_TARGET_MS1000Ecto queue target in ms
:queue_intervalDB_QUEUE_INTERVAL_MS5000Ecto queue interval in ms
:types-Parrhesia.PostgresTypesInternal config-file setting

Parrhesia.ReadRepo

Atom keyENVDefaultNotes
:urlDATABASE_URLrequiredShares the primary DB URL with the write repo
:pool_sizeDB_READ_POOL_SIZE32Read-only query pool size
:queue_targetDB_READ_QUEUE_TARGET_MS1000Read pool Ecto queue target in ms
:queue_intervalDB_READ_QUEUE_INTERVAL_MS5000Read pool Ecto queue interval in ms
:types-Parrhesia.PostgresTypesInternal config-file setting

:listeners

Atom keyENVDefaultNotes
:public.bind.portPORT4413Default public listener port
:public.max_connectionsPARRHESIA_PUBLIC_MAX_CONNECTIONS20000Target total connection ceiling for the public listener
:public.proxy.trusted_cidrsPARRHESIA_TRUSTED_PROXIES[]Trusted reverse proxies for forwarded IP handling
:public.features.metrics.*PARRHESIA_METRICS_*see belowConvenience runtime overrides for metrics on the public listener
:metrics.bind.portPARRHESIA_METRICS_ENDPOINT_PORT9568Optional dedicated metrics listener port
:metrics.max_connectionsPARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS1024Target total connection ceiling for the dedicated metrics listener
:metrics.enabledPARRHESIA_METRICS_ENDPOINT_ENABLEDfalseEnables the optional dedicated metrics listener

Listener max_connections is a first-class config field. Parrhesia translates it to ThousandIsland's per-acceptor num_connections limit based on the active acceptor count. Raw bandit_options[:thousand_island_options] can still override that for advanced tuning.

Listener transport.tls supports :disabled, :server, :mutual, and :proxy_terminated. For TLS-enabled listeners, the main config-file fields are certfile, keyfile, optional cacertfile, optional cipher_suite, optional client_pins, and proxy_headers for proxy-terminated identity.

Every listener supports this config-file schema:

Atom keyENVDefaultNotes
:id-listener key or :listenerListener identifier
:enabledpublic/metrics helpers onlytrueWhether the listener is started
:bind.ip-0.0.0.0 (public) / 127.0.0.1 (metrics)Bind address
:bind.portPORT / PARRHESIA_METRICS_ENDPOINT_PORT4413 / 9568Bind port
:max_connectionsPARRHESIA_PUBLIC_MAX_CONNECTIONS / PARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS20000 / 1024Target total listener connection ceiling; accepts integer or :infinity in config files
:transport.scheme-:httpListener scheme
:transport.tls-%{mode: :disabled}TLS mode and TLS-specific options
:proxy.trusted_cidrsPARRHESIA_TRUSTED_PROXIES on public[]Trusted proxy CIDRs for forwarded identity / IP handling
:proxy.honor_x_forwarded_for-trueRespect X-Forwarded-For from trusted proxies
:network.public-falseAllow only public networks
:network.private_networks_only-falseAllow only RFC1918 / local networks
:network.allow_cidrs-[]Explicit CIDR allowlist
:network.allow_all-trueAllow all source IPs
:features.nostr.enabled-true on public, false on metrics listenerEnables /relay
:features.admin.enabled-true on public, false on metrics listenerEnables the listener management endpoint
:paths.health-"/health"Overrides the listener health path
:paths.ready-"/ready"Overrides the listener readiness path
:paths.relay-"/relay"Overrides the listener relay path
:paths.management-"/management"Overrides the listener management path
:paths.metrics-"/metrics"Overrides the listener metrics path
:features.metrics.enabledPARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT on publictrue on public, true on metrics listenerEnables /metrics
:features.metrics.auth_tokenPARRHESIA_METRICS_AUTH_TOKENnilOptional bearer token for /metrics
:features.metrics.access.publicPARRHESIA_METRICS_PUBLICfalseAllow public-network access to /metrics
:features.metrics.access.private_networks_onlyPARRHESIA_METRICS_PRIVATE_NETWORKS_ONLYtrueRestrict /metrics to private networks
:features.metrics.access.allow_cidrsPARRHESIA_METRICS_ALLOWED_CIDRS[]Additional CIDR allowlist for /metrics
:features.metrics.access.allow_all-trueUnconditional metrics access in config files

Listener path overrides must be non-empty absolute paths and must be unique within a listener. | :auth.nip42_required | - | false | Require NIP-42 for relay reads / writes | | :auth.nip98_required_for_admin | PARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIRED on public | true | Require NIP-98 for management API calls | | :identity_role | - | :relay | Identity role advertised in NIP-11 for this listener | | :baseline_acl.read | - | [] | Static read deny/allow rules | | :baseline_acl.write | - | [] | Static write deny/allow rules | | :read_protected_mode | - | :sanitize on public, :deny otherwise | Read handling for protected surfaces (:deny closes, :sanitize excludes protected subsets) | | :limits.max_filter_limit | - | global :limits.max_filter_limit | Per-listener cap for REQ initial replay and SYNC-PAGE page size; accepts :infinity in config files | | :limits.max_query_candidates | - | global :limits.max_query_candidates | Per-listener query candidate cap for REQ initial replay; accepts :infinity in config files | | :bandit_options | - | [] | Advanced Bandit / ThousandIsland passthrough |

:nip66

Atom keyENVDefaultNotes
:enabled-trueEnables the built-in NIP-66 publisher worker
:publish_interval_seconds-900Republish cadence for 10166 and 30166 events
:publish_monitor_announcement?-truePublish a 10166 monitor announcement alongside discovery events
:timeout_ms-5000Probe timeout for websocket and NIP-11 checks
:checks-[:open, :read, :nip11]Checks advertised in 10166 and run against each target relay during probing
:targets-[]Optional explicit relay targets to probe; when empty, Parrhesia uses :relay_url for the public listener

NIP-66 targets are probe sources, not publish destinations. Parrhesia connects to each target relay, collects the configured liveness / discovery data, and stores the resulting signed 10166 / 30166 events in its own local event store so clients can query them here.

:nip43

Atom keyENVDefaultNotes
:enabled-trueEnables the built-in NIP-43 relay access flow and advertises 43 in NIP-11
:invite_ttl_seconds-900Expiration window for generated invite claim strings returned by REQ filters targeting kind 28935
:request_max_age_seconds-300Maximum allowed age for inbound join (28934) and leave (28936) requests

Parrhesia treats NIP-43 invite requests as synthetic relay output, not stored client input. A REQ for kind 28935 causes the relay to generate a fresh relay-signed invite event on the fly. Clients then submit that claim back in a protected kind 28934 join request. When a join or leave request is accepted, Parrhesia updates its local relay membership state and publishes the corresponding relay-signed 8000 / 8001 delta plus the latest 13534 membership snapshot locally.

:limits

Atom keyENVDefault
:max_frame_bytesPARRHESIA_LIMITS_MAX_FRAME_BYTES1048576
:max_event_bytesPARRHESIA_LIMITS_MAX_EVENT_BYTES262144
:max_filters_per_reqPARRHESIA_LIMITS_MAX_FILTERS_PER_REQ16
:max_filter_limitPARRHESIA_LIMITS_MAX_FILTER_LIMIT500
:max_query_candidatesPARRHESIA_LIMITS_MAX_QUERY_CANDIDATES20000
:max_tags_per_eventPARRHESIA_LIMITS_MAX_TAGS_PER_EVENT256
:max_tag_values_per_filterPARRHESIA_LIMITS_MAX_TAG_VALUES_PER_FILTER128
:ip_max_event_ingest_per_windowPARRHESIA_LIMITS_IP_MAX_EVENT_INGEST_PER_WINDOW1000
:ip_event_ingest_window_secondsPARRHESIA_LIMITS_IP_EVENT_INGEST_WINDOW_SECONDS1
:pubkey_max_event_ingest_per_windowPARRHESIA_LIMITS_PUBKEY_MAX_EVENT_INGEST_PER_WINDOW0
:pubkey_event_ingest_window_secondsPARRHESIA_LIMITS_PUBKEY_EVENT_INGEST_WINDOW_SECONDS1
:relay_max_event_ingest_per_windowPARRHESIA_LIMITS_RELAY_MAX_EVENT_INGEST_PER_WINDOW10000
:relay_event_ingest_window_secondsPARRHESIA_LIMITS_RELAY_EVENT_INGEST_WINDOW_SECONDS1
:max_subscriptions_per_connectionPARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION32
:max_event_future_skew_secondsPARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS900
:max_event_ingest_per_windowPARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW120
:event_ingest_window_secondsPARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS1
:auth_max_age_secondsPARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS600
:websocket_ping_interval_secondsPARRHESIA_LIMITS_WEBSOCKET_PING_INTERVAL_SECONDS30
:websocket_pong_timeout_secondsPARRHESIA_LIMITS_WEBSOCKET_PONG_TIMEOUT_SECONDS10
:max_outbound_queuePARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE256
:outbound_drain_batch_sizePARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE64
:outbound_overflow_strategyPARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY:close
:max_negentropy_payload_bytesPARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES4096
:max_negentropy_sessions_per_connectionPARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION8
:max_negentropy_total_sessionsPARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS10000
:max_negentropy_items_per_sessionPARRHESIA_LIMITS_MAX_NEGENTROPY_ITEMS_PER_SESSION10000
:negentropy_id_list_thresholdPARRHESIA_LIMITS_NEGENTROPY_ID_LIST_THRESHOLD32
:negentropy_session_idle_timeout_secondsPARRHESIA_LIMITS_NEGENTROPY_SESSION_IDLE_TIMEOUT_SECONDS60
:negentropy_session_sweep_interval_secondsPARRHESIA_LIMITS_NEGENTROPY_SESSION_SWEEP_INTERVAL_SECONDS10

:policies

Atom keyENVDefault
:auth_required_for_writesPARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITESfalse
:auth_required_for_readsPARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READSfalse
:min_pow_difficultyPARRHESIA_POLICIES_MIN_POW_DIFFICULTY0
:accept_ephemeral_eventsPARRHESIA_POLICIES_ACCEPT_EPHEMERAL_EVENTStrue
:mls_group_event_ttl_secondsPARRHESIA_POLICIES_MLS_GROUP_EVENT_TTL_SECONDS300
:marmot_require_h_for_group_queriesPARRHESIA_POLICIES_MARMOT_REQUIRE_H_FOR_GROUP_QUERIEStrue
:marmot_group_max_h_values_per_filterPARRHESIA_POLICIES_MARMOT_GROUP_MAX_H_VALUES_PER_FILTER32
:marmot_group_max_query_window_secondsPARRHESIA_POLICIES_MARMOT_GROUP_MAX_QUERY_WINDOW_SECONDS2592000
:marmot_media_max_imeta_tags_per_eventPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_IMETA_TAGS_PER_EVENT8
:marmot_media_max_field_value_bytesPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_FIELD_VALUE_BYTES1024
:marmot_media_max_url_bytesPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_URL_BYTES2048
:marmot_media_allowed_mime_prefixesPARRHESIA_POLICIES_MARMOT_MEDIA_ALLOWED_MIME_PREFIXES[]
:marmot_media_reject_mip04_v1PARRHESIA_POLICIES_MARMOT_MEDIA_REJECT_MIP04_V1true
:marmot_push_server_pubkeysPARRHESIA_POLICIES_MARMOT_PUSH_SERVER_PUBKEYS[]
:marmot_push_max_relay_tagsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_RELAY_TAGS16
:marmot_push_max_payload_bytesPARRHESIA_POLICIES_MARMOT_PUSH_MAX_PAYLOAD_BYTES65536
:marmot_push_max_trigger_age_secondsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_TRIGGER_AGE_SECONDS120
:marmot_push_require_expirationPARRHESIA_POLICIES_MARMOT_PUSH_REQUIRE_EXPIRATIONtrue
:marmot_push_max_expiration_window_secondsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_EXPIRATION_WINDOW_SECONDS120
:marmot_push_max_server_recipientsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_SERVER_RECIPIENTS1
:management_auth_requiredPARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIREDtrue
Atom keyENVDefault
:public.features.metrics.enabledPARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINTtrue
:publicPARRHESIA_METRICS_PUBLICfalse
:private_networks_onlyPARRHESIA_METRICS_PRIVATE_NETWORKS_ONLYtrue
:allowed_cidrsPARRHESIA_METRICS_ALLOWED_CIDRS[]
:auth_tokenPARRHESIA_METRICS_AUTH_TOKENnil

:retention

Atom keyENVDefaultNotes
:check_interval_hoursPARRHESIA_RETENTION_CHECK_INTERVAL_HOURS24Partition maintenance + pruning cadence
:months_aheadPARRHESIA_RETENTION_MONTHS_AHEAD2Pre-create current month plus N future monthly partitions for events and event_tags
:max_db_bytesPARRHESIA_RETENTION_MAX_DB_BYTES:infinityInterpreted as GiB threshold; accepts integer or infinity
:max_months_to_keepPARRHESIA_RETENTION_MAX_MONTHS_TO_KEEP:infinityKeep at most N months (including current month); accepts integer or infinity
:max_partitions_to_drop_per_runPARRHESIA_RETENTION_MAX_PARTITIONS_TO_DROP_PER_RUN1Safety cap for each maintenance run

:features

Atom keyENVDefault
:verify_event_signatures-true
:nip_45_countPARRHESIA_FEATURES_NIP_45_COUNTtrue
:nip_50_searchPARRHESIA_FEATURES_NIP_50_SEARCHtrue
:nip_77_negentropyPARRHESIA_FEATURES_NIP_77_NEGENTROPYtrue
:parrhesia_sync_pagePARRHESIA_FEATURES_PARRHESIA_SYNC_PAGEtrue
:marmot_push_notificationsPARRHESIA_FEATURES_MARMOT_PUSH_NOTIFICATIONSfalse

:verify_event_signatures is config-file only. Production releases always verify event signatures.

Extra runtime config

Atom keyENVDefaultNotes
extra runtime config filePARRHESIA_EXTRA_CONFIGunsetImports an additional runtime .exs file

Deploy

Option A: Elixir release

export MIX_ENV=prod
export DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod"
export POOL_SIZE=20
mix deps.get --only prod
mix compile
mix release
_build/prod/rel/parrhesia/bin/parrhesia eval "Parrhesia.Release.migrate()"
_build/prod/rel/parrhesia/bin/parrhesia start

For systemd/process managers, run the release command with start.

Option B: Nix release package (default.nix)

Build:

nix build

Run the built release from ./result/bin/parrhesia (release command interface).

Option C: Docker image via Nix flake

Build the image tarball:

nix build .#dockerImage
# or with explicit build target:
nix build .#packages.x86_64-linux.dockerImage

Load it into Docker:

docker load < result

Run database migrations:

docker run --rm \
-e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
parrhesia:latest \
eval "Parrhesia.Release.migrate()"

Start the relay:

docker run --rm \
-p 4413:4413 \
-e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
-e POOL_SIZE=20 \
parrhesia:latest

Option D: Docker Compose with PostgreSQL

The repo includes compose.yaml and .env.example so Docker users can run Postgres and Parrhesia together.

Set up the environment file:

cp .env.example .env

If you are building locally from source, build and load the image first:

nix build .#dockerImage
docker load < result

Then start the stack:

docker compose up -d db
docker compose run --rm migrate
docker compose up -d parrhesia

The relay will be available on:

ws://localhost:4413/relay

Notes:


Benchmark

The benchmark compares two Parrhesia profiles, one backed by PostgreSQL and one backed by the in-memory adapter, against strfry and nostr-rs-relay using nostr-bench. The cloud benchmark target set also includes nostream and Haven. Benchmark runs also lift Parrhesia's relay-side limits by default so the benchmark client, not server guardrails, is the main bottleneck.

just bench compare is a sequential mixed-workload benchmark, not an isolated per-endpoint microbenchmark. Each relay instance runs connect, then echo, then event, then req against the same live process, so later phases measure against state and load created by earlier phases.

Run it with:

just bench compare

Cloud benchmark (Hetzner Cloud)

For distributed runs (one server node + multiple client nodes), use:

just bench cloud
# or: ./scripts/run_bench_cloud.sh

or invoke the orchestrator directly:

node scripts/cloud_bench_orchestrate.mjs

Prerequisites:

Example:

export HCLOUD_TOKEN=...
just bench cloud-quick
# or: ./scripts/run_bench_cloud.sh --quick

Outputs:

Useful history/render commands:

# List available machines and runs in history
just bench list
# Regenerate chart + README table for a machine
just bench update <machine_id>
# Regenerate from all machines
just bench update all

Current comparison results:

metricparrhesia-pgparrhesia-memstrfrynostr-rs-relaymem/pgstrfry/pgnostr-rs-relay/pg
connect avg latency (ms) ↓34.6743.332.672.671.25x0.08x0.08x
connect max latency (ms) ↓61.6774.674.674.001.21x0.08x0.06x
echo throughput (TPS) ↑72441.0062704.6761189.33152654.330.87x0.84x2.11x
echo throughput (MiB/s) ↑39.6734.3034.2083.630.86x0.86x2.11x
event throughput (TPS) ↑1897.331370.003426.67772.670.72x1.81x0.41x
event throughput (MiB/s) ↑1.230.872.200.500.70x1.78x0.41x
req throughput (TPS) ↑13.3347.001811.33878.333.52x135.85x65.88x
req throughput (MiB/s) ↑0.030.1711.772.405.00x353.00x72.00x

Higher is better for metrics. Lower is better for metrics.

(Results from a Linux container on a 6-core Intel i5-8400T with NVMe drive, PostgreSQL 18)

Benchmark chart


Development quality checks

Before opening a PR:

mix precommit

Additional external CLI end-to-end checks with nak:

just e2e nak

For Marmot client end-to-end checks (TypeScript/Node suite using marmot-ts, included in precommit):

just e2e marmot