Skip to content

Pryv.io DNS zone configuration

This guide describes how to declare DNS records that Pryv.io’s embedded DNS server will answer for your associated domain.

Since v2 (2026) DNS is served by the core binary’s embedded DNS server (module components/dns-server/), not by a dedicated container. There are two places records can come from:

  • Static entries — declared in YAML (dns.staticEntries + dns.records.root in override-config.yml) and reloaded on restart. Authoritative: they shadow anything runtime.
  • Runtime entries — stored in PlatformDB (rqlite, cluster-wide) and picked up by the DNS server on its periodic refresh. Written by the ACME orchestrator (transient _acme-challenge.* TXT records) and by operators via the bin/dns-records.js CLI or the POST /reg/records / DELETE /reg/records/:subdomain admin endpoints.

There is no longer a service-config-leader / admin-panel GUI — the unified config file is the source of truth for static records, and the CLI / admin API are the source of truth for runtime records.

This page is for operators whose Pryv.io deployment runs in DNS-active mode — i.e. the embedded DNS server answers for the domain (dns.active: true). It is not needed if your deployment runs in DNSless mode (dnsLess.isActive: true) or if you delegate DNS to a third party (Route 53, Cloudflare, Gandi, Infomaniak…).

Typical reasons to add records:

  • Publish operator-owned subdomains (sw.${DOMAIN}, mail.${DOMAIN}, my-service.${DOMAIN}).
  • Publish root-level records (A, MX, TXT, CAA, NS, SOA).
  • Satisfy DNS-based validation challenges (ACME DNS-01 — in v2 the built-in ACME orchestrator handles this automatically; you only write TXT records manually if you run ACME externally).

Before any record is served, the core must be configured to run the embedded DNS server:

dnsLess:
isActive: false # required with dns.domain: per-user subdomains under the domain
dns:
domain: example.com # your primary domain — do not include a leading dot
active: true # start the embedded DNS server
port: 53 # UDP and TCP; in prod bind to 53 (docker maps host 53/udp and 53/tcp → container)
ip: '0.0.0.0' # bind address
defaultTTL: 300 # seconds

In multi-core, the DNS server on each core reads from the same PlatformDB and returns consistent answers cluster-wide.

Upgrading from 2.0.0-rc.65 or earlier: set dnsLess.isActive explicitly

Section titled “Upgrading from 2.0.0-rc.65 or earlier: set dnsLess.isActive explicitly”

