Configuration reference
Every key in config.yaml β types, defaults, validation rules.
MeshHold reads a single YAML file at startup. On Linux the default location is
~/.meshhold/config.yaml; on Windows the per-user installer puts it under
%LOCALAPPDATA%\MeshHold\config.yaml. Pass --config /path/to/file.yaml to
override.
A minimal config is short β only swarm_key and bootstrap_peers are usually
hand-written, everything else has a sensible default:
swarm_key: "/key/swarm/psk/1.0.0/\n/base16/\n<hex...>"
bootstrap_peers:
- "/ip4/198.51.100.10/tcp/7777/p2p/12D3KooW..."
Missing or invalid keys are surfaced loudly at startup β the daemon refuses to boot rather than silently downgrade.
The diagram is a "you are here" key for the tables below β each branch maps to one of the sections on this page.
Top-level keys
| Key | Type | Default | Notes |
|---|---|---|---|
swarm_key |
string | empty | Pre-shared swarm key in /key/swarm/psk/1.0.0/ format. Empty = limited mode (no P2P stack). |
bootstrap_peers |
list of string | [] |
libp2p multiaddrs the node dials at startup to join the swarm. |
node |
object | β | Node-level identity, storage, transports. See node. |
api |
object | β | REST / Web UI listener. See api. |
chat |
object | β | Chat retention + auto-relay. See chat. |
push |
object | β | Offline push gateway role. See push. |
at_rest_encryption |
object | β | Master-key sourcing for Badger + libp2p identity. See at_rest_encryption. |
vaults |
list | [] |
Trusted vaults this node holds keys for. See vaults. |
webhooks |
list | [] |
Inbound webhooks (POST /api/v1/hooks/<token>). See webhooks. |
outbound_webhooks |
list | [] |
Outbound HTTP delivery of mesh events. See outbound_webhooks. |
port_forwards |
list | [] |
ssh -L / ssh -R style TCP/UDP forwards over the tunnel mesh. See port_forwards. |
telemetry |
object | β | Anonymous usage reporting to meshhold.com. Default on; switch off with enabled: false. See telemetry. |
node
| Key | Type | Default | Notes |
|---|---|---|---|
id |
string | derived from identity file | Override only when migrating an identity; normally leave empty. |
name |
string | OS hostname | Display name shown to peers. |
listen_addr |
string | 0.0.0.0:7777 |
libp2p TCP listen address. Ignored when node.obfs is configured (use per-obfs ports). |
reliable |
bool | true |
Marks the node as a long-lived holder candidate for the replication scheduler. |
blocks_dir |
string | <base>/blocks |
Where convergent-encrypted blocks land on disk. |
metadata_dir |
string | <base>/meta |
Badger store + libp2p identity file. |
blocks_max_bytes |
int64 | 0 (unlimited) |
Hard cap on the blocks directory in bytes. Replication evicts above this. |
blocks_reserve_bytes |
int64 | 10 GiB |
Free-space floor on the blocks filesystem; replication starts evicting when crossed. |
replication_min_period |
duration | 20s |
Lower bound on the replication-cycle interval. Go duration string ("1s", "500ms", "2m"). |
replication_max_period |
duration | 60s |
Upper bound on the replication-cycle interval. |
holder_ttl |
duration | 24h |
How long a holder row stays authoritative without a refresh AnnounceHave. |
peer_blocklist |
list of string | [] |
libp2p peer IDs (12D3KooW...) this node refuses to dial or accept. |
public_address |
string | auto-detected | host:port override used when building share invites. Set on VPS nodes. |
mdns_enabled |
bool | true |
LAN mDNS peer discovery. Disable on hardened nodes. |
<base> is ~/.meshhold/ on Linux, %LOCALAPPDATA%\MeshHold\ on Windows.
Inside the official Docker image the daemon runs from
WORKDIR /var/lib/meshhold as uid 65532, so <base> is that directory
and the equivalent defaults become /var/lib/meshhold/blocks and
/var/lib/meshhold/meta. Mount a host path or named volume there to
persist state across container restarts.
Running in Docker
The image is gcr.io/distroless/static-debian12:nonroot underneath, so
there is no shell, no package manager, and no system service manager
inside the container β just the meshhold daemon process holding pid 1.
A few container-only knobs that don't apply to the .deb / .rpm install:
| Knob | Default | Notes |
|---|---|---|
| Container user | nonroot (uid 65532, gid 65532) |
Built into the base image. Volumes you bind-mount must be readable + writable by this uid. |
WORKDIR |
/var/lib/meshhold |
Where the daemon reads blocks/ and meta/ from by default. |
| Config path | /etc/meshhold/config.yaml |
Hardcoded into the ENTRYPOINT. Bind-mount your own with -v $PWD/config.yaml:β¦:ro. |
MESHHOLD_PASSWORD env |
empty | Idempotent first-run auth bootstrap: stored as a bcrypt hash only if Badger has none yet. Subsequent runs ignore it silently β safe to leave in a long-lived compose file. |
EXPOSE |
7777/tcp, 8080/tcp |
Documentation only; you still need -p 7777:7777 -p 8080:8080 (or a reverse proxy in front of 8080). |
| Log format | --log-format=json (forced) |
Cooperates with docker logs JSON parsers and shippers (Loki, Vector). Override by appending your own args after the image name. |
The image does not include the systemd unit, the meshhold system
user, or the postinstall password-bootstrap script β those are owned by
the .deb / .rpm packagers. Inside Docker, the container runtime is
your service manager, the named-volume + uid mapping is your file-system
permission model, and the MESHHOLD_PASSWORD env is your auth bootstrap.
node.obfs β masquerade transports
The plain TCP listener is on by default. REALITY and SSH masquerades are opt-in.
Inbound and outbound are independent: a peer reaches you via any listener
you turn on, and your own outbound dialer walks node.obfs.order in
sequence to pick a transport per peer.
| Key | Type | Default | Notes |
|---|---|---|---|
node.obfs.plain.enabled |
bool | true |
Plain TCP listener. |
node.obfs.plain.port |
int | port of node.listen_addr |
TCP port. Falls back to listen_addr's port if unset. |
node.obfs.reality.enabled |
bool | false |
TLS-REALITY listener. |
node.obfs.reality.port |
int | 0 |
TCP port. |
node.obfs.reality.dest |
string | empty | Upstream the listener forwards unauthenticated TLS handshakes to (e.g. www.microsoft.com:443). |
node.obfs.reality.private_key_file |
string | auto-generated | X25519 server secret path. Created on first start when missing. |
node.obfs.ssh.enabled |
bool | false |
SSH-masquerade listener. |
node.obfs.ssh.port |
int | 0 |
TCP port. |
node.obfs.ssh.banner |
string | "SSH-2.0-OpenSSH_9.6\r\n" |
Banner override. |
node.obfs.order |
list of string | ["plain", "reality", "ssh"] |
Outbound dial priority. Put obfuscated transports first in censored environments. |
node.relay β libp2p Circuit Relay v2 / AutoNAT
Defaults match the "client behind home NAT" profile. Public-reachable nodes
flip serve (and usually nat_service) on.
| Key | Type | Default | Notes |
|---|---|---|---|
node.relay.serve |
bool | false |
Accept relay reservations from NAT'd peers. |
node.relay.nat_service |
bool | mirrors serve |
Run AutoNAT dial-back probes for other peers. |
node.relay.auto_dial |
bool | true |
Reserve relay slots automatically when this node detects it's behind NAT. |
node.upnp β automatic IGD port mapping
UPnP IGD (and the legacy NAT-PMP fallback) lets the daemon install
port mappings on the LAN router so peers can dial this node
directly instead of relying on a public relay. At startup the
daemon SSDPs the LAN for an Internet Gateway Device, maps each
libp2p listen port, and injects /ip4/<wan>/tcp/<port> into the
multiaddrs Identify advertises to peers. Per-forward mappings are
also driven through this subsystem when an entry has
open_via_upnp: true.
CGNAT-aware: the router-reported WAN IP is compared against
/api/ipinfo (see node.ipinfo). When they disagree, the daemon
keeps the LAN-side mapping (it can still help mesh peers behind
the same NAT) but suppresses the external-multiaddr announcement
to avoid sending peers a dead-end IP.
| Key | Type | Default | Notes |
|---|---|---|---|
node.upnp.enabled |
bool | true |
Toggles the subsystem. false means the daemon never probes the LAN. |
node.upnp.lease_seconds |
int | 7200 |
Requested mapping lifetime; refresh runs at lease_seconds / 2. |
node.upnp.discover_timeout_seconds |
int | 6 |
Caps the SSDP probe so a LAN without UPnP doesn't drag daemon startup. |
node.ipinfo β external IP + country lookup
At startup (and after any UPnP remap) the daemon does a single
GET /api/ipinfo against the MeshHold marketing site to learn its
own public IP + country code. The values populate the topology
gossip beat (so peers see the IP / flag on their Network page) and
gate the CGNAT comparison described above. Operators in air-gapped
environments turn this off; everyone else can leave it alone.
| Key | Type | Default | Notes |
|---|---|---|---|
node.ipinfo.enabled |
bool | true |
Set false to never call out. |
node.ipinfo.url |
string | https://meshhold.com/api/ipinfo |
Mirror this onto your own deployment if you don't want to use the public endpoint. |
node.ipinfo.timeout_seconds |
int | 5 |
Per-call HTTP timeout. |
node.ipinfo.cache_seconds |
int | 21600 (6 h) |
How long a successful lookup is cached before re-querying. |
node.s3 β S3-compatible listener
Disabled by default. Even when enabled, the admin REST surface
(/api/v1/s3/keys, /api/v1/s3/permissions) is available so keys can be
pre-provisioned.
| Key | Type | Default | Notes |
|---|---|---|---|
node.s3.enabled |
bool | false |
Toggles the listener. |
node.s3.listen_addr |
string | 127.0.0.1:3900 |
Bind address. Loopback default to prevent accidental exposure. |
node.s3.region |
string | meshhold |
Echoed in error responses and used as Sig v4 scope. Clients must match. |
node.s3.max_put_bytes |
int64 | 67108864 (64 MiB) |
Single-PUT cap; larger uploads must use multipart. |
node.s3.base_domain |
string | empty | When set, enables virtual-hosted-style addressing (<bucket>.<base_domain>). |
node.call_media β headless camera/mic
For Raspberry-Pi-class nodes that auto-answer calls and stream from v4l2 + ALSA.
| Key | Type | Default | Notes |
|---|---|---|---|
node.call_media.enabled |
bool | false |
Master switch. Both video_devices and audio_device empty + enabled β off. |
node.call_media.video_devices |
list of string | [] |
v4l2 paths (e.g. /dev/video0); index 0 used at call start, CameraControl{NEXT} rotates. |
node.call_media.audio_device |
string | empty | ALSA device name (default, hw:0,0). Empty = video-only. |
node.enrich β metadata enrichment
Disabled by default β operators turn this on for one node per LAN so AcoustID / TMDB lookups don't get duplicated.
node.enrich.music
| Key | Type | Default | Notes |
|---|---|---|---|
node.enrich.music.enabled |
bool | false |
Worker only constructs when true. Ingest enqueues unconditionally. |
node.enrich.music.acoustid_api_key |
string | empty | Required when enabled. Free at acoustid.org/api-key. Falls back to MESHHOLD_ACOUSTID_API_KEY. |
node.enrich.music.user_agent |
string | project default | UA header sent to MusicBrainz. |
node.enrich.music.fpcalc_path |
string | $PATH |
Chromaprint binary location. |
node.enrich.music.poll_interval_seconds |
int | 30 |
How often the worker sweeps the enrich queue. |
node.enrich.video
| Key | Type | Default | Notes |
|---|---|---|---|
node.enrich.video.enabled |
bool | false |
Master switch. |
node.enrich.video.tmdb_api_key |
string | empty | Falls back to the catalog settings keystore, then MESHHOLD_TMDB_API_KEY. |
node.enrich.video.language |
string | en-US |
BCP-47 tag TMDB localises titles + overviews to. |
node.enrich.video.user_agent |
string | project default | UA header sent to TMDB. |
node.enrich.video.poll_interval_seconds |
int | 30 |
Worker sweep interval. |
node.enrich.video.top_cast |
int | 10 |
Cast members written to each VideoMeta row. |
node.enrich.video.ffprobe_path |
string | $PATH |
Used by the on-open track scanner; independent of enabled. |
node.enrich.video.ffmpeg_path |
string | $PATH |
Used for server-side audio-track remux + embedded-subtitle extraction. |
api
| Key | Type | Default | Notes |
|---|---|---|---|
api.listen_addr |
string | 0.0.0.0:8080 |
REST + Web UI listener. |
api.tls.acme_domain |
string | empty | When set, the daemon obtains a Let's Encrypt cert for this domain via HTTP-01. |
api.tls.cert_file |
string | empty | PEM-encoded certificate path. Both cert_file + key_file must be set together. |
api.tls.key_file |
string | empty | PEM-encoded private key path. |
Leave all three TLS fields empty to fall back to a self-signed certificate generated on first run.
chat
| Key | Type | Default | Notes |
|---|---|---|---|
chat.retention_days |
int | 30 |
Caps how long incoming chat messages are held. 0 disables the cap ("store forever"). All messages get clamped at receive time. |
chat.auto_relay |
bool | true on desktop, false on mobile |
When on, the node subscribes to + persists ciphertext for unknown rooms it sees, bounded by retention_days. |
push
The gateway role is the only configurable side. Recipient endpoints live in
the user's ProfileDoc.
| Key | Type | Default | Notes |
|---|---|---|---|
push.gateway.enabled |
bool | false |
When true, the daemon advertises the push-gateway capability and runs the push loop. |
push.gateway.transport |
string | unifiedpush |
unifiedpush, fcm, or empty to compose every wired transport into a MultiSender dispatched by recipient endpoint type. |
push.gateway.fcm_service_account_file |
string | empty | Path to a Google service-account JSON. Missing / invalid logs a warning and disables FCM β UnifiedPush-only operators aren't punished. |
push.gateway.silence_window |
duration | 45s |
How long the gateway waits after a chat message before deciding the recipient hasn't picked it up via gossip. |
push.gateway.tick_period |
duration | 10s |
Sweep interval for the pending_push table. Should be materially smaller than silence_window. |
at_rest_encryption
Master-key sourcing for the daemon's at-rest encryption layer (Badger,
libp2p identity, networks store). The zero value (enabled: false) is the
safe default for headless installs without a platform keystore.
passphrase_file always wins when set, so you can opt out of the platform
keystore on a desktop install if you'd rather feed the secret yourself.
Failure to obtain a key with enabled: true is a hard startup error β
the daemon never silently downgrades to plaintext storage.
| Key | Type | Default | Notes |
|---|---|---|---|
at_rest_encryption.enabled |
bool | false |
When true the daemon obtains a master key from the configured source. Failure to obtain the key is a hard startup error β no silent downgrade to plaintext. |
at_rest_encryption.passphrase_file |
string | empty | When non-empty, forces the passphrase-file provider regardless of any available platform keystore. Use it on headless servers where systemd-creds / Vault / Kubernetes mounts deliver the passphrase. |
at_rest_encryption.salt_file |
string | derived | Per-host KDF salt path. Empty defaults to <dirname(passphrase_file)>/master.salt. |
Mobile / desktop builds flip enabled: true and rely on the platform keystore
(Keychain / Credential Manager / libsecret).
vaults
A list of trusted vaults β the node holds the symmetric key for each entry and decrypts file metadata locally.
| Key | Type | Default | Notes |
|---|---|---|---|
vault_id |
string | required | Stable 32-byte vault identifier. |
name |
string | required | Display name. |
key |
string | required | Vault encryption key (URL-safe base64). |
storage_path |
string | <blocks_dir>/<vault_id> |
Filesystem path for materialised files when full_sync: true. |
full_sync |
bool | false |
When true, the node materialises every file in the vault onto disk under storage_path. false keeps blocks only. |
replication_factor |
int | 3 |
Target number of holders the replication scheduler aims for. |
type |
string | storage |
storage (default) for file vaults, chat for chat rooms. Crypto + trust model identical between types. |
ingest_coalesce_seconds |
int | 60 |
Window inside which a fresh local edit replaces the previous FileVersion row in filehistory/. 0 = every edit appends a new row. Must be >= 0. |
hard_quota_bytes |
int64 | 0 (none) |
Cap on the vault's disk footprint. Uploads past the cap are rejected with HTTP 507. |
soft_quota_bytes |
int64 | 0 (none) |
Informational threshold flagged in analytics. Must be <= hard_quota_bytes when both are set. |
webhooks
Inbound webhooks accepted on POST /api/v1/hooks/<token> (and GET for
trigger-style integrations). The URL token is the only credential.
| Key | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Unique slug used for logs / audit. Not part of the URL. |
token |
string | required | URL-path secret. Unique across the list, >= 8 chars, no slashes or whitespace. |
action |
string | required | call or message. |
target_node_id |
string | β | libp2p peer ID of the callee. Required for action: call. Must be empty for action: message. |
video |
bool | false |
When true the call is audio+video; only meaningful for action: call. |
room_id |
string | β | Chat-vault ID the message is published into. Required for action: message. Must be empty for action: call. |
text |
string | empty | Fixed message text β request body is ignored when set. Only meaningful for action: message. |
fallback_text |
string | empty | Used when text is empty and the request body yielded no extractable text. Empty fallback + empty extracted text β 400. |
Bodies are parsed as Slack-shape JSON, application/x-www-form-urlencoded
with payload=<json>, or text/plain. There is no rate-limit and no
loopback bind β operators put a reverse proxy in front when needed.
outbound_webhooks
Outbound HTTP delivery of mesh events. Bodies are unsigned JSON; operators who want auth put the receiver behind a reverse proxy.
| Key | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Unique slug used as the queue-row tag. |
url |
string | required | http:// or https:// only. |
events |
list of string | required, non-empty | Subscribed event names. Typos are rejected at load. |
give_up_after_seconds |
int | 86400 (24 h) |
Retry budget cap. 0 disables retries (one shot, drop on failure). Negative is rejected. |
Known event names: file.added, file.updated, file.deleted,
chat.message, peer.connected, peer.disconnected, tunnel.opened,
tunnel.closed, replication.lag, forward.opened, forward.closed.
The retry schedule is 5s β 30s β 5min β 30min, then capped.
port_forwards
ssh -L / ssh -R style TCP/UDP forwards routed over the libp2p tunnel
mesh. Inherits multi-hop routing and libp2p Circuit Relay v2.
forward and reverse differ only in which side of the tunnel binds the
listener and which side dials the actual destination. The two examples
below at the bottom of this page show one of each.
| Key | Type | Default | Notes |
|---|---|---|---|
id |
string | derived | Opaque handle. Empty in hand-edited YAML β deterministic ID derived from name. |
name |
string | required | Human-readable label, unique across the list. |
direction |
string | required | forward (ssh -L: bind locally, dial peer-side) or reverse (ssh -R: peer binds, dials back here). |
proto |
string | required | tcp or udp. |
listen_addr |
string | required | Bind address on whichever side actually listens. :port is filled with 127.0.0.1 for forward / 0.0.0.0 for reverse. |
remote_addr |
string | required | Dial destination on whichever side does the dialing. Must split into host:port. |
peer_node_id |
string | required | libp2p peer ID of the counter-party. |
peer_key_id |
string | required | Name of the peer mgmt key in this node's store. Tunnel refuses to come up without an authorised key. |
route |
list of string | [] |
Explicit hop list. Empty β topology BFS picks the path. |
autostart |
bool | true |
When false, the entry stays in config but dormant until POST /forwards/:id/start. |
open_via_upnp |
bool | false |
Ask the listener-side router to forward the port through UPnP. For forward this is the local router; for reverse it's the peer's router. Best-effort: a router without UPnP just leaves the listener LAN-only. Requires node.upnp.enabled (the default). |
telemetry
Anonymous, low-volume usage reporting. Enabled by default β collects a once-a-day beat containing your country, a hash of your swarm key, and counters for vault/chat/call/tunnel usage. The full field list and the rationale live in privacy-telemetry; this section only covers the YAML knobs.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
Master switch. false skips all telemetry β no HTTP requests are made, no counters are kept in memory. |
endpoint |
string | https://meshhold.com/api/telemetry/beat |
Override only if you run a private aggregator (e.g. an internal MeshHold fleet that should not report to the public site). |
interval |
duration | 24h |
Period between beats. Go duration string ("1h", "6h", "24h"). A Β±10% jitter is applied automatically so installs do not all hit the endpoint at the same minute. |
include_usage_stats |
bool | true |
When false, only the minimum (network_hash, country_code, version, os, arch) is sent. Useful if you want to be counted but do not want to share feature usage. |
The two convenience CLI commands meshhold telemetry enable /
meshhold telemetry disable rewrite this block in place; calling
them is equivalent to setting enabled by hand and saves you the
file edit.
Example: VPS exit node
A public-reachable VPS that serves as a relay, runs the push gateway, and exposes the S3 listener over loopback (for a local Caddy reverse proxy):
swarm_key: "/key/swarm/psk/1.0.0/\n/base16/\n<hex...>"
bootstrap_peers: [] # this IS the bootstrap
node:
name: "exit-fra-01"
listen_addr: "0.0.0.0:7777"
reliable: true
public_address: "exit.example.org:7777"
relay:
serve: true
s3:
enabled: true
listen_addr: "127.0.0.1:3900"
base_domain: "s3.example.org"
enrich:
music:
enabled: true
acoustid_api_key: "..."
video:
enabled: true
tmdb_api_key: "..."
api:
listen_addr: "0.0.0.0:8080"
tls:
acme_domain: "node.example.org"
chat:
retention_days: 90
push:
gateway:
enabled: true
silence_window: "45s"
outbound_webhooks:
- name: "ops-slack"
url: "https://hooks.slack.com/services/..."
events: ["replication.lag", "peer.disconnected"]
Example: home gateway with port forwards
Expose a LAN game server to the Internet through a VPS peer:
port_forwards:
- name: "zomboid-public"
direction: "reverse"
proto: "tcp"
listen_addr: "0.0.0.0:16261" # VPS-side bind
remote_addr: "192.168.1.50:16261" # LAN server
peer_node_id: "12D3KooW..."
peer_key_id: "vps-tunnel"
# Ask the VPS's host network to expose the port through UPnP
# too. On a cloud VM this is usually a no-op (no IGD), but
# on a colo / bare-metal exit it can save a manual port-forward
# configuration step.
open_via_upnp: true
- name: "openvpn-into-home"
direction: "forward"
proto: "udp"
listen_addr: "127.0.0.1:1194" # local bind
remote_addr: "192.168.1.1:1194" # remote LAN OpenVPN
peer_node_id: "12D3KooW..."
peer_key_id: "home-tunnel"
Reloading
Most fields require a daemon restart. The exceptions are managed through the
REST surface and intentionally not in config.yaml:
- Vault membership:
POST /api/v1/vaultswrites to Badger, not the YAML. - Management keys:
meshhold mgmt-keys/MgmtKeysPanelin the Web UI. - S3 access keys + per-bucket permissions:
/api/v1/s3/keys,/api/v1/s3/permissions. - TMDB API key, when set via the Web UI Settings page, lives in the catalog
settings keystore and overrides
node.enrich.video.tmdb_api_key.
When in doubt, edit config.yaml, run meshhold validate-config to
catch typos, then systemctl restart meshhold (or the equivalent on your
platform).