# Parrhesia Parrhesia Logo Parrhesia is a Nostr relay server written in Elixir/OTP. Supported storage backends: - PostgreSQL, which is the primary and production-oriented backend - in-memory storage, which is useful for tests, local experiments, and benchmarks **ALPHA CONDITION – BREAKING CHANGES MIGHT HAPPEN!** - Advanced Querying: Full-text search (NIP-50) and COUNT queries (NIP-45). - Secure Messaging: First-class support for Marmot MLS-encrypted groups and NIP-17/44/59 gift-wrapped DMs. - Identity & Auth: NIP-42 authentication flows and NIP-86 management API with NIP-98 HTTP auth. - Data Integrity: Negentropy-based synchronization and NIP-62 vanish flows. It exposes: - listener-configurable WS/HTTP ingress, with a default `public` listener on port `4413` - a WebSocket relay endpoint at `/relay` on listeners that enable the `nostr` feature - NIP-11 relay info on `GET /relay` with `Accept: application/nostr+json` - operational HTTP endpoints such as `/health`, `/ready`, and `/metrics` on listeners that enable them - a NIP-86-style management API at `POST /management` on listeners that enable the `admin` feature Listeners can run in plain HTTP, HTTPS, mutual TLS, or proxy-terminated TLS modes. The current TLS implementation supports: - server TLS on listener sockets - optional client certificate admission with listener-side client pin checks - proxy-asserted client TLS identity on trusted proxy hops - admin-triggered certificate reload by restarting an individual listener from disk ## 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. ## Requirements - Elixir `~> 1.18` - Erlang/OTP 28 - PostgreSQL (18 used in the dev environment; 16+ recommended) - Docker or Podman plus Docker Compose support if you want to run the published container image --- ## Run locally ### 1) Prepare the database Parrhesia uses these defaults in `dev`: - `PGDATABASE=parrhesia_dev` - `PGHOST=localhost` - `PGPORT=5432` - `PGUSER=$USER` Create the DB and run migrations/seeds: ```bash mix setup ``` ### 2) Start the server ```bash mix run --no-halt ``` The default `public` listener binds to `http://localhost:4413`. WebSocket clients should connect to: ```text ws://localhost:4413/relay ``` ### Useful endpoints - `GET /health` -> `ok` - `GET /ready` -> readiness status - `GET /metrics` -> Prometheus metrics (private/loopback source IPs by default) - `GET /relay` + `Accept: application/nostr+json` -> NIP-11 document - `POST /management` -> management API (requires NIP-98 auth) --- ## Test suites Primary test entrypoints: - `mix test` for the ExUnit suite - `mix test.marmot_e2e` for the Marmot client end-to-end suite - `mix test.node_sync_e2e` for the two-node relay sync end-to-end suite - `mix test.node_sync_docker_e2e` for the release-image Docker two-node relay sync suite The node-sync harnesses are driven by: - [`scripts/run_node_sync_e2e.sh`](./scripts/run_node_sync_e2e.sh) - [`scripts/run_node_sync_docker_e2e.sh`](./scripts/run_node_sync_docker_e2e.sh) - [`scripts/node_sync_e2e.exs`](./scripts/node_sync_e2e.exs) - [`compose.node-sync-e2e.yaml`](./compose.node-sync-e2e.yaml) `mix test.node_sync_e2e` runs two real Parrhesia nodes against separate PostgreSQL databases, verifies catch-up and live sync, restarts one node, and verifies persisted resume behavior. `mix test.node_sync_docker_e2e` runs the same scenario against the release Docker image. 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: - `Parrhesia.API.Events` for publish, query, and count - `Parrhesia.API.Stream` for local REQ-like subscriptions - `Parrhesia.API.Admin` for management operations - `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync Start with: - [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup - generated ExDoc for the `Embedded API` module group when running `mix docs` Important caveats for host applications: - Parrhesia is still alpha; expect some public API and config churn. - Parrhesia currently assumes a single runtime per BEAM node and uses globally registered process names. - The defaults in this repo's `config/*.exs` are not imported automatically when Parrhesia is used as a dependency. A host app must set `config :parrhesia, ...` explicitly. - The host app is responsible for migrating Parrhesia's schema, for example with `Parrhesia.Release.migrate()` or `mix ecto.migrate -r Parrhesia.Repo`. If you only want the in-process API and not the HTTP/WebSocket edge, configure: ```elixir config :parrhesia, :listeners, %{} ``` 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: - `DATABASE_URL` (**required**), e.g. `ecto://USER:PASS@HOST/parrhesia_prod` - `POOL_SIZE` (optional, default `32`) - `PORT` (optional, default `4413`) - `PARRHESIA_*` runtime overrides for relay config, metadata, identity, sync, ACL, limits, policies, listeners, retention, and features - `PARRHESIA_EXTRA_CONFIG` (optional path to an extra runtime config file) `config/runtime.exs` reads these values at runtime in production releases. ### Runtime env naming For runtime overrides, use the `PARRHESIA_...` prefix: - `PARRHESIA_RELAY_URL` - `PARRHESIA_METADATA_HIDE_VERSION` - `PARRHESIA_IDENTITY_*` - `PARRHESIA_SYNC_*` - `PARRHESIA_ACL_*` - `PARRHESIA_TRUSTED_PROXIES` - `PARRHESIA_PUBLIC_MAX_CONNECTIONS` - `PARRHESIA_MODERATION_CACHE_ENABLED` - `PARRHESIA_ENABLE_EXPIRATION_WORKER` - `PARRHESIA_ENABLE_PARTITION_RETENTION_WORKER` - `PARRHESIA_STORAGE_BACKEND` - `PARRHESIA_LIMITS_*` - `PARRHESIA_POLICIES_*` - `PARRHESIA_METRICS_*` - `PARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS` - `PARRHESIA_RETENTION_*` - `PARRHESIA_FEATURES_*` - `PARRHESIA_METRICS_ENDPOINT_*` Examples: ```bash 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`. #### Top-level `:parrhesia` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:relay_url` | `PARRHESIA_RELAY_URL` | `ws://localhost:4413/relay` | Advertised relay URL and auth relay tag target | | `:metadata.hide_version?` | `PARRHESIA_METADATA_HIDE_VERSION` | `true` | Hides the relay version from outbound `User-Agent` and NIP-11 when enabled | | `:acl.protected_filters` | `PARRHESIA_ACL_PROTECTED_FILTERS` | `[]` | JSON-encoded protected filter list for sync ACL checks | | `:identity.path` | `PARRHESIA_IDENTITY_PATH` | `nil` | Optional path for persisted relay identity material | | `:identity.private_key` | `PARRHESIA_IDENTITY_PRIVATE_KEY` | `nil` | Optional inline relay private key | | `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache | | `:enable_expiration_worker` | `PARRHESIA_ENABLE_EXPIRATION_WORKER` | `true` | Toggle background expiration worker | | `:nip43` | config-file driven | see table below | Built-in NIP-43 relay access invite / membership flow | | `:nip66` | config-file driven | see table below | Built-in NIP-66 discovery / monitor publisher | | `:sync.path` | `PARRHESIA_SYNC_PATH` | `nil` | Optional path to sync peer config | | `:sync.start_workers?` | `PARRHESIA_SYNC_START_WORKERS` | `true` | Start outbound sync workers on boot | | `:limits` | `PARRHESIA_LIMITS_*` | see table below | Runtime override group | | `:policies` | `PARRHESIA_POLICIES_*` | see table below | Runtime override group | | `:listeners` | config-file driven | see notes below | Ingress listeners with bind, transport, feature, auth, network, and baseline ACL settings | | `:retention` | `PARRHESIA_RETENTION_*` | see table below | Partition lifecycle and pruning policy | | `:features` | `PARRHESIA_FEATURES_*` | see table below | Runtime override group | | `:storage.events` | `-` | `Parrhesia.Storage.Adapters.Postgres.Events` | Config-file override only | | `:storage.moderation` | `-` | `Parrhesia.Storage.Adapters.Postgres.Moderation` | Config-file override only | | `:storage.groups` | `-` | `Parrhesia.Storage.Adapters.Postgres.Groups` | Config-file override only | | `:storage.admin` | `-` | `Parrhesia.Storage.Adapters.Postgres.Admin` | Config-file override only | #### `Parrhesia.Repo` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:url` | `DATABASE_URL` | required | Example: `ecto://USER:PASS@HOST/DATABASE` | | `:pool_size` | `POOL_SIZE` | `32` | DB connection pool size | | `:queue_target` | `DB_QUEUE_TARGET_MS` | `1000` | Ecto queue target in ms | | `:queue_interval` | `DB_QUEUE_INTERVAL_MS` | `5000` | Ecto queue interval in ms | | `:types` | `-` | `Parrhesia.PostgresTypes` | Internal config-file setting | #### `Parrhesia.ReadRepo` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:url` | `DATABASE_URL` | required | Shares the primary DB URL with the write repo | | `:pool_size` | `DB_READ_POOL_SIZE` | `32` | Read-only query pool size | | `:queue_target` | `DB_READ_QUEUE_TARGET_MS` | `1000` | Read pool Ecto queue target in ms | | `:queue_interval` | `DB_READ_QUEUE_INTERVAL_MS` | `5000` | Read pool Ecto queue interval in ms | | `:types` | `-` | `Parrhesia.PostgresTypes` | Internal config-file setting | #### `:listeners` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:public.bind.port` | `PORT` | `4413` | Default public listener port | | `:public.max_connections` | `PARRHESIA_PUBLIC_MAX_CONNECTIONS` | `20000` | Target total connection ceiling for the public listener | | `:public.proxy.trusted_cidrs` | `PARRHESIA_TRUSTED_PROXIES` | `[]` | Trusted reverse proxies for forwarded IP handling | | `:public.features.metrics.*` | `PARRHESIA_METRICS_*` | see below | Convenience runtime overrides for metrics on the public listener | | `:metrics.bind.port` | `PARRHESIA_METRICS_ENDPOINT_PORT` | `9568` | Optional dedicated metrics listener port | | `:metrics.max_connections` | `PARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS` | `1024` | Target total connection ceiling for the dedicated metrics listener | | `:metrics.enabled` | `PARRHESIA_METRICS_ENDPOINT_ENABLED` | `false` | Enables 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 key | ENV | Default | Notes | | --- | --- | --- | --- | | `:id` | `-` | listener key or `:listener` | Listener identifier | | `:enabled` | public/metrics helpers only | `true` | Whether the listener is started | | `:bind.ip` | `-` | `0.0.0.0` (`public`) / `127.0.0.1` (`metrics`) | Bind address | | `:bind.port` | `PORT` / `PARRHESIA_METRICS_ENDPOINT_PORT` | `4413` / `9568` | Bind port | | `:max_connections` | `PARRHESIA_PUBLIC_MAX_CONNECTIONS` / `PARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS` | `20000` / `1024` | Target total listener connection ceiling; accepts integer or `:infinity` in config files | | `:transport.scheme` | `-` | `:http` | Listener scheme | | `:transport.tls` | `-` | `%{mode: :disabled}` | TLS mode and TLS-specific options | | `:proxy.trusted_cidrs` | `PARRHESIA_TRUSTED_PROXIES` on `public` | `[]` | Trusted proxy CIDRs for forwarded identity / IP handling | | `:proxy.honor_x_forwarded_for` | `-` | `true` | Respect `X-Forwarded-For` from trusted proxies | | `:network.public` | `-` | `false` | Allow only public networks | | `:network.private_networks_only` | `-` | `false` | Allow only RFC1918 / local networks | | `:network.allow_cidrs` | `-` | `[]` | Explicit CIDR allowlist | | `:network.allow_all` | `-` | `true` | Allow all source IPs | | `:features.nostr.enabled` | `-` | `true` on `public`, `false` on metrics listener | Enables `/relay` | | `:features.admin.enabled` | `-` | `true` on `public`, `false` on metrics listener | Enables `/management` | | `:features.metrics.enabled` | `PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT` on `public` | `true` on `public`, `true` on metrics listener | Enables `/metrics` | | `:features.metrics.auth_token` | `PARRHESIA_METRICS_AUTH_TOKEN` | `nil` | Optional bearer token for `/metrics` | | `:features.metrics.access.public` | `PARRHESIA_METRICS_PUBLIC` | `false` | Allow public-network access to `/metrics` | | `:features.metrics.access.private_networks_only` | `PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY` | `true` | Restrict `/metrics` to private networks | | `:features.metrics.access.allow_cidrs` | `PARRHESIA_METRICS_ALLOWED_CIDRS` | `[]` | Additional CIDR allowlist for `/metrics` | | `:features.metrics.access.allow_all` | `-` | `true` | Unconditional metrics access in config files | | `: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 | | `:baseline_acl.read` | `-` | `[]` | Static read deny/allow rules | | `:baseline_acl.write` | `-` | `[]` | Static write deny/allow rules | | `:bandit_options` | `-` | `[]` | Advanced Bandit / ThousandIsland passthrough | #### `:nip66` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:enabled` | `-` | `true` | Enables the built-in NIP-66 publisher worker | | `:publish_interval_seconds` | `-` | `900` | Republish cadence for `10166` and `30166` events | | `:publish_monitor_announcement?` | `-` | `true` | Publish a `10166` monitor announcement alongside discovery events | | `:timeout_ms` | `-` | `5000` | Probe 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 key | ENV | Default | Notes | | --- | --- | --- | --- | | `:enabled` | `-` | `true` | Enables the built-in NIP-43 relay access flow and advertises `43` in NIP-11 | | `:invite_ttl_seconds` | `-` | `900` | Expiration window for generated invite claim strings returned by `REQ` filters targeting kind `28935` | | `:request_max_age_seconds` | `-` | `300` | Maximum 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 key | ENV | Default | | --- | --- | --- | | `:max_frame_bytes` | `PARRHESIA_LIMITS_MAX_FRAME_BYTES` | `1048576` | | `:max_event_bytes` | `PARRHESIA_LIMITS_MAX_EVENT_BYTES` | `262144` | | `:max_filters_per_req` | `PARRHESIA_LIMITS_MAX_FILTERS_PER_REQ` | `16` | | `:max_filter_limit` | `PARRHESIA_LIMITS_MAX_FILTER_LIMIT` | `500` | | `:max_tags_per_event` | `PARRHESIA_LIMITS_MAX_TAGS_PER_EVENT` | `256` | | `:max_tag_values_per_filter` | `PARRHESIA_LIMITS_MAX_TAG_VALUES_PER_FILTER` | `128` | | `:ip_max_event_ingest_per_window` | `PARRHESIA_LIMITS_IP_MAX_EVENT_INGEST_PER_WINDOW` | `1000` | | `:ip_event_ingest_window_seconds` | `PARRHESIA_LIMITS_IP_EVENT_INGEST_WINDOW_SECONDS` | `1` | | `:relay_max_event_ingest_per_window` | `PARRHESIA_LIMITS_RELAY_MAX_EVENT_INGEST_PER_WINDOW` | `10000` | | `:relay_event_ingest_window_seconds` | `PARRHESIA_LIMITS_RELAY_EVENT_INGEST_WINDOW_SECONDS` | `1` | | `:max_subscriptions_per_connection` | `PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION` | `32` | | `:max_event_future_skew_seconds` | `PARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS` | `900` | | `:max_event_ingest_per_window` | `PARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW` | `120` | | `:event_ingest_window_seconds` | `PARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS` | `1` | | `:auth_max_age_seconds` | `PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS` | `600` | | `:max_outbound_queue` | `PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE` | `256` | | `:outbound_drain_batch_size` | `PARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE` | `64` | | `:outbound_overflow_strategy` | `PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY` | `:close` | | `:max_negentropy_payload_bytes` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES` | `4096` | | `:max_negentropy_sessions_per_connection` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION` | `8` | | `:max_negentropy_total_sessions` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS` | `10000` | | `:max_negentropy_items_per_session` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_ITEMS_PER_SESSION` | `50000` | | `:negentropy_id_list_threshold` | `PARRHESIA_LIMITS_NEGENTROPY_ID_LIST_THRESHOLD` | `32` | | `:negentropy_session_idle_timeout_seconds` | `PARRHESIA_LIMITS_NEGENTROPY_SESSION_IDLE_TIMEOUT_SECONDS` | `60` | | `:negentropy_session_sweep_interval_seconds` | `PARRHESIA_LIMITS_NEGENTROPY_SESSION_SWEEP_INTERVAL_SECONDS` | `10` | #### `:policies` | Atom key | ENV | Default | | --- | --- | --- | | `:auth_required_for_writes` | `PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES` | `false` | | `:auth_required_for_reads` | `PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READS` | `false` | | `:min_pow_difficulty` | `PARRHESIA_POLICIES_MIN_POW_DIFFICULTY` | `0` | | `:accept_ephemeral_events` | `PARRHESIA_POLICIES_ACCEPT_EPHEMERAL_EVENTS` | `true` | | `:mls_group_event_ttl_seconds` | `PARRHESIA_POLICIES_MLS_GROUP_EVENT_TTL_SECONDS` | `300` | | `:marmot_require_h_for_group_queries` | `PARRHESIA_POLICIES_MARMOT_REQUIRE_H_FOR_GROUP_QUERIES` | `true` | | `:marmot_group_max_h_values_per_filter` | `PARRHESIA_POLICIES_MARMOT_GROUP_MAX_H_VALUES_PER_FILTER` | `32` | | `:marmot_group_max_query_window_seconds` | `PARRHESIA_POLICIES_MARMOT_GROUP_MAX_QUERY_WINDOW_SECONDS` | `2592000` | | `:marmot_media_max_imeta_tags_per_event` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_IMETA_TAGS_PER_EVENT` | `8` | | `:marmot_media_max_field_value_bytes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_FIELD_VALUE_BYTES` | `1024` | | `:marmot_media_max_url_bytes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_URL_BYTES` | `2048` | | `:marmot_media_allowed_mime_prefixes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_ALLOWED_MIME_PREFIXES` | `[]` | | `:marmot_media_reject_mip04_v1` | `PARRHESIA_POLICIES_MARMOT_MEDIA_REJECT_MIP04_V1` | `true` | | `:marmot_push_server_pubkeys` | `PARRHESIA_POLICIES_MARMOT_PUSH_SERVER_PUBKEYS` | `[]` | | `:marmot_push_max_relay_tags` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_RELAY_TAGS` | `16` | | `:marmot_push_max_payload_bytes` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_PAYLOAD_BYTES` | `65536` | | `:marmot_push_max_trigger_age_seconds` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_TRIGGER_AGE_SECONDS` | `120` | | `:marmot_push_require_expiration` | `PARRHESIA_POLICIES_MARMOT_PUSH_REQUIRE_EXPIRATION` | `true` | | `:marmot_push_max_expiration_window_seconds` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_EXPIRATION_WINDOW_SECONDS` | `120` | | `:marmot_push_max_server_recipients` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_SERVER_RECIPIENTS` | `1` | | `:management_auth_required` | `PARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIRED` | `true` | #### Listener-related Metrics Helpers | Atom key | ENV | Default | | --- | --- | --- | | `:public.features.metrics.enabled` | `PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT` | `true` | | `:public` | `PARRHESIA_METRICS_PUBLIC` | `false` | | `:private_networks_only` | `PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY` | `true` | | `:allowed_cidrs` | `PARRHESIA_METRICS_ALLOWED_CIDRS` | `[]` | | `:auth_token` | `PARRHESIA_METRICS_AUTH_TOKEN` | `nil` | #### `:retention` | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | `:check_interval_hours` | `PARRHESIA_RETENTION_CHECK_INTERVAL_HOURS` | `24` | Partition maintenance + pruning cadence | | `:months_ahead` | `PARRHESIA_RETENTION_MONTHS_AHEAD` | `2` | Pre-create current month plus N future monthly partitions for `events` and `event_tags` | | `:max_db_bytes` | `PARRHESIA_RETENTION_MAX_DB_BYTES` | `:infinity` | Interpreted as GiB threshold; accepts integer or `infinity` | | `:max_months_to_keep` | `PARRHESIA_RETENTION_MAX_MONTHS_TO_KEEP` | `:infinity` | Keep at most N months (including current month); accepts integer or `infinity` | | `:max_partitions_to_drop_per_run` | `PARRHESIA_RETENTION_MAX_PARTITIONS_TO_DROP_PER_RUN` | `1` | Safety cap for each maintenance run | #### `:features` | Atom key | ENV | Default | | --- | --- | --- | | `:verify_event_signatures` | `-` | `true` | | `:nip_45_count` | `PARRHESIA_FEATURES_NIP_45_COUNT` | `true` | | `:nip_50_search` | `PARRHESIA_FEATURES_NIP_50_SEARCH` | `true` | | `:nip_77_negentropy` | `PARRHESIA_FEATURES_NIP_77_NEGENTROPY` | `true` | | `:marmot_push_notifications` | `PARRHESIA_FEATURES_MARMOT_PUSH_NOTIFICATIONS` | `false` | `:verify_event_signatures` is config-file only. Production releases always verify event signatures. #### Extra runtime config | Atom key | ENV | Default | Notes | | --- | --- | --- | --- | | extra runtime config file | `PARRHESIA_EXTRA_CONFIG` | unset | Imports an additional runtime `.exs` file | --- ## Deploy ### Option A: Elixir release ```bash 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: ```bash nix-build ``` Run the built release from `./result/bin/parrhesia` (release command interface). ### Option C: Docker image via Nix flake Build the image tarball: ```bash nix build .#dockerImage # or with explicit build target: nix build .#packages.x86_64-linux.dockerImage ``` Load it into Docker: ```bash docker load < result ``` Run database migrations: ```bash docker run --rm \ -e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \ parrhesia:latest \ eval "Parrhesia.Release.migrate()" ``` Start the relay: ```bash 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`](./compose.yaml) and [`.env.example`](./.env.example) so Docker users can run Postgres and Parrhesia together. Set up the environment file: ```bash cp .env.example .env ``` If you are building locally from source, build and load the image first: ```bash nix build .#dockerImage docker load < result ``` Then start the stack: ```bash docker compose up -d db docker compose run --rm migrate docker compose up -d parrhesia ``` The relay will be available on: ```text ws://localhost:4413/relay ``` Notes: - `compose.yaml` keeps PostgreSQL in a separate container; the Parrhesia image only runs the app release. - The container listens on port `4413`; use `PARRHESIA_HOST_PORT` if you want a different published host port. - Migrations are run explicitly through the one-shot `migrate` service instead of on every app boot. - Common runtime overrides can go straight into `.env`; see [`.env.example`](./.env.example) for examples. - For more specialized overrides, mount a file and set `PARRHESIA_EXTRA_CONFIG=/path/in/container/runtime.exs`. - When a GHCR image is published, set `PARRHESIA_IMAGE=ghcr.io//parrhesia:` in `.env` and reuse the same compose flow. --- ## Benchmark The benchmark compares two Parrhesia profiles, one backed by PostgreSQL and one backed by the in-memory adapter, against [`strfry`](https://github.com/hoytech/strfry) and [`nostr-rs-relay`](https://sr.ht/~gheartsfield/nostr-rs-relay/) using [`nostr-bench`](https://github.com/rnostr/nostr-bench). Benchmark runs also lift Parrhesia's relay-side limits by default so the benchmark client, not server guardrails, is the main bottleneck. `mix bench` 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: ```bash mix bench ``` Current comparison results from [BENCHMARK.md](./BENCHMARK.md): | metric | parrhesia-pg | parrhesia-mem | nostr-rs-relay | mem/pg | nostr-rs/pg | | --- | ---: | ---: | ---: | ---: | ---: | | connect avg latency (ms) ↓ | 9.33 | 7.67 | 7.00 | **0.82x** | **0.75x** | | connect max latency (ms) ↓ | 12.33 | 9.67 | 10.33 | **0.78x** | **0.84x** | | echo throughput (TPS) ↑ | 64030.33 | 93656.33 | 140767.00 | **1.46x** | **2.20x** | | echo throughput (MiB/s) ↑ | 35.07 | 51.27 | 77.07 | **1.46x** | **2.20x** | | event throughput (TPS) ↑ | 5015.33 | 1505.33 | 2293.67 | 0.30x | 0.46x | | event throughput (MiB/s) ↑ | 3.40 | 1.00 | 1.50 | 0.29x | 0.44x | | req throughput (TPS) ↑ | 6416.33 | 14566.67 | 3035.67 | **2.27x** | 0.47x | | req throughput (MiB/s) ↑ | 42.43 | 94.23 | 19.23 | **2.22x** | 0.45x | 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) --- ## Development quality checks Before opening a PR: ```bash mix precommit ``` Additional external CLI end-to-end checks with `nak`: ```bash mix test.nak_e2e ``` For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`, included in `precommit`): ```bash mix test.marmot_e2e ```