Files
netbird-docs/src/pages/client/mdm-integration.mdx
T
Jack Carterandriccardom 4a9435e701 docs: group MDM policy keys by job and split delivery into per-platform pages (#1020)
* docs: document the MDM keys added since the page was written, and iOS

The reference table listed 18 of the 23 keys the client recognises, and
claimed a count of 16. Six keys were missing from every downloadable
template as well, including lazyConnection, which the page already
documented:

- lazyConnection, disableAdvancedView, enableLocalMetrics,
  localMetricsAddress, allowRemoteJobs and debugBundleUploadURL are now
  in the reference table and in netbird.admx/.adml, io.netbird.client.plist,
  netbird-macos.mobileconfig, netbird-macos.sh and netbird-policy.reg.
- Notes call out the two pairs that read as related but are not:
  disableMetricsCollection (anonymous telemetry) versus
  enableLocalMetrics (the client's own Prometheus endpoint), and
  disableAdvancedView being UI-only, so unlike its neighbours it never
  causes a request to be rejected.

iOS and tvOS now have a section of their own. The channel is Apple's
Managed App Configuration against bundle id io.netbird.app
(io.netbird.app.tv on tvOS), with a new netbird-ios-appconfig.plist
template and per-provider instructions. The section states what the
policy reaches today: the app locks its interface and refuses
configuration changes, while the tunnel engine runs in a Network
Extension that does not receive the app configuration, so keys the
engine applies to the connection are not enforced there yet.

Two template defects found on the way:

- netbird-macos.mobileconfig was not well-formed XML. Its header
  comment contained a double hyphen, which XML forbids inside a
  comment, so plutil -lint and any strict parser rejected the file an
  admin downloaded.
- netbird-policy.reg had one LF-terminated line among CRLF ones;
  normalised, with the UTF-16LE encoding reg import requires preserved.

Verified with npm run build and npm run lint:mdx, plus XML well-formedness
on all five XML templates, bash -n on the shell template, and a
cross-reference check that every string and presentation the ADMX
references is defined in the ADML.

* docs: iOS enforces every key; document when a change reaches the tunnel

The iOS section was written while the policy stopped at the app: the
network extension that runs the engine has its own preferences domain,
and Apple's app-configuration channel does not deliver there. The app
now mirrors the configuration into the shared App Group and tells the
extension when it changes, so the keys the engine applies to the
connection are enforced too.

What replaces the old warning is a timing caveat, which is what an admin
actually needs in order to plan a rollout. The hand-off happens only
while the app is running: in the foreground a change reaches the tunnel
within about 30 seconds or immediately on activation, but iOS suspends a
backgrounded app, so a policy pushed — or withdrawn — in that window
applies the next time the app is opened. A tunnel brought up by VPN On
Demand or the widget therefore runs on the last policy the app saw.

tvOS keeps the old behaviour and now says so: the hand-off needs a shared
app group, which does not work between the tvOS app and its extension, so
only the interface is enforced there.

Also corrected: blockInbound, wireguardPort and lazyConnection are no
longer listed as having no effect but as having no control to lock, and
debugBundleUploadURL moves out of the ignored list, since the engine
applies it when a remote job produces a bundle. The app-config template
carries the same three keys and the same timing note.

* docs: list allowRemoteJobs among the keys iOS enforces without a control

The key is enforced on iOS like the other engine-applied ones: a peer
whose policy withholds it refuses management-requested jobs. What is not
there yet is the lock on the Troubleshoot toggle, which arrives with the
iOS client PR that introduces that toggle — so promising it in the table
of what the user sees would describe a screen that does not exist.

Moved to the group of keys the engine applies with nothing on screen to
mark, next to blockInbound, wireguardPort, lazyConnection and
debugBundleUploadURL. It moves back to the table once the toggle ships.

* docs: sign macOS profiles with CMS, and scope the iOS claims properly

Three review findings, all correct.

`productsign` signs installer packages, not configuration profiles, and
an Apple Developer ID certificate cannot sign a profile at all — Apple's
own guidance is that a `.mobileconfig` is a CMS signed-data message. The
instruction predates this branch but the commit that fixed the malformed
XML comment rewrote it, so it is fixed here rather than carried forward.
It was also misleading in a second way: a profile delivered through an
MDM is signed by the MDM channel, so a managed rollout has nothing to
sign by hand. The step now says that, and gives `security cms -S` for the
case where the profile is handed out some other way.

"Every key is enforced on iOS" contradicted the list of
platform-inapplicable keys three paragraphs below it; it now reads "every
key that applies to the platform", and points at that list.

The engine-applied group — blockInbound, wireguardPort, lazyConnection,
allowRemoteJobs, debugBundleUploadURL — was stated without qualification
under a table headed "iOS / tvOS", while the warning just above says the
tvOS engine never receives the policy. Marked as iOS, with the tvOS case
pointed back at that warning.

* docs: group MDM policy keys by job and split delivery into per-platform pages

Rewrite /client/mdm-integration as the concept page and key reference:
a running example (a three-key baseline), the "pinned switches" model
(present means pinned, even as false), and the 23 keys grouped by the
job they do, each with its own anchor and the mistake it invites.

Correct claims that disagree with the client source (v0.80.0):
- disableClientRoutes stops the device using routes (resources, exit
  nodes); it was described as not routing for others
- blockInbound also stops the SSH server and routing; not a kill switch
- since v0.75.0 the app hides managed settings, no "(MDM)" tag
- a policy change restarts the connection
- splitTunnel* have no effect today (Android has no MDM channel yet)
- managementURL pins the server, not the account, on NetBird Cloud
- the pre-shared key is readable by local users on Windows and macOS

Move Windows, macOS and iOS delivery into their own pages under
MDM Deployment, add them to the nav, and point inbound links at the
new per-key anchors.

* docs: fix MDM accuracy points found in review

- integer booleans are ignored on macOS (plist decodes as uint64); say so
- disableAutostart and disableAdvancedView lock nothing; qualify the lock claim
- managementURL: drop the disableProfiles advice, no key pins the account
- drop the unverifiable restart duration and a disableAdvancedView claim
- macOS page: fix 'never write the file' vs the shell-script channel
- Windows page: warn that the sample .reg pins every key
- tighten wording

* docs: say what disableAdvancedView hides (Peers and Resources tabs)

* docs: make clear disableNetworks removes the choice, not the networks

* docs: add recommended MDM policies by device role

* docs: lazyConnection follows the account setting, on by default since v0.74.0

* docs: drop the open-an-issue line from MDM troubleshooting

* docs: make remote jobs optional for every device role

* docs: make disableNetworks and disableAdvancedView optional for laptops

* docs: rename Recap to TL;DR on the MDM pages

* docs: TL;DR on the main MDM page only, drop the lead-in

* docs: list MDM Integration first under MDM Deployment in the nav

* docs: second review pass on the MDM pages

- macOS integer booleans are locked as well as unapplied; say so
- the lock-nothing keys are not desktop-only (disableAdvancedView on iOS)
- drop Linux from disableAutostart (no MDM channel there)
- blockInbound: drop the redundant routed-network clause
- encryption intro: Rosenpass permissive does not need to match
- troubleshooting: check macOS boolean types, no warning is logged
- wording fixes

* docs: correct MDM timing and CLI claims from a macOS lab run (v0.80.0)

- netbird debug config reads the policy file on each call; the daemon
  applies a change at its next reload and logs 'MDM policy changed'
- netbird up --flag on a connected client ignores its flags; the
  rejection examples now say login --flag or up on a disconnected client
- settings-field locks apply immediately; the disableUpdateSettings,
  disableProfiles and disableNetworks gates apply at the next reload
- the restart on a policy change takes a second or two

* docs: MDM results from the Windows and macOS profile labs (v0.80.0)

- netbird logout works under every key and deregisters the peer; with
  managementURL, disableProfiles and disableUpdateSettings pinned, logout
  plus up --setup-key moves a device to another account on the same URL
- a profile-installed macOS policy file is a binary plist, root:wheel 644
- a REG_BINARY (or other unread type) value is not listed as managed
- the policy key inherits Authenticated Users read access

* docs: tighten four MDM statements to what the labs and source show

- no key blocks netbird logout of the active profile (source: only a
  non-active profile logout is gated, by disableProfiles)
- the engine-restart log line appears only while the client is connected
- macOS: binary plist is what a configuration profile produces; the shell
  script writes the same owner and mode
- Windows: the observed fact is that the NetBird key inherits Authenticated
  Users read access, not a statement about the parent key

* docs: address CodeRabbit review on the MDM delivery pages

- iOS: the app must be managed by the MDM, not necessarily installed by
  it (Apple supports taking over a user-installed app); same in the
  iOS app-config template
- macOS: Jamf's Application & Custom Settings takes the bare plist, so
  point Jamf at io.netbird.client.plist (and note the signed .mobileconfig
  upload route); JumpCloud's MDM Custom Configuration Profile takes a
  .mobileconfig per JumpCloud's docs, so drop the bare-plist claim and
  its troubleshooting entry
- Windows: ADMXInstall only ingests the template; values are set through
  Policy/Config/NetBird~Policy~NetBird/<PolicyName>; drop the
  unverified Registry CSP path

* docs: address CodeRabbit's second review of the MDM templates

- netbird-macos.sh: managementURL, allowServerSSH and wireguardPort now
  default to $NULL like every other key; the old defaults pinned them
  (allowServerSSH=true pinned the SSH server allowed) on any run that
  did not edit those lines
- drop the claim that disableMetricsCollection governs anonymous usage
  telemetry from the mobileconfig, the bare plist, the script and the
  ADML help text; the client recognizes the key but nothing reads it

* docs: correct the Mosyle path for the macOS MDM profile

Mosyle takes the .mobileconfig under Management > Management Profiles >
Certificates / Custom Profiles; there is no Custom Settings option with a
preference domain (per Orb and SI Prep deployment guides; Mosyle's own
docs need a login). Drop Mosyle from the bare-plist template header, and
align the mobileconfig header with the Jamf advice on the page.

* docs: drop the profiles-command install tip from the macOS template

macOS 11 and later cannot install configuration profiles with the
profiles command (man profiles, profiles tool 8.0+); point local testing
at a double-click and System Settings > General > Device Management, the
path the lab used.

* docs: make the Windows MDM page Intune-first, with a registry reference section

The page now leads with the Intune workflow: import the NetBird ADMX/ADML
once, create an Imported Administrative templates profile, assign it to a
device group, sync and verify, then the OMA-URI fallback and Intune
troubleshooting. Group Policy, .reg files, JumpCloud and the value-type
table move to a Registry reference section with its own troubleshooting.

New, from Microsoft Learn (Import custom ADMX templates): the current
portal paths, en-us ADML only, and that re-importing an updated template
fails until the profiles and the old template are deleted. netbird.admx
has no namespace dependencies and no combo boxes, so it imports alone.

The Intune install page stays separate; both pages now link to each
other. Main-page links follow the renamed anchors (#value-types,
#intune-troubleshooting).

* docs: move the enforce-settings link to the end of the Intune deploy page

* docs: group the Windows page's Intune steps under one heading

Mirrors the Registry reference section, so the page's table of contents
shows the Intune/registry split. Heading texts and anchors are unchanged.

* docs: rename the first MDM key group to Set the server and startup

The old heading named only managementURL; the group also holds
disableAutoConnect and disableAutostart.

* docs: add a use case to the Lock the client app section

What a developer on a locked Acme laptop sees (app tabs, the two CLI
errors, what still works), plus the finance team's disableNetworks and
Auto Apply exit node variant. Every behaviour is from the macOS and
Windows labs (v0.80.0).

* docs: replace the Lock the client app use case with a plain goals table

Goal, setting, and what users can still do, written for a junior admin.
disableProfiles row says plainly that signing out and into another
account still works (Windows lab W12).

* docs: OMA-URI payloads: <disabled/> pins an on/off setting off

The ADMX's on/off policies write enabledValue 1 and disabledValue 0, so
<enabled/> can only pin a setting on; document <disabled/> for off, the
String data type, and that un-pinning means Not configured (check the
device with reg query, since removing an OMA-URI may not clear it).

* docs: add What you can achieve tables to every MDM key section

Same goal / setting / what-to-know format as Lock the client app, for
server and startup, device exposure, encryption, support and monitoring,
and connection tuning; Lock's table now names values too. Every line
restates a claim already verified in the labs or source.

* docs: reorder the MDM page: goals first, key reference after

- What you can achieve (the six goal tables) comes first, then
  Recommended policies, How the client applies a policy, and the Policy
  keys reference; goal-table keys link down to their reference entries
- reference groups get noun names (Server and startup keys, ...) so no
  two headings share an anchor
- the Acme running example is gone; its three keys become The laptop
  baseline under Recommended policies, which the Windows, macOS and iOS
  pages now link to as their example
- a one-line pin rule opens the goals, since the explanation now follows

* docs: laptop baseline goes to the user-devices group, not just laptops

* docs: rename the laptop baseline to the user device baseline

It applies to every user device (and the iOS page uses it), so the
Recommended policies column becomes User devices, the anchor becomes
#the-user-device-baseline, and the three platform pages follow.

---------

Co-authored-by: riccardom <riccardomanfrin@gmail.com>
2026-10-06 17:41:42 +02:00

339 lines
37 KiB
Plaintext

import { Note, Warning } from "@/components/mdx";
export const description =
"Enforce NetBird client settings on a fleet of Windows, macOS, and iOS devices through your MDM (Intune, Jamf, Kandji, Mosyle, Workspace ONE, JumpCloud, Group Policy): how a policy is applied and locked, and what every policy key does, grouped by the job it solves.";
# MDM Integration
Installing NetBird on a few hundred laptops is the easy half of a rollout. The hard half is everything that happens afterwards: a user points the client at a different server, creates a second profile, turns on an SSH server, or switches off a setting your security posture depends on. Fixing that one machine at a time does not scale, and asking users nicely is not a control.
The NetBird client solves this by honoring **MDM policy**: settings your device management tool writes to the operating system's own managed-configuration store. The client reads that store, applies what it finds on top of every other setting, and locks those settings so nobody on the device can change them. NetBird adds no agent or connector of its own. If your MDM can write to the store, it can manage NetBird.
This page is in four parts:
1. [What you can achieve](#what-you-can-achieve): the goals you can reach with a policy, and the setting for each.
2. [Recommended policies](#recommended-policies): a starting policy for user devices, shared devices, and routing peers.
3. [How the client applies a policy](#how-the-client-applies-a-policy): the mental model, and what a user sees on a managed device.
4. [Policy keys reference](#policy-keys-reference), then [verifying enforcement](#verifying-enforcement) and [troubleshooting](#troubleshooting).
How to deliver a policy is specific to each operating system, so it has its own page:
| Platform | Where NetBird reads policy | How to deliver it |
| --- | --- | --- |
| Windows | Registry, `HKLM\Software\Policies\NetBird` | [Enforce settings on Windows](/manage/peers/mdm-deployment/windows-mdm-policy): Intune, Group Policy, `.reg` import, JumpCloud |
| macOS | `/Library/Managed Preferences/io.netbird.client.plist` | [Enforce settings on macOS](/manage/peers/mdm-deployment/macos-mdm-policy): a configuration profile for bundle id `io.netbird.client` |
| iOS and tvOS | Managed App Configuration for bundle id `io.netbird.app` (tvOS: `io.netbird.app.tv`) | [Enforce settings on iOS](/manage/peers/mdm-deployment/ios-mdm-policy): an app configuration sent with the managed app |
Linux and Android have no MDM channel today. See [When not to use MDM policy](#when-not-to-use-mdm-policy).
## What you can achieve
Find what you want to do, set the key, and deliver it with your MDM (see the platform pages above). Each key links to its full entry in the [Policy keys reference](#policy-keys-reference).
Include only the keys you mean to pin. A key you set is locked, even when you set it to `false`; a key you leave out stays the user's to change.
### Set the server and startup
| You want to | Set | What to know |
| --- | --- | --- |
| Make every device use your management server | [`managementURL`](#managementURL) to your server's URL | Pins the server, not the account: users can still sign out and in to another account on that server. |
| Make devices connect automatically at boot | [`disableAutoConnect`](#disableAutoConnect) to `false` | Users can still disconnect by hand. |
| Make users connect by hand | [`disableAutoConnect`](#disableAutoConnect) to `true` | The client starts disconnected; the user clicks **Connect** or runs `netbird up`. |
| Stop the NetBird app opening at login | [`disableAutostart`](#disableAutostart) to `true` | The background service still runs. A user can switch launch-at-login back on, but the app turns it off again the next time it starts. |
### Lock the client app
| You want to | Set | What users can still do |
| --- | --- | --- |
| Stop users changing NetBird's settings | [`disableUpdateSettings`](#disableUpdateSettings) to `true` | Connect, disconnect, and sign in. Every settings change, in the app or on the command line, is refused. |
| Stop users adding a second profile, for example to reach a personal NetBird account | [`disableProfiles`](#disableProfiles) to `true` | Use their current profile as normal. They can still sign out and sign in to a different account; see the [`managementURL`](#managementURL) note. |
| Make sure users keep the networks and exit node you assign | [`disableNetworks`](#disableNetworks) to `true` | Use the assigned networks and exit node. They cannot turn them off. To send all internet traffic through an exit node, also enable **Auto Apply** on it in the dashboard. |
| Give non-technical users a simpler app | [`disableAdvancedView`](#disableAdvancedView) to `true` | Everything except the **Peers** and **Resources** lists. |
| Lock the app down completely, for example on shared or kiosk devices | all four to `true` | Connect and disconnect. |
None of these settings stops a user from disconnecting NetBird. See [What a policy does not do](#what-a-policy-does-not-do).
### Control what the device exposes
| You want to | Set | What to know |
| --- | --- | --- |
| Stop other peers opening connections to a device | [`blockInbound`](#blockInbound) to `true` | Outbound connections still work. It also stops the device's SSH server and its routing. Never use it on a routing peer. |
| Make sure no NetBird SSH server runs on a device | [`allowServerSSH`](#allowServerSSH) to `false` | Wins even when SSH is enabled for the peer in the dashboard. |
| Allow the NetBird SSH server on a device | [`allowServerSSH`](#allowServerSSH) to `true` | SSH must also be enabled for the peer in the dashboard, and `blockInbound` must be off. |
| Stop a laptop from carrying other peers' traffic | [`disableServerRoutes`](#disableServerRoutes) to `true` | Protects against a laptop wrongly assigned as a routing peer. Never use it on a real routing peer. |
| Stop a device using routed networks and exit nodes | [`disableClientRoutes`](#disableClientRoutes) to `true` | Cuts the device off from every network resource and exit node, so it is rarely what you want on a laptop. |
### Encrypt the tunnel
| You want to | Set | What to know |
| --- | --- | --- |
| Add a shared secret to every connection | [`preSharedKey`](#preSharedKey) | Every peer, including routing peers and servers, needs the same key, or they cannot connect to each other. Local users can read the key. |
| Add post-quantum protection | [`rosenpassEnabled`](#rosenpassEnabled) and [`rosenpassPermissive`](#rosenpassPermissive) to `true` | Permissive mode lets peers without Rosenpass still connect. Remove it once every peer runs Rosenpass. |
### Support and monitoring
| You want to | Set | What to know |
| --- | --- | --- |
| Let support collect debug bundles remotely | [`allowRemoteJobs`](#allowRemoteJobs) to `true`, and [`debugBundleUploadURL`](#debugBundleUploadURL) to your upload server | Bundles hold peer names, IP addresses, and logs; the upload URL keeps them on infrastructure you control. |
| Make sure remote jobs never run on a device | [`allowRemoteJobs`](#allowRemoteJobs) to `false` | Remote jobs are off by default; pinning `false` stops anyone turning them on. |
| Scrape connection state with Prometheus | [`enableLocalMetrics`](#enableLocalMetrics) to `true` | Listens on `127.0.0.1:9191` by default. Keep `localMetricsAddress` on loopback: the endpoint has no authentication. |
### Tune the connection
| You want to | Set | What to know |
| --- | --- | --- |
| Keep every connection open on a device | [`lazyConnection`](#lazyConnection) to `false` | Overrides the account setting for that device. `NB_LAZY_CONN` on the device beats this key. |
| Force lazy connections on a device | [`lazyConnection`](#lazyConnection) to `true` | Same precedence as above. |
| Use a fixed WireGuard port | [`wireguardPort`](#wireguardPort) | Only when another program needs the default port or your firewall rules expect a specific one. |
## Recommended policies
Start from the column that matches the device group, then add or remove keys for your own posture. Send each policy to one device group, and keep routing peers out of every group that gets `blockInbound` or `disableServerRoutes`.
| Key | User devices | Shared or kiosk devices | Routing peers and servers |
| --- | --- | --- | --- |
| `managementURL` | ✓ | ✓ | ✓ |
| `disableUpdateSettings` | ✓ | ✓ | ✓ |
| `disableProfiles` | ✓ | ✓ | ✓ |
| `disableServerRoutes` | ✓ | ✓ | never |
| `blockInbound` | optional | ✓ | never |
| `allowRemoteJobs` and `debugBundleUploadURL` | optional | optional | optional |
| `disableNetworks` | optional | ✓ | |
| `disableAdvancedView` | optional | ✓ | |
| `disableAutoConnect: false` (always connect at boot) | | ✓ | ✓ |
Why each column looks the way it does:
- **User devices**, such as laptops and desktops, consume resources and never serve them, so `disableServerRoutes` stops one that is wrongly assigned as a routing peer from carrying other peers' traffic. Add `blockInbound` if nothing ever needs to reach these devices. Leave it out if IT reaches them over [NetBird SSH](/manage/peers/ssh), and control inbound access with access policies instead. Add `disableNetworks` to stop users choosing networks and exit nodes (the ones the dashboard assigns keep working), and `disableAdvancedView` to hide the **Peers** and **Resources** lists.
- **Shared or kiosk devices** should leave the user as little as possible to change: no network or exit node choice, no advanced view, and a connection at every boot.
- **Routing peers and servers** must keep forwarding traffic and accepting connections, so they never get `blockInbound` or `disableServerRoutes`. This column only applies to Windows and macOS machines; Linux has no MDM channel, so configure Linux routing peers with [CLI flags](#when-not-to-use-mdm-policy).
- **Remote jobs** are optional in every column. Turn on `allowRemoteJobs` wherever your support team collects [debug bundles](/manage/peers/remote-jobs) remotely, and always pin `debugBundleUploadURL` with it, so bundles go to an upload server you control. Routing peers are often the first place to look when a resource is unreachable or relayed.
Leave these keys out unless you have a specific reason:
- `preSharedKey`, `rosenpassEnabled`, `rosenpassPermissive`: each has to match on every peer, or devices lose their connections. See [Encryption keys](#encryption-keys).
- `disableClientRoutes`: cuts a device off from every network resource and exit node.
- `wireguardPort`: the default suits almost every fleet.
- `lazyConnection`: leave lazy connections to the account setting, so one dashboard switch controls the whole fleet. Push `false` only for devices that must hold every connection open.
- `enableLocalMetrics`, `localMetricsAddress`: only for devices you scrape with Prometheus.
- `splitTunnelMode`, `splitTunnelApps`, `disableMetricsCollection`: no effect yet.
### The user device baseline
The three keys every column above shares make the usual starting policy, and the platform pages use it as their example. With a self-hosted management server:
| Key | Value | What it does |
| --- | --- | --- |
| `managementURL` | `https://netbird.example.com:443` | Every device talks to your server and nothing else |
| `disableUpdateSettings` | `true` | Users can connect and disconnect, but cannot change settings |
| `disableProfiles` | `true` | Users cannot add a second profile |
On NetBird Cloud, use `https://api.netbird.io:443`. Send the policy only to the device group that holds your user devices, so routing peers and servers stay out of it.
## How the client applies a policy
Think of a policy as **a set of pinned switches**. Every key in the payload pins one setting to the value you chose and takes it out of the user's reach. Every key you leave out stays an ordinary setting that the user, the CLI, or the dashboard controls as usual. **A key that is absent is not pinned, and a key that is present is pinned, whatever its value.**
<Note>
**Pushing `false` is not the same as leaving a key out.** `allowServerSSH: false` pins the SSH server off: no user, CLI flag, or dashboard setting can turn it on while the policy stands. Leaving `allowServerSSH` out of the payload leaves the SSH server to whoever controls the device. Only include the keys you mean to pin.
</Note>
On Windows and macOS, the client daemon reads the policy at startup and again every minute. Each time:
1. **It applies the policy on top of every other configuration layer.** Defaults, the on-disk profile, environment variables, and CLI or app input all apply first; the policy is applied last and wins. A policy also overrides the service-install flags `--disable-update-settings`, `--disable-profiles`, and `--disable-networks`: pushing `disableNetworks: false` re-enables network selection even on a device installed with `--disable-networks`. One exception: the `NB_LAZY_CONN` environment variable takes precedence over the `lazyConnection` key.
2. **It locks the pinned settings.** A request that tries to change a pinned setting to a different value, from the desktop app, the CLI (`netbird login --flag`, or `netbird up --flag` on a disconnected client) or the daemon API, is rejected as a whole with `fields managed by MDM cannot be modified: [<keys>]`. None of its other fields are applied either. A request that repeats the pinned value is accepted, because it changes nothing. These locks read the policy file on every request, so they apply as soon as the file changes; the `disableUpdateSettings`, `disableProfiles`, and `disableNetworks` gates apply at the next reload. Two keys only change what the app shows or does, and lock nothing: [`disableAutostart`](#disableAutostart) and [`disableAdvancedView`](#disableAdvancedView).
3. **It never writes policy values to disk.** The on-disk profile keeps the device's own settings. When you remove a key from the policy, the setting falls back to that on-disk value on the next reload and becomes editable again, with nothing left behind.
When a reload finds that the policy has changed, the client restarts its connection to apply it. Expect a brief interruption on every policy change (typically a second or two), and plan larger changes outside working hours. The desktop app then shows a notification: **NetBird settings updated**, "Your NetBird configuration was updated by your IT policy."
On iOS the timing is different: the app passes the policy to the tunnel only while the app is running. See [Enforce settings on iOS](/manage/peers/mdm-deployment/ios-mdm-policy#when-a-policy-takes-effect).
### What a user sees
Since NetBird v0.75.0, the desktop app **hides** the controls for pinned settings rather than greying them out. With the user device baseline, the server address field is gone. Read-only mode removes the **Network**, **Security**, **SSH**, and **Advanced** settings tabs, and `disableProfiles` removes the **Profiles** tab. **General**, **Troubleshooting**, and **About** remain, and the **Connect** button still works.
On the CLI, changing a pinned setting fails with the error above. The [`disableUpdateSettings`](#disableUpdateSettings), [`disableProfiles`](#disableProfiles), and [`disableNetworks`](#disableNetworks) keys have errors of their own, listed under [Troubleshooting](#troubleshooting).
### What a policy does not do
A policy pins the client's configuration. It does not make the tunnel mandatory: a user can still disconnect with `netbird down` or the **Disconnect** button, no key blocks `netbird logout` of the active profile (which removes the peer from your account), and anyone with administrator rights on the device can stop the service, uninstall the client, or edit the registry or the managed-preferences file. NetBird has no always-on mode. Enforce that layer with the operating system: give users standard accounts, manage the NetBird service with your MDM, and write your [access policies](/manage/access-control) so that disconnecting from NetBird costs a user access to company resources rather than freeing them from restrictions.
## Policy keys reference
The client recognizes 23 keys. They are grouped below by the job they do, starting with the ones nearly every rollout needs. A key that has no meaning on a platform (for example `allowServerSSH` on iOS) is ignored there, so one payload is safe to send to every platform.
Key names are camelCase in plist and app-configuration payloads. On Windows, the registry value names and the Group Policy templates use PascalCase (`ManagementURL`, `BlockInbound`); both forms are recognized, and names are matched without regard to case. In the Group Policy editor, each policy is named after its key in plain words, such as **Block inbound** for `blockInbound`.
Value types:
- **boolean**: a native boolean (`<true/>` or `<false/>` in a plist), or one of the strings `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`. An integer (`0` is false, anything else is true) also works on Windows (`REG_DWORD`) and iOS, but **not on macOS**: there, a boolean written as `<integer>` is listed as managed and locked, but never applied, so the setting is frozen at whatever the device had.
- **string**: a string. An empty string counts as not set.
- **integer**: an integer, or a string of decimal digits.
The registry type for each key on Windows is on [Enforce settings on Windows](/manage/peers/mdm-deployment/windows-mdm-policy#value-types).
### Server and startup keys
Nearly every rollout sets `managementURL`. The other two keys decide whether NetBird connects, and whether its app opens, on their own.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="managementURL"></span>`managementURL` | string | The management server the client connects to, for example `https://api.netbird.io:443` for NetBird Cloud or `https://netbird.example.com:443` for a self-hosted server. Must start with `http://` or `https://`; a URL without a port gets `:443` (or `:80`) added. An invalid URL is logged and ignored, and the client keeps its previous server. |
| <span id="disableAutoConnect"></span>`disableAutoConnect` | boolean | `true`: the client does not connect when its service starts, so the user has to click **Connect** or run `netbird up`. `false`: pins [Connect on Startup](/client/connect-on-startup) on, so the client always connects at boot. |
| <span id="disableAutostart"></span>`disableAutostart` | boolean | `true`: the desktop app does not open itself at login. The app removes its login entry (the Windows `Run` registry key or the macOS Login Item) every time it starts, and never creates one on a fresh install. The background service is not affected. Desktop app only. |
- **`managementURL` pins the server, not the account.** NetBird Cloud serves every account from `https://api.netbird.io:443`, so on Cloud this key stops a user from pointing the client at another server, but not from signing in to another Cloud account. No key pins the account: even with `managementURL`, `disableProfiles`, and `disableUpdateSettings` all pinned, `netbird logout` followed by `netbird up --setup-key <key>` moves the device to another account on the same server. A device signed in to another account has no access to your resources, because your access policies do not apply to it.
- **`disableAutostart` only acts when the app starts.** The **Launch NetBird UI at Login** switch in the app's settings stays usable. A user who turns it back on gets the app at their next login, and the app removes the entry again as soon as it opens. Pushing `false` does not turn launch-at-login on; it only stops the app from removing the entry.
### Client app keys
These keys decide how much of the app a user can touch. The user device baseline uses the first two.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="disableUpdateSettings"></span>`disableUpdateSettings` | boolean | Read-only mode. Every request that would change a setting is rejected, from the app or the CLI (`netbird login --flag`, or `netbird up --flag` on a disconnected client). Connecting, disconnecting, and signing in again still work, because they change no settings. |
| <span id="disableProfiles"></span>`disableProfiles` | boolean | Hides profile management in the app and rejects creating, renaming, removing, or switching [profiles](/client/profiles). The device stays on its current profile. |
| <span id="disableNetworks"></span>`disableNetworks` | boolean | Stops the user changing which networks and exit node the device uses. Networks and exit nodes **keep working**: the device still reaches the resources and exit node the dashboard assigns to it. Only the choice is taken away. The app hides the exit node switcher and the **Resources** tab, and `netbird networks list`, `select`, and `deselect` are rejected. The device keeps whatever selection it had when the policy arrived. |
| <span id="disableAdvancedView"></span>`disableAdvancedView` | boolean | Removes **Advanced View** from the desktop app's menu, along with its **Peers** and **Resources** tabs: the list of peers this device can see, with each connection's details, and the list of network resources. A user already in that view is switched back to the simple view. On iOS it hides the Advanced section. It only changes what the app shows: no setting is locked, and no request is rejected because of it. |
- **`disableUpdateSettings` covers settings no key names.** The DNS, firewall, interface, and SSH sub-options have no key of their own, so the only way to stop users changing them is read-only mode. On a device with `disableUpdateSettings: true`, an administrator who needs to change one of those settings has to lift the policy first.
- **`disableNetworks` plus an exit node pins the device to that exit node.** With an exit node that has **Auto Apply** enabled, the exit node is applied automatically and the user cannot deselect it. See [Enforcing the exit node on managed devices](/use-cases/remote-access/exit-nodes#enforcing-the-exit-node-on-managed-devices) for the full recipe, including why the policy has to reach the devices before users touch the exit node switch.
### Device exposure keys
These keys decide what reaches the device, and what the device passes on. Each pins a client-side setting that also exists in the app and the CLI, and each one wins over what the dashboard configures: routes, routing-peer assignments, SSH, and access policies.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="blockInbound"></span>`blockInbound` | boolean | `true`: drops every inbound connection from other peers, whatever your access policies allow. It also stops the SSH server and stops the device acting as a routing peer. Outbound connections from the device are not affected. See [Block Inbound Connections](/client/block-inbound-connections). |
| <span id="allowServerSSH"></span>`allowServerSSH` | boolean | `false`: the [NetBird SSH server](/manage/peers/ssh) never runs on this device, even if SSH is enabled for the peer in the dashboard. `true`: lets it run, but only once SSH is also enabled for the peer in the dashboard and `blockInbound` is off. |
| <span id="disableServerRoutes"></span>`disableServerRoutes` | boolean | `true`: the device never acts as a [routing peer](/manage/networks/how-routing-peers-work). It does not forward traffic to the networks, resources, or exit node it is assigned to serve. |
| <span id="disableClientRoutes"></span>`disableClientRoutes` | boolean | `true`: the device ignores the routes it receives. It does not install routes to network resources reached through a routing peer, and it cannot use an exit node. It can still reach other peers directly. |
- **The two route keys are easy to mix up.** "Client" and "server" describe the device's role in a route, not the direction of the traffic. `disableClientRoutes` stops the device *using* routes, which is what an end-user laptop does. `disableServerRoutes` stops the device *providing* routes, which is what a routing peer does. On a laptop, `disableClientRoutes: true` cuts it off from every network resource and exit node, which is rarely what you want. `disableServerRoutes: true` is the safe choice for laptops, because it stops a laptop that has been wrongly assigned as a routing peer from carrying other peers' traffic.
- **Do not send `blockInbound` or `disableServerRoutes` to routing peers.** A routing peer with either key set stops forwarding. If it is the only routing peer for a network, that network becomes unreachable for everyone. Scope these keys to a device group that excludes routing peers and servers.
- **`blockInbound` is not a kill switch.** It blocks connections *to* the device, not traffic *from* it, and it does not keep the tunnel up. Use it for devices that only ever consume resources, such as most end-user laptops, and never for a device other peers need to reach.
### Encryption keys
These keys change how connections are encrypted. A pre-shared key, and Rosenpass outside permissive mode, have to match on both ends of a connection, so a key sent to only part of a fleet can cut that part off from the rest.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="preSharedKey"></span>`preSharedKey` | string | A WireGuard pre-shared key mixed into every connection this device makes. Only peers configured with the **same** key can connect to each other. Redacted in logs, and never returned by the client API. |
| <span id="rosenpassEnabled"></span>`rosenpassEnabled` | boolean | Turns on [Rosenpass](/client/post-quantum-cryptography), a post-quantum key exchange that rotates a fresh pre-shared key every two minutes. Experimental. A device with Rosenpass on cannot connect to a peer without it unless `rosenpassPermissive` is also on. |
| <span id="rosenpassPermissive"></span>`rosenpassPermissive` | boolean | With Rosenpass on, lets this device fall back to a standard WireGuard connection with a peer that does not run Rosenpass, and keeps using Rosenpass with the peers that do. |
- **A pre-shared key splits your network in two.** Devices that have the key and devices that do not cannot connect to each other. If you pushed a `preSharedKey` to your laptops only, they would lose every routing peer and server that does not carry the same key. Only use it when every peer, including routing peers and servers, gets the same key.
- **The pre-shared key is not hidden from local users.** By default, standard users can read the NetBird policy key on Windows, and the macOS managed-preferences file is readable by every user. Treat the key as an extra layer of protection, never as a credential that only administrators know.
- **To roll out Rosenpass, start in permissive mode.** Push `rosenpassEnabled: true` with `rosenpassPermissive: true`, so devices that do not have Rosenpass yet can still connect. Remove permissive mode once every peer runs Rosenpass.
### Support and monitoring keys
These keys turn on remote diagnostics and local metrics. Both features expose information about the device, so the safe default is to leave them off and pin them on only where you need them.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="allowRemoteJobs"></span>`allowRemoteJobs` | boolean | `true`: lets the management server run [remote jobs](/manage/peers/remote-jobs) on this device, such as collecting a debug bundle. Off by default. `false`: pins remote jobs off, so a user cannot turn them on. |
| <span id="debugBundleUploadURL"></span>`debugBundleUploadURL` | string | Where debug bundles produced by remote jobs are uploaded, overriding the location the management server asks for. Must be an `https` URL with a host; any other value is ignored. Treated as a secret and never logged, because the URL can carry credentials or a signed token. |
| <span id="enableLocalMetrics"></span>`enableLocalMetrics` | boolean | Turns on the client's [local Prometheus `/metrics` endpoint](/client/local-metrics), which reports peer connection state, latency, and whether each connection is direct or relayed. |
| <span id="localMetricsAddress"></span>`localMetricsAddress` | string | The address the `/metrics` endpoint listens on. Default `127.0.0.1:9191`. |
- **Pin the upload URL wherever you allow remote jobs.** A debug bundle holds peer names, IP addresses, and logs. Setting `debugBundleUploadURL` to an upload server you run keeps bundles on infrastructure you control.
- **Keep the metrics endpoint on loopback.** The endpoint has no authentication. A user without administrator rights can only bind it to a loopback address, but a policy value is applied exactly as written. A non-loopback `localMetricsAddress` publishes peer names and connection state to anything that can reach the port.
### Connection tuning keys
Most fleets never need these keys.
| Key | Type | What it does |
| --- | --- | --- |
| <span id="lazyConnection"></span>`lazyConnection` | boolean | Overrides what the management server decides about [lazy connections](/manage/peers/lazy-connection) for this device. `true` forces them on and `false` forces them off, for every peer the device connects to. Leaving the key out follows the account's **Lazy connections** setting, which is on by default for accounts created by management v0.74.0 or later; older accounts keep the setting they had. The `NB_LAZY_CONN` environment variable, if set on the device, takes precedence over this key. |
| <span id="wireguardPort"></span>`wireguardPort` | integer | The local UDP port the WireGuard interface listens on. Default `51820`. Values outside `1` to `65535` are ignored, and the client keeps its previous port. |
Set `wireguardPort` only when another program on the device needs the default port, or your firewall rules expect a specific one.
### Keys with no effect yet
The client recognizes these keys, so they do no harm in a payload, but none of them changes anything today.
| Key | Type | Status |
| --- | --- | --- |
| <span id="splitTunnelMode"></span>`splitTunnelMode` | string | `allow` or `disallow`. Intended for per-app split tunneling on Android, which has no MDM channel yet. Ignored on every other platform. |
| <span id="splitTunnelApps"></span>`splitTunnelApps` | string | A comma-separated list of Android package names for `splitTunnelMode`. Same status. |
| <span id="disableMetricsCollection"></span>`disableMetricsCollection` | boolean | Reserved for a future client telemetry feature. Unrelated to `enableLocalMetrics`. |
## Verifying enforcement
On Windows and macOS, ask the daemon what it is enforcing:
```bash
netbird debug config
```
This prints the resolved configuration as JSON, after every layer including the policy. Its `mDMManagedFields` array lists every key in the policy the client reads. For the user device baseline it reads:
```json
"mDMManagedFields": [
"disableProfiles",
"disableUpdateSettings",
"managementURL"
]
```
If a key you sent is missing from the array, the policy did not reach the device, or it used a name the client does not recognize.
`netbird debug config` reads the policy file at the moment you run it, so it shows a change straight away. The daemon applies the change at its next reload, within a minute, and logs it: `MDM policy changed: added=[...] removed=[...] changed=[...]`, followed, while the client is connected, by `MDM policy changed; restarting engine to apply new configuration`. Those lines are the confirmation that the policy is enforced, not just delivered. The client log records the same information on every reload: one `MDM enrolled with N managed key(s): [...]` line, and one `MDM override <key> = <value>` line for each setting it overrode, with secrets redacted. The log is `/var/log/netbird/client.log` on macOS and `C:\ProgramData\Netbird\client.log` on Windows.
On iOS there is no CLI. See [Verifying on a device](/manage/peers/mdm-deployment/ios-mdm-policy#verifying-on-a-device).
## Troubleshooting
Problems specific to one delivery channel, such as a Group Policy that never reaches the registry or a macOS profile that disappears after a reboot, are covered on the [Windows](/manage/peers/mdm-deployment/windows-mdm-policy#intune-troubleshooting), [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#troubleshooting), and [iOS](/manage/peers/mdm-deployment/ios-mdm-policy#troubleshooting) pages. The problems below look the same on every platform.
**`mDMManagedFields` is empty or missing keys.**
Start at the source. Confirm the values are in the registry or the managed-preferences file, using the commands on the platform page. If they are, look for `MDM ignoring unknown` in the client log: the value name does not match any key in the [reference](#policy-keys-reference). Names are matched without regard to case, but must otherwise be exact. `WireGuardPort`, for example, matches; `WgPort` does not.
**A key is listed in `mDMManagedFields` but the setting did not change.**
The value is the wrong type or out of range. The key still counts as managed, so users cannot change the setting, but the client keeps its previous value. The client log has a warning such as `MDM management URL "..." invalid` or `MDM wireguard port ... out of range`. On Windows, check that the value has the [registry type](/manage/peers/mdm-deployment/windows-mdm-policy#value-types) the key expects: a `managementURL` stored as a `REG_DWORD`, for example, is ignored. On macOS, check that booleans are `<true/>` or `<false/>`, not `<integer>`; the client logs no warning for those.
**A user gets `fields managed by MDM cannot be modified: [...]`.**
The policy is working: the user, or a script, tried to change a pinned setting. The keys in the brackets are the pinned settings involved. Change those settings through your MDM instead.
**`netbird up --flag` prints `Already connected` and changes nothing.**
On a connected client, `netbird up` ignores its flags, whether or not a policy is in place. Run `netbird down` first. On a managed device, the change is then rejected if the policy pins that setting.
**A user gets `update settings are disabled, you cannot use this feature without update settings enabled`.**
`disableUpdateSettings` is on, or the service was installed with `--disable-update-settings`. Every settings change is rejected, including changes to settings no key names. Lift the policy to make the change.
**A user gets `profiles are disabled, you cannot use this feature without profiles enabled`.**
`disableProfiles` is on, or the service was installed with `--disable-profiles`. The device stays on its current profile.
**A user gets `network selection is disabled by the administrator`.**
`disableNetworks` is on, or the service was installed with `--disable-networks`.
**Lazy connections do not follow the policy.**
The `NB_LAZY_CONN` environment variable is set on the device, and it takes precedence over `lazyConnection`. Remove it from the service environment.
**Devices lost access to network resources after a policy change.**
Check for `disableClientRoutes: true` or a `preSharedKey` in the policy. The first stops the device using routes; the second stops it connecting to any peer that does not have the same key. See [Device exposure keys](#device-exposure-keys) and [Encryption keys](#encryption-keys).
**A network became unreachable for everyone after a policy change.**
A routing peer received `blockInbound: true` or `disableServerRoutes: true`, and stopped forwarding. Take routing peers out of the policy's scope.
**My MDM is not mentioned on these pages.**
Any MDM that can write a registry value on Windows, push a configuration profile on macOS, or send an app configuration on iOS works, because the mechanism belongs to the operating system, not to NetBird.
## When not to use MDM policy
- **Linux and Android devices.** Neither has an MDM channel in NetBird today. On Linux, set the same options with `netbird up` flags or the service-install flags (`netbird service install --disable-update-settings --disable-profiles --disable-networks`), or [bootstrap the client from a config file](/manage/peers/bootstrap-via-config-file).
- **One device, one change.** To change a setting on a single machine you manage by hand, use the app or the CLI. A policy is for settings that must hold across a fleet and must not drift.
- **Access control.** A policy configures the client on one device. Which peers can reach which resources belongs in [access policies](/manage/access-control), which apply to every device and do not depend on an MDM.
## TL;DR
- **Find the goal, set the key.** [What you can achieve](#what-you-can-achieve) maps each goal to a setting, and [the user device baseline](#the-user-device-baseline) (`managementURL`, `disableUpdateSettings`, `disableProfiles`) is the usual start.
- **Present means pinned, even as `false`.** Every key you leave out stays the user's. The client applies the policy last, locks what it pins, re-reads it every minute, and restarts the connection briefly when it changes.
- **Keep routing peers out of the wrong policy.** Never send them `blockInbound` or `disableServerRoutes`, and send a `preSharedKey` to every peer or none.
- **Verify with `netbird debug config`, then the log.** `mDMManagedFields` shows the policy the client reads; the `MDM policy changed` log line confirms it is enforced.