Skip to content

Site-to-site VPN

A VPN gateway connects your organization's b'nerd resources to a remote site — your office, a data center, another cloud — over an encrypted tunnel. b'nerd supports two protocols: IPsec (IKEv2, pre-shared key) and WireGuard. Both are managed through the same organization-scoped API, dashboard, and CLI surface.

Org-admin only, except individual devices

Every gateway and site-to-site peer operation — creating or deleting a gateway, and creating, updating, or deleting a peer — requires the Admin role on the organization. A Member account gets 403 regardless of its permission grants; a caller with no membership in the organization gets 404.

Devices (road-warrior) are the one exception: any Member may enrol, list, and revoke their own device. Org-admins get a wider view (every member's devices) and one extra write (narrowing a device's access) — see that section for the exact split.

Not yet available at this release

  • Gateway failover. A gateway — IPsec or WireGuard — runs as a single instance on one node; there is no active/standby failover for either protocol. If that node fails, the platform re-creates the gateway on a healthy node and every tunnel re-establishes; expect an interruption while that happens.
  • Outbound exposures to your own cluster. An outbound exposure can target a platform-provided managed app today; targeting a service in a cluster of your own needs that cluster to be linked to the VPN cluster by b'nerd, and no customer cluster is linked at this release — the API refuses such an exposure with 422 and the code cluster_not_linked.
  • DNS inside the tunnel. Traffic is routed by subnet/virtual IP; the platform does not resolve names for you across the tunnel. This applies to road-warrior devices too.

What a VPN gateway is

A gateway is an organization-owned resource with exactly one protocolipsec or wireguard — chosen at creation and immutable afterward. If you need both, create two gateways, one per protocol.

Every gateway is reconciled onto b'nerd's own shared VPN cluster and gets a dedicated public IP once reconciled (status.floating_ip). Your organization does not manage the underlying node, only the gateway's configuration, peers, and exposures.

Exposures make something reachable through the tunnel, by a virtual IP inside the gateway's own service CIDR (status.service_cidr):

  • an outbound exposure targets a platform-provided managed app (or, once b'nerd has linked one of your clusters, a service in that cluster — see the note above);
  • an inbound ("reverse") exposure targets a host on the remote side of the tunnel instead.

Each peer's authorization names which exposures — and which reverse exposures — that specific peer may reach:

"authorization": { "exposures": ["keycloak"], "reverseExposures": [] }

Access is deny by default: a peer with no matching entry in authorization cannot reach the exposure, even though the tunnel itself is up.

By default an organization may have 2 gateways total — enough for one IPsec plus one WireGuard gateway. Contact support to raise the limit.

IPsec site-to-site

An IPsec gateway speaks IKEv2 with a pre-shared key. Gateway-level crypto settings (ike.{version,proposals,lifetime,dpd.{delay,timeout}}) and the gateway's serviceCIDR are set at create time and are not customer- editable afterward in this release.

Create a gateway

bnerd vpn gateways create hq-site \
  --gateway-config '{"serviceCIDR":"10.42.0.0/24"}'
POST /organizations/{org}/vpn_gateways
Authorization: Bearer <token>
Content-Type: application/json

{ "vpn_gateway": { "name": "hq-site", "protocol": "ipsec", "config": { "serviceCIDR": "10.42.0.0/24" } } }

name and protocol are both required on the API — there is no default and no NULL protocol. The CLI defaults --protocol to ipsec, so it can be omitted there.

Add a peer

A peer's IPsec configuration is CRD-shaped: ESP proposals, the remote peer's address and IKE identity, and the subnets routed behind it. Dashboard: on the gateway, Add peer with ESP proposals, Peer address, Peer ID, Remote subnets, and PSK.

The CLI has a named flag for each field:

echo -n "$PSK" | bnerd vpn peers create hq-site office \
  --esp-proposals aes256-sha256-modp2048 \
  --peer-address 203.0.113.5 \
  --peer-id office.example.com \
  --remote-subnets 10.20.2.0/24 \
  --psk -

--peer-config still exists for the full JSON shape (inline or @file), but it cannot be combined with the flags above. A JSON object with no top-level ipsec/wireguard key is treated as the ipsec block and wrapped automatically; a JSON object that already has a protocol key is passed through as-is:

bnerd vpn peers create hq-site office \
  --psk-file ./office.psk \
  --peer-config '{"esp":{"proposals":["aes256-sha256-modp2048"]},
                   "peer":{"address":"203.0.113.5","id":"office.example.com"},
                   "remoteSubnets":["10.20.2.0/24"]}' \
  --authorization '{"exposures":["keycloak"]}'
