Self-hosting

The quickstart runs a control plane on loopback over plaintext, which is fine for seeing the shape of it and nothing else. This is the version you would put in front of real devices.

Three pieces: a control plane (needs PostgreSQL and a certificate), one or more relays (need a UDP port and nothing else), and an agent on every device.

The install script deliberately does not set up the first two. They need a database, a certificate, and decisions about who may reach them, and a script that guessed at any of that would be configuring a security boundary on your behalf.

What runs where

The control plane is never in the data path. It decides what should be true and hands that to agents; traffic between devices does not pass through it, and it can be down without anybody losing connectivity — agents keep the state they last applied and keep converging on it. Relays carry ciphertext they cannot read.

That shapes the sizing: the control plane wants a small VM and a reliable database; relays want bandwidth and a stable address.

The control plane

Database

PostgreSQL 14 or later. The control plane applies its own migrations on start, so an empty database is all it needs — give it a user that owns its own schema.

MESHP_DATABASE_URL=postgres://meshp:[email protected]:5432/meshp?sslmode=require

It is the sole source of truth for desired state, so back it up like one. Presence and health are deliberately not stored here — they are high-frequency and ephemeral (ADR-0012) — so a restore loses observations rather than configuration.

Secrets

MESHP_SECRET_KEY=<openssl rand -base64 32>
MESHP_ADMIN_TOKEN=<openssl rand -base64 32>

MESHP_SECRET_KEY is the master secret that enrolment challenges and session signatures are derived from. The control plane refuses to start without it. Changing it invalidates every outstanding challenge and session, so agents reconnect — survivable, but not something to do casually.

MESHP_ADMIN_TOKEN is the bootstrap secret, and it now has exactly one job: getting you back in when nobody else can — the last owner has left, or everybody has forgotten their password — because a self-hosted deployment has no support line to call.

It used to have two. Creating the first account was the other, and meshp-control --bootstrap does that against the database instead, so a deployment can run without ever setting this:

meshp-control --bootstrap --organisation acme --email [email protected]

It asks for a password on the terminal, makes the first person an owner, and can print an API token if you name the permissions it should carry. It refuses to run on a deployment that already has accounts, and adopts an organisation that already exists rather than refusing.

It is not the credential to run anything with. It is a single shared secret with no identity, so the audit trail can only ever record that "the bootstrap secret" did something; it cannot be scoped to one network; and it cannot be revoked without locking out whatever else was using it. Treat it as a root credential for every network on this control plane, keep it somewhere you would keep a root password, and use an account for everything else.

Once an account exists, every use of it is logged at warning level. That line means something is still reaching for the shared secret when it could be using an account — an old script, usually. It is throttled to at most one an hour, so it is a nudge rather than noise.

Leaving it unset is supported and is the right end state for a deployment whose people all have accounts. What you lose is the way back in, so do not unset it until somebody other than you holds the owner role, and be aware that a deployment with no accounts and no bootstrap secret cannot be administered at all without editing the database.

Accounts, roles and API tokens

Every person gets an account, and what they can do is what their role allows. The four built-in roles nest:

Role What it is for
reader Sees everything this control plane shows and changes nothing.
operator Adds and removes devices, and moves traffic between advertisers when a link dies. Cannot change the access policy, DNS, or which routes exist.
administrator Configures networks completely. Cannot create accounts, grant roles, or revoke anybody else's credentials.
owner Everything, including deciding who may act.

A role can be granted across the whole organisation, or narrowed to a single network — which is what a technician who looks after one customer needs. A grant narrowed to a network never carries an organisation-wide permission, so somebody given a role inside one network cannot create accounts across the organisation it belongs to.

GET /api/v1/permissions lists the catalogue; GET /api/v1/organizations/{id}/roles lists what this deployment can grant.

Scripts and CI use API tokens, not passwords and not the bootstrap secret. A token belongs to the person who minted it, carries the permissions they name — intersected with what they hold at the time it is used, so demoting somebody demotes their tokens — and is revoked on its own without touching their password. Mint one at POST /api/v1/me/tokens, list them at GET /api/v1/me/tokens, and revoke one at DELETE /api/v1/me/tokens/{id}. An owner can see and revoke everybody's at /api/v1/organizations/{id}/tokens, which is what you want the day somebody leaves.

Tokens expire: ninety days by default, a year at most, and there is no unexpiring one. That is deliberate — a bearer secret that never expires outlives the reason it was made.

Running more than one control plane

One meshp-control is a single point of failure. More than one works, and needs one setting:

MESHP_REPLICAS=2