BREAKING (configuration). From 2.0.0-rc.66, a configuration that sets dns.domain but leaves dnsLess.isActive out refuses to boot, with or without core.url, and bin/check-config.js reports it as a problem. Such a core was silently broken: it was treated as multi-core under the domain (without core.url, its own URL became https://<core.id>.<dns.domain>) and /service/info advertised https://{username}.<domain>/, reg.<domain> and access.<domain> in place of your own service.* URLs, while requests were routed path-style under the shipped default dnsLess.isActive: true.

Before restarting a core on 2.0.0-rc.66 or later:

  • Set dnsLess.isActive explicitly: false for a deployment with per-user subdomains under dns.domain (as in the snippet above), true for a single core with path-style routing (dnsLess.publicUrl then sets its URLs).
  • A DNSless multi-core member (its own core.url, path-style routing) should drop dns.domain: such a member with no dns.domain and no dnsLess key is accepted as before.
  • Run node bin/check-config.js <your config file> on the new release (2.0.0-rc.66 or later) and fix what it reports before restarting.

Configurations written by bin/init.js or bin/bootstrap.js already set the key. A core without dns.domain is not affected.

Per-account names (usernames, access aliases, former usernames) are answered from the name to core rows of PlatformDB, on a single core too. A single core upgraded from 2.0.0-rc.65 or earlier needs one run of bin/reconcile-user-cores.js for its aliases and changed usernames to resolve: see aliases and username changes.

dns.staticEntries is a map from subdomain → record type(s). Each entry replaces the subdomain-level answer served by the DNS server — runtime records in PlatformDB for the same subdomain are shadowed (a drift warning is logged on startup if both exist).

dns:
staticEntries:
sw:
a: ['192.0.2.10', '192.0.2.11'] # two cores behind a round-robin
mail:
a: ['192.0.2.10']
www:
cname: 'my-site.example.com'
txt-demo:
txt: ['hello from pryv']

Root-level records (apex / @) for the zone live under dns.records.root:

dns:
records:
root:
a: ['192.0.2.10', '192.0.2.11']
aaaa: []
ns: ['ns1.example.com', 'ns2.example.com']
mx:
- { name: 'mail.example.com', priority: 10, ttl: 3600 }
- { name: 'mail-fallback.example.com', priority: 50, ttl: 3600 }
txt: ['v=spf1 include:_mailcust.example.com ?all']
caa: ['0 issue "letsencrypt.org"']
soa: null # null = auto-generated

reg, access, and mfa are reserved by the distribution. Every core answers those routes itself, so the embedded DNS resolves them to all available cores’ IPs automatically. Do not list them in staticEntries — any entry is ignored in favour of the auto-resolution.

Runtime records live in the cluster-wide PlatformDB (rqlite) and are served by every core’s DNS server. They are the right place for:

  • Transient validation records — the built-in ACME orchestrator writes _acme-challenge.<hostname> TXT records here automatically during DNS-01 challenges.
  • Operator-owned dynamic entries that you want to add or remove without restarting the core.

Run from the core’s repository root. The CLI talks to PlatformDB directly and works whether master is running or not — the DNS server picks up changes on its next periodic refresh (30 s by default).

Terminal window
node bin/dns-records.js list # list all runtime records
node bin/dns-records.js load records.yaml # upsert records from YAML
node bin/dns-records.js load records.yaml --dry-run
node bin/dns-records.js load records.yaml --replace # wipe existing then load
node bin/dns-records.js delete <subdomain> # remove a subdomain
node bin/dns-records.js export [file.yaml] # dump current records as YAML

YAML shape for load:

records:
- subdomain: _acme-challenge
records:
txt: ['validation-token-from-acme-client']
- subdomain: www
records:
a: ['192.0.2.10']

Token-authenticated routes on reg/records (auth via auth.adminAccessKey):

  • POST /reg/records — create or replace a subdomain’s records (payload mirrors one entry of the YAML above).
  • DELETE /reg/records/:subdomain — remove a subdomain.

These are the surfaces used by integrations that manage DNS programmatically (e.g. an external ACME client pushing TXT challenges).

IPv4 / IPv6 host records. Lists.

# static
dns:
staticEntries:
my-service:
a: ['192.0.2.10']
aaaa: ['2001:db8::10']

Alias. Single string.

dns:
staticEntries:
www:
cname: 'my-site.example.com'

Array of strings. One entry = one TXT RR; multiple entries = multiple TXT RRs for the same name.

dns:
staticEntries:
challenge:
txt: ['hi there', 'my-dns-challenge']

SPF records are just TXT records at the root — place them under dns.records.root.txt:

dns:
records:
root:
txt: ['v=spf1 include:_mailcust.example.com ?all']

Array of objects with name, priority, optional ttl:

dns:
records:
root:
mx:
- { name: 'mail.example.com', priority: 10, ttl: 10800 }
- { name: 'mail-fallback.example.com', priority: 50, ttl: 10800 }

dns.records.root.{ns, caa, soa} — ns and caa are arrays of strings, soa is either null (auto-generated) or a string in standard SOA record format.

Operators still running Pryv.io v1 used service-config-leader to edit config-leader/conf/platform.yml under DNS_SETTINGS. That mechanism does not exist in v2 — the table below maps each v1 variable to its v2 equivalent for convenience when migrating:

v1 platform.yml variable v2 equivalent
DNS_SETTINGS.settings.DNS_CUSTOM_ENTRIES.value.<sub>.ip dns.staticEntries.<sub>.a[]
DNS_SETTINGS.settings.DNS_CUSTOM_ENTRIES.value.<sub>.alias.name dns.staticEntries.<sub>.cname
DNS_SETTINGS.settings.DNS_CUSTOM_ENTRIES.value.<sub>.description dns.staticEntries.<sub>.txt[]
DNS_SETTINGS.settings.DNS_ROOT_DOMAIN_A_RECORD.value dns.records.root.a[]
DNS_SETTINGS.settings.DNS_ROOT_TXT_ARRAY.value dns.records.root.txt[]
DNS_SETTINGS.settings.DNS_MX_RECORDS.value[] dns.records.root.mx[]

Pryv.io v1 deployments older than the service-config-leader switch stored DNS records as JSON in pryv/dns/conf/dns.json (single-node) or reg-master/dns/conf/dns.json and reg-slave/dns/conf/dns.json (cluster). All keys lived under a top-level dns property. v2 equivalent:

v1 dns.json key v2 equivalent
dns.staticDataInDomain.<sub>.ip dns.staticEntries.<sub>.a[]
dns.staticDataInDomain.<sub>.alias.name dns.staticEntries.<sub>.cname (string)
dns.staticDataInDomain.<sub>.description (string or array) dns.staticEntries.<sub>.txt[]
dns.domain_A dns.records.root.a[]
dns.rootTXT.description[] dns.records.root.txt[]
dns.mail[] ({name, ip, ttl, priority}) dns.records.root.mx[] ({name, priority, ttl}) — ip is no longer carried; resolve the MX target’s A/AAAA via its own zone entry

Notes when porting:

  • Lower-case keys. The v1 server lower-cased subdomains; v2’s YAML keys must already be lower-case (no implicit normalisation).
  • JSON → YAML. v1’s JSON was edit-fragile (a stray comma stopped the DNS server from booting). The v2 YAML loader is more permissive but still validated on startup; bad shape fails fast with a logged error from components/dns-server/.
  • SPF. v1 documented SPF as a rootTXT entry, which is correct in v2 too — place the v=spf1 … string in dns.records.root.txt[].