POST /organizations/{org}/vpn_gateways/{gw}/vpn_peers
Authorization: Bearer <token>
Content-Type: application/json

{
  "vpn_peer": {
    "name": "office",
    "config": {
      "ipsec": {
        "esp": { "proposals": ["aes256-sha256-modp2048"] },
        "peer": { "address": "203.0.113.5", "id": "office.example.com" },
        "remoteSubnets": ["10.20.2.0/24"]
      }
    },
    "authorization": { "exposures": ["keycloak"] },
    "psk": "…"
  }
}

psk is write-only: it is accepted on create/update and never appears in any response, list, or log. hq validates the ipsec block on an IPsec gateway's peer — at least one ESP proposal, a peer address, a peer ID, and at least one remote subnet — and refuses an incomplete or malformed one with 422 validation_failed naming the field that failed.

WireGuard site-to-site

Create a gateway

Dashboard: Organization → VPN → New gateway, Protocol WireGuard, with an optional listen port (default 51820).

bnerd vpn gateways create site-a --protocol wireguard
# or with a non-default UDP port:
bnerd vpn gateways create site-a --protocol wireguard --listen-port 51821
POST /organizations/{org}/vpn_gateways
Authorization: Bearer <token>
Content-Type: application/json

{
  "vpn_gateway": {
    "name": "site-a",
    "protocol": "wireguard",
    "config": { "wireguard": { "listenPort": 51820 } }
  }
}

config is optional — omit it to accept the default port. Once the gateway reconciles, its detail view (dashboard, bnerd vpn gateways get, or GET /organizations/{org}/vpn_gateways/{id}) shows:

  • status.wireguard_public_key — the gateway's own WireGuard public key. It is empty until status.phase leaves declared (nothing has been reconciled onto the cluster yet). The matching private key is generated by the VPN operator on the cluster and never leaves it — b'nerd never holds it, and there is no way to retrieve it via the API or dashboard.
  • status.floating_ip — the public IP your remote site connects to.
  • listen_port — the UDP port it's listening on.
  • status.phasedeclared, ready, degraded, or error.
  • status.tunnel_health — the gateway's overall tunnel health; null when the gateway has no peers yet.

Add a site peer

Dashboard: on the gateway, Add peer with the site's public key, the subnets routed behind it, and optionally an endpoint, a persistent-keepalive interval, and a preshared key.

echo -n "$WG_PSK" | bnerd vpn peers create site-a office \
  --wireguard-public-key <44-char base64 key> \
  --subnets 10.20.2.0/24,10.20.3.0/24 \
  --endpoint 203.0.113.5:51820 \
  --keepalive 25 \
  --preshared-key -
POST /organizations/{org}/vpn_gateways/{gw}/vpn_peers
Authorization: Bearer <token>
Content-Type: application/json

{
  "vpn_peer": {
    "name": "office",
    "config": {
      "wireguard": {
        "publicKey": "…",
        "subnets": ["10.20.2.0/24", "10.20.3.0/24"],
        "endpoint": "203.0.113.5:51820",
        "persistentKeepalive": 25
      }
    },
    "authorization": { "exposures": ["keycloak"], "reverseExposures": [] },
    "wireguard_preshared_key": "…"
  }
}

The peer resource echoes back protocol and a wireguard object (public_key, subnets, endpoint, persistent_keepalive) — null for an IPsec peer, mirroring the ipsec object (esp_proposals, peer_address, peer_id, remote_subnets) an IPsec peer carries instead, which is null here. The preshared key — like the IPsec PSK — is write-only and never returned. b'nerd never holds your site's WireGuard private key; you only ever send it your site's public key.