That is a statement about the deployment rather than a count anything verifies — nothing in a replica knows how many others exist. What it decides is whether the replica connects to the bus that carries "network N changed" between them (ADR-0025), which is PostgreSQL's own LISTEN/NOTIFY. There is no second data store to run: redundancy costs you a second container and nothing else.

Point every replica at the same PostgreSQL and put them behind whatever already balances your traffic. They share no state of their own; PostgreSQL is the source of truth and always was.

Two things are worth knowing before you rely on it.

An agent holds a session with one replica at a time, so a replica going away drops its agents and they reconnect — to whichever replica the balancer gives them, with no loss of configuration. What they lose is the seconds it takes to notice.

Whether a device is connected is shared; what it last reported is not. Every replica knows which devices have a control session open, because that is recorded when a session opens and closes. What a device says on each heartbeat — the version it applied, what its tunnel is doing — stays in the replica that received it, which is deliberate: writing it where every replica could see it would mean a database write per heartbeat per device (ADR-0012).

So the overview shows every connected device, and for the ones attached to another replica it says reported to another replica in place of tunnel detail rather than implying the device has gone quiet.

Leave it at 1, or unset, for a single-replica deployment. A replica that connects to the bus when there is nobody to talk to holds a database connection open for nothing.

Taking a relay out of service

MESHP_RELAYS names your relays and still does. What it cannot express is "stop sending new sessions here", which is what you want before turning a machine off.

curl -fsS -X PUT -H "Authorization: Bearer $MESHP_BOOTSTRAP" \
  -H 'Content-Type: application/json' -d '{"state":"draining"}' \
  http://localhost:8080/api/v1/relays/relay1/state

GET /api/v1/relays lists them with their states. Three states:

active agents are told about it
draining agents are not told about it; sessions already using it keep working
disabled agents are not told about it, and it is not expected back

Draining does not move anything. A drained relay is still running and still carrying what it has; what stops is new sessions being pointed at it. It empties as devices reconnect for their own reasons, which is gentle and is not quick.

Nothing here reports when it is empty. Relays do not check in, so the control plane knows which relays exist and not whether any traffic is flowing through them. Judging when it is safe to switch a drained relay off is yours to do, and last_seen_at in the listing is always null for the same reason. That is the remaining half of #128.

A restart does not undo a drain. Endpoints are refreshed from MESHP_RELAYS on every boot; state is not, because a restart quietly putting a relay back into service is the failure this exists to remove. Removing a relay from MESHP_RELAYS removes it from the list entirely.

TLS

Agents refuse a plaintext control URL to anything but loopback, so a control plane serving real devices needs a certificate. Either supply one:

MESHP_TLS_CERT=/etc/meshp/tls/fullchain.pem
MESHP_TLS_KEY=/etc/meshp/tls/privkey.pem

or have it obtain one from Let's Encrypt:

MESHP_TLS_DOMAINS=control.example.com

Port 443 is enough. The challenge is answered over TLS itself (TLS-ALPN-01), so a host that only accepts 443 can still obtain and renew a certificate — which is worth knowing, because some providers firewall port 80 by default and the failure would otherwise look like a DNS problem. Port 80 is used when it is reachable, both for the other challenge type and to redirect anyone who types http://.

The certificate has to be saved somewhere that survives a restart, or every restart asks Let's Encrypt again and a crash loop exhausts a week's rate limit in an afternoon. The unit below handles this: StateDirectory=meshp-control gives the service /var/lib/meshp-control, which is where MESHP_TLS_CACHE_DIR points by default. Set it only if you want it elsewhere — and if you do, make sure the unit can write there, because ProtectSystem=strict leaves everything else read-only.

Terminating TLS at a reverse proxy instead is a perfectly good choice. The systemd unit does not force either way; it just has CAP_NET_BIND_SERVICE so it can hold 443 without running as root.

Relays

Tell the control plane where its relays are, and give it the private half of a signing key:

meshp-control --generate-relay-key      # prints a keypair; use it once, keep the output
MESHP_RELAY_SIGNING_KEY=<the private half>
MESHP_RELAYS=lon1=relay1.example.com:51820;fra1=relay2.example.com:51820

Several addresses per relay are allowed — id=host:port,host:port — and agents try them in order, so put the port most likely to get through a hostile network first.

Without these, the control plane starts and warns that peers will know about each other and be unable to reach each other. That is not a soft failure to skim past: nothing discovers direct paths yet, so a deployment with no relay has no data plane.

Running it