A public key already used by another peer on the same gateway is refused with 422 validation_failed — hq enforces per-gateway uniqueness so two peers can never collide on the same WireGuard identity.

Remote-side configuration

Build your site's own wg0.conf from the values above:

[Interface]
PrivateKey = <your site's own private key>
Address = 10.20.2.1/24            # an address you choose on your side

[Peer]
PublicKey = <gateway status.wireguard_public_key>
Endpoint = <gateway status.floating_ip>:<gateway listen_port>
AllowedIPs = <gateway status.service_cidr>
PersistentKeepalive = 25
PresharedKey = <same preshared key, if you set one>

Devices (road-warrior)

A road-warrior device is a member's own laptop or phone, enrolled against a WireGuard gateway so it can join the tunnel directly — no site-to-site peer required. IPsec gateways don't have devices; enrol a WireGuard gateway for this instead (a gateway can only speak one protocol, see above).

b'nerd never holds your private key, and can never recover it

The device's WireGuard key pair is generated in your browser or by the CLI, on your own machine. Only the public key is ever sent to hq — the private key never leaves the device that generated it and is never stored, logged, or transmitted anywhere by b'nerd. If you lose it, there is no recovery: revoke the device and enrol a new one. The client configuration itself (see Fetch the client configuration) stays fetchable by its owner at any time — it's specifically the private key that cannot be recovered.

Dashboard: enrol a device

Account → VPN devices (/account/vpn-devices) lists every device you've enrolled, across every organization you belong to, and lets you enrol a new one:

  1. Pick the Organization (if you belong to more than one) and the Gateway — only WireGuard gateways you may enrol against are offered, filtered to ones that have actually finished provisioning.
  2. Name the device (e.g. bernd-laptop) and submit. The browser generates a WireGuard key pair locally and sends only the public half.
  3. A one-time reveal panel shows the composed .conf file and a QR code to scan into the WireGuard app, plus a Download button. This is your only chance to copy it — the private key isn't retained anywhere by b'nerd, so the panel can't be reopened later. Click Done, I've saved it once you have.
  4. Import the file with wg-quick up or the WireGuard app.

If no gateway is offered, no WireGuard gateway exists yet in that organization, or none has finished provisioning — ask an org-admin.

Revoking a device (from the same page) disconnects it immediately and cannot be undone; the entry stays in your list, marked revoked, so you can see when it happened.

CLI

bnerd vpn up generates a WireGuard key pair on your machine (the private half is written to <config-dir>/<device>.key, mode 0600, and never sent anywhere), enrols a device against a gateway, fetches its client configuration, and brings the local WireGuard interface up. It picks the gateway for you when the organization has exactly one ready WireGuard gateway; with more than one you have to say which. The device name defaults to this machine's hostname, sanitized into a valid label; passed explicitly with --device, it's validated instead of sanitized, so an invalid name is rejected rather than silently rewritten — the device you see in vpn devices list is always the name you asked for, never a guess at what you meant. Running vpn up again is cheap and idempotent — it finds the existing enrolment carrying the local public key and reuses it rather than enrolling a second device. Its --regenerate-keys flag is the one thing that discards the local private key: it deletes it, generates a fresh pair, and enrols a new device under that pair, leaving the old enrolment in place to be revoked separately if you don't want it anymore.

bnerd vpn down tears the local interface back down. This is purely local — the device stays enrolled, so a later vpn up reconnects without touching the API. To withdraw a device's access for good, revoke it instead.

bnerd vpn status reports whether the local tunnel is up, its endpoint, and its traffic counters.

bnerd vpn devices list lists your own devices; its --all flag is an org-admin view of every member's devices in the organization — a plain member running it gets an explanatory error, not a silently narrowed list. bnerd vpn devices revoke <device-id> revokes one (idempotent — revoking an already-revoked device isn't an error) and does not touch the local key file: that machine's private key is left exactly where it was, so re-running bnerd vpn up re-enrols it as a new device on the same key. There's no devices enrol command — enrolment only ever happens on the CLI or browser that generated the key, via vpn up or the dashboard.

If the device you're fetching a config for has since been revoked, the CLI says so and tells you to run vpn up again rather than leaving you with a bare error — your local key is kept unless you also pass --regenerate-keys.

A static access token used with bnerd vpn up needs the vpn_devices:write scope (scopes nest read < write < manage < *) and must be org-wide — gateways and devices are organization-level, so a project-pinned token is refused. A token without the scope gets 403 with the code insufficient_scope and meta.required_scope naming the scope; the CLI prints it as "this token lacks the vpn_devices:write scope; create an org-wide token with that scope".

Full flag reference

See bnerd vpn for every flag, its type and default, plus the CLI's own table of enrolment refusals and its examples.

Enrol a device (API)

POST /organizations/{organization_id}/vpn_gateways/{vpn_gateway_id}/vpn_devices
Authorization: Bearer <token>
Content-Type: application/json

{
  "vpn_device": {
    "name": "bernd-laptop",
    "wireguard_public_key": "<44-char base64 public key, generated locally>"
  }
}

Any org member may call this — enrolment is self-service. The owner is always the caller: there is no account_id field, so nobody can enrol a device "for" someone else, and an org-admin can't pre-provision one (they'd need the private key to use it anyway). hq — not the caller — pins the device's tunnel address out of the gateway's road-warrior range.

A 201 returns the created VpnDevice. Refusals:

Status errors[0].code Meaning What to do
409 gateway_not_ready The gateway hasn't reconciled a service CIDR yet Retry — the identical request succeeds once it has
422 service_cidr_unsupported This gateway's service CIDR can never host road-warrior devices Contact b'nerd — an operator has to set a range
422 exhausted Every address in the gateway's road-warrior range is taken Revoke an unused device, or contact b'nerd
422 limit_reached The gateway already holds the org's device ceiling (default 50, see Limits) Revoke an unused device, or ask an org-admin to raise the org's override
422 validation_failed Duplicate name or public key, a malformed key, or a non-WireGuard gateway Fix the field named in the error and retry
404 Gateway doesn't exist, or belongs to a different organization

List and show devices (API)

GET /organizations/{organization_id}/vpn_devices
GET /organizations/{organization_id}/vpn_devices?all=true
GET /organizations/{organization_id}/vpn_devices/{id}

By default, GET vpn_devices lists your own devices across every gateway in the organization, revoked ones included (revoked_at distinguishes them). ?all=true lists every member's devices instead and adds account_email to each — it's org-admin only; a member asking for it gets 403 rather than a silently narrowed list, so a partial answer can never be mistaken for a complete one.

GET vpn_devices/{id} shows one device — the owner or an org-admin. A device that exists but isn't yours (and you're not an admin) is 404, not 403: a device id must not double as an existence oracle for a colleague's laptop.

To find a gateway to enrol against in the first place, before you own any device on it:

GET /organizations/{organization_id}/vpn_devices/enrollable_gateways

Any org member may call this (unlike GET vpn_gateways, which stays org-admin only) — it exists specifically so a plain member has a way to discover which WireGuard gateway to enrol against. It's deliberately narrow: WireGuard gateways only, and only id, name, protocol, phase per gateway — no config, status, peers, exposures, or device data. A static token needs only the vpn_devices:read scope for it.

Device fields

Field Meaning
id, gateway_id, gateway_name The device and the gateway it's enrolled on
account_id The owner — always the account that enrolled the device
account_email Present only on an org-admin's or ?all=true view; absent (not null) on a member's view of their own devices
name User-facing label, e.g. bernd-laptop; unique per (gateway, owner) among active devices
wireguard_public_key The 44-char base64 public key the client generated
tunnel_address The /32 hq pinned for this device out of the gateway's road-warrior range; freed for reuse once revoked
authorization Effective access — see Authorization model below
authorization_override This device's own narrowing, or null to inherit the gateway default
health up, down, or null until the gateway has reported this device at all — shown as unknown in the dashboard
last_handshake, bytes_in, bytes_out From the gateway's reported status; null until reported
revoked_at null until revoked, then the revocation timestamp
created_at Enrolment timestamp