sudo install -m 0755 meshp-control /usr/local/bin/
sudo useradd --system --no-create-home meshp
sudo install -d -o meshp -g meshp /etc/meshp
sudo install -m 0600 -o meshp -g meshp control.env /etc/meshp/control.env
sudo install -m 0644 systemd/meshp-control.service /etc/systemd/system/
sudo systemctl enable --now meshp-control

The unit runs unprivileged and reads its configuration from an owner-only environment file rather than from the unit itself, which keeps the database URL, the secret key and the admin token out of systemctl show.

Check it with /readyz, which reports the database and the schema — so it answers "can this serve agents" rather than "is the process alive". /healthz is the liveness probe.

A relay

A relay needs the public half of the signing key and a UDP port. It verifies a capability it cannot mint, so a compromised relay cannot invent access — and it only ever sees ciphertext (ADR-0016, ADR-0017).

MESHP_RELAY_ISSUER_KEY=<the public half>
MESHP_RELAY_LISTEN=:51820
MESHP_RELAY_ADMIN_ADDR=127.0.0.1:9090
sudo install -m 0644 systemd/meshp-relay.service /etc/systemd/system/
sudo systemctl enable --now meshp-relay

Keep the admin address on loopback. It is diagnostics, not an API anyone else should reach.

Devices

curl -fsSLO https://raw.githubusercontent.com/meshpnet/meshp/main/scripts/install.sh
less install.sh && sudo sh install.sh

Then start it the way this machine starts services, and join:

# Linux
sudo systemctl enable --now meshpd
# macOS
sudo install -m 0644 -o root -g wheel deploy/launchd/net.meshp.meshpd.plist /Library/LaunchDaemons/
sudo launchctl bootstrap system /Library/LaunchDaemons/net.meshp.meshpd.plist
sudo meshp join <token> --control-url https://control.example.com

The Mac daemon is loaded into the system domain rather than a user's, because creating a utun goes through PF_SYSTEM and needs root — and because a device on a network should not stop being on it when somebody logs out. sudo launchctl kickstart -k system/net.meshp.meshpd restarts it, and sudo launchctl bootout system/net.meshp.meshpd stops it for good.

Its log goes to /var/log/meshpd.log, written by the daemon rather than redirected by launchd, and rotated at 4MB keeping two previous files — so a device bounds what it spends on describing itself. That is not the obvious arrangement and the obvious one does not work: newsyslog rotates by renaming, a rename does not move an open file descriptor, and a file launchd is holding would go on receiving output under its new name while the path everybody reads stayed empty. /var/log/meshpd.err keeps whatever never reaches the log at all, such as a panic.

# Windows, as Administrator. wintun.dll ships beside meshpd.exe (ADR-0028).
sc.exe create meshpd binPath= "C:\Program Files\meshp\meshpd.exe" start= auto
sc.exe start meshpd

The space after binPath= and start= is sc.exe's own syntax and is not a typo; without it the command is rejected. The service logs to meshpd.log inside its state directory, because a Windows service has no console and anything written to stderr is discarded; it is rotated the same way as on macOS. --log-file puts it somewhere else. sc.exe stop meshpd stops it and sc.exe delete meshpd removes it.

The agent runs as root because it creates a WireGuard interface, writes routes and loads firewall rules. meshp does not: it is a thin client that asks the daemon over a local unix socket, and the daemon is what generates and stores key material. A private key never crosses that socket in either direction.

That socket is owner-only, so meshp needs sudo too. meshpd --socket-group <group> relaxes it to a group — read that as the privilege grant it is, because anything able to reach the socket can enrol this device and decide where its traffic goes.

Upgrading

Agents and the control plane are versioned independently and the protocol is backward-compatible within a major version, so the order does not matter much. Upgrade the control plane first if you want new server-side behaviour available when agents arrive asking for it.

An agent that has been offline longer than MESHP_DELTA_RETENTION (72 hours by default) is sent a full snapshot rather than a delta when it returns. That is correct but expensive, so the setting is the trade-off between the size of the change log and how cheap a reconnection is after a weekend or a closed laptop.

What to watch

  • /readyz on the control plane. It goes unready when the database is unreachable or the schema is behind, which are the two states where agents cannot be served.
  • Convergence lag. The gap between a network's desired version and what each device has applied. A device that never closes the gap is a broken agent; a network where the gap is widening is a broken control plane.
  • Certificate expiry, if you are supplying your own.

What is not here yet

Users, roles and sessions — the admin API is one shared token. Multi-tenant management, white-label branding and billing live in the hosted service rather than here (ADR-0009); the boundary is an API rather than a licence, and nothing in this repository is crippled to create it.

A web dashboard. Everything above is the API, because the API is all there is today.

Rendered from docs/self-hosting.md, which is where it is edited.