Device status is not live

health, last_handshake and the counters are what b'nerd last read from the gateway, and b'nerd reads the gateway's status when it reconciles the gateway — after an enrolment, a revoke, an exposure or authorization change — not continuously. A freshly enrolled device can show null/unknown for a while even though its tunnel is up, and a handshake time can be older than the tunnel's real last handshake. Treat these fields as a recent snapshot; the authoritative live view of a tunnel is on the device itself (bnerd vpn status, wg show).

Fetch the client configuration

GET /organizations/{organization_id}/vpn_devices/{id}/client_config

Owner only, under every role and every auth path — an org-admin gets 403, a platform admin gets 403, and a static token is bound to its own account (a vpn_devices:* token minted by an org-admin cannot fetch a member's config). Handing this out is one stolen key away from a working look-alike client, so it's withheld from everyone but the device's own owner. vpn_devices:read scope is enough — the endpoint mutates nothing.

The response — everything needed to compose a wg-quick .conf except the private key, which never left the device:

{
  "data": {
    "interface": {
      "address": "100.64.30.194/32",
      "mtu": 1420
    },
    "peer": {
      "public_key": "<gateway's WireGuard public key>",
      "endpoint": "<gateway floating IP>:<gateway listen port>",
      "allowed_ips": ["<gateway service CIDR>"],
      "persistent_keepalive": 25
    },
    "gateway": {
      "id": "...",
      "name": "site-a",
      "phase": "ready"
    }
  }
}
  • interface.address — this device's own tunnel address, as a /32.
  • peer.allowed_ips is a one-entry list — the gateway's whole service CIDR, not a per-device route. Every exposure's virtual IP lives inside that CIDR, so this one route reaches all of them; what a given device can actually reach is enforced separately, on the gateway, by its authorization — not by narrowing this list.
  • peer.persistent_keepalive is always 25.
  • Compose the .conf by adding your own PrivateKey under [Interface].

Refusals: 410 device_revoked once the device has been revoked (enrol a new one instead — this config can never come back for the same device); 409 gateway_not_ready if the gateway hasn't yet reported its public key, floating IP, or service CIDR (the response's meta.missing names which). This endpoint is deliberately not gated on the gateway's overall status.phase — a freshly enrolled device is itself an unestablished peer, which would otherwise mean the very first device on a gateway could never fetch the config it needs to connect.

Authorization model

A gateway carries a default device authorization — what every road-warrior device on it may reach, by exposure name, unless narrowed:

PATCH /organizations/{organization_id}/vpn_gateways/{id}
Authorization: Bearer <token>
Content-Type: application/json

{ "vpn_gateway": { "device_authorization": { "exposures": ["keycloak"], "reverseExposures": [] } } }

Org-admin only, and the only org-facing gateway update — every other gateway field stays operator-only. It can't be set at creation: the value is validated against the gateway's exposure names, and a brand-new gateway has none yet, so set it once the exposures exist. Omitting device_authorization is 422, not a no-op, so a malformed client can't silently wipe a customer's access rules.

An org-admin may additionally narrow a single device below that default:

PATCH /organizations/{organization_id}/vpn_devices/{id}
Authorization: Bearer <token>
Content-Type: application/json

{ "vpn_device": { "authorization_override": { "exposures": [], "reverseExposures": [] } } }

The override can only narrow — every exposure name it lists must already be in the gateway's device_authorization for the same direction, or the request is refused with 422 (widening a single device beyond the gateway default isn't possible this way; widen the gateway default itself first). {} (reach nothing) is the narrowest valid value. null clears the override so the device goes back to inheriting the gateway default. Omitting authorization_override entirely is 422 — with the field missing there's no way to tell "forgot it" from "set it to null", and treating a forgotten field as a wipe would silently change a customer's access.

The authorization a device reports on read is always derived, never stored as such: the override (or the gateway default, if there is none) intersected with the gateway's current exposures. Narrowing the gateway default immediately narrows every device that inherits it, including one whose override was written while the default was wider — and an exposure that's since been deleted silently drops out of everyone's effective access rather than being rendered as a rule the operator would reject.

Revoke a device

DELETE /organizations/{organization_id}/vpn_devices/{id}

The owner, or an org-admin, may revoke. This is a soft revoke, not a delete: the row stays (who had access, and when it was taken away, is exactly what an audit needs), but the device's tunnel stops working immediately. It's idempotent — revoking an already-revoked device is a 200 with no further effect. Revoking frees the device's name, key, and tunnel address for reuse, which is what lets you re-enrol with the same local key pair after a 410.

Removing a member from an organization automatically revokes every device they'd enrolled on that organization's gateways.

Peer status

Both protocols report the same per-peer status, read from the gateway's detail view (dashboard, bnerd vpn peers list, or the peers embedded in GET /organizations/{org}/vpn_gateways/{id}):

Field Meaning
health up or down
bytes_in Bytes received from this peer
bytes_out Bytes sent to this peer

There is no separate "list peers" or "show peer" endpoint — peer state is always read embedded in its gateway. As with devices, these values are the gateway status as b'nerd last read it (at the gateway's last reconcile), not a live feed.

The peer's most recent handshake timestamp (last_handshake) is not on this list — it is only readable inside the gateway's raw status, status.peers[].last_handshake on GET /organizations/{org}/vpn_gateways/{id}, not in the CLI peers table or the dashboard's peer list.

Limits & billing

Gateways per organization 2 by default — enough for one IPsec plus one WireGuard gateway — contact support to raise it
Devices per gateway 50 by default, platform-configurable up to a hard cap of 60 — contact support to raise it. Each gateway carves a road-warrior address range of 61 usable addresses out of its service CIDR, so the device limit is reached before the range runs out
Price €49/month per gateway, flat
Peers, devices No per-peer or per-device charge

Endpoints

Method Path Description
GET /organizations/{organization_id}/vpn_gateways List the organization's gateways
POST /organizations/{organization_id}/vpn_gateways Create a gateway
GET /organizations/{organization_id}/vpn_gateways/{id} Show a gateway, with its peers and exposures
PATCH /organizations/{organization_id}/vpn_gateways/{id} Set a gateway's default device authorization (the only customer-editable gateway field)
DELETE /organizations/{organization_id}/vpn_gateways/{id} Delete a gateway (refused for a deletion-protected gateway)
POST /organizations/{organization_id}/vpn_gateways/{gateway_id}/vpn_peers Create a peer
PATCH /organizations/{organization_id}/vpn_gateways/{gateway_id}/vpn_peers/{id} Update a peer
DELETE /organizations/{organization_id}/vpn_gateways/{gateway_id}/vpn_peers/{id} Delete a peer
GET /organizations/{organization_id}/vpn_devices List your own devices (?all=true: every member's, org-admin only)
GET /organizations/{organization_id}/vpn_devices/enrollable_gateways List WireGuard gateways you may enrol a device against (any member)
POST /organizations/{organization_id}/vpn_gateways/{gateway_id}/vpn_devices Enrol a device (any member, self-service)
GET /organizations/{organization_id}/vpn_devices/{id} Show a device (owner or org-admin)
GET /organizations/{organization_id}/vpn_devices/{id}/client_config Fetch a device's WireGuard client config (owner only)
PATCH /organizations/{organization_id}/vpn_devices/{id} Narrow one device's access (org-admin)
DELETE /organizations/{organization_id}/vpn_devices/{id} Revoke a device (owner or org-admin)

Besides the gateway's device authorization default above, nothing else on the gateway itself is customer-editable after creation — name, protocol, config, and endpoint stay operator-only.

What's next

Task Where
Check whether your account holds the Admin role Roles & RBAC
Understand usage and invoicing Billing & Usage
Look up platform terms Glossary