WorkstationConfig

A machine setup you define once and attach to many workstations, by label or by explicit reference.

A WorkstationConfig is a machine setup you define once and reuse: the packages, tools, code, and settings that make a machine ready for a particular kind of work. You attach it to workstations by label, and every matching machine gets it, including machines created later.

Configs exist because setups outlive machines: a team baseline, a project toolchain, and your personal preferences can each be their own config. Several can apply to one workstation, stacked as layers by priority, so a project config refines the team’s and your personal one sits on top.

Concretely, a WorkstationConfig carries the same machine-contents and system-settings fields a Workstation does, plus a selector (which workstations receive it) and a priority (where it sits in the stack). It has no status: it is pure declarative configuration.

apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfig

How it attaches

A config applies to a workstation in one of two ways:

  1. By selector: its spec.selector.matchLabels match the workstation’s metadata.labels. An empty selector matches nothing, so a config with no selector applies only via an explicit reference (it can’t silently reconfigure unrelated workstations).
  2. By explicit reference: the workstation lists it in spec.configs.

How far a selector reaches depends on who authored the config. A config written by an ordinary namespace member applies only to workstations that member owns; a config written by a namespace admin applies across the whole namespace. An admin who wants a namespace-wide config to affect only their own workstations can set spec.selector.ownerScope: true, which additionally requires the workstation’s owner to equal the config owner. An explicit reference (spec.configs) is never gated this way: it’s an affirmative choice by the workstation’s owner.

The personal layer

spec.selector.ownerScope: true with no matchLabels means “every workstation whose owner is me, in this namespace”, the layer that follows the developer rather than the fleet.

apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfig
metadata:
  name: my-setup
spec:
  selector:
    ownerScope: true      # no matchLabels, which is what makes it personal
  dotfilesRepo:
    url: https://github.com/me/dotfiles
  packages:
    - ripgrep
  • It defaults to priority 75, above the ordinary selector default of 50, so your own preference beats a team default without your having to pick a number.
  • Its steps are non-fatal by default. A broken personal step will not take a workstation to Available: False; see Step failure policy.
  • It applies to boxes you own, never to a teammate’s box you have been granted SSH on. That box gets their personal layer.
  • It re-converges live. Editing it reconfigures every workstation you own, not just the next one you create. Preview with rl diff -f before you apply.
  • ownerScope with matchLabels is a different thing: the fleet gesture described above, keeping the ordinary priority and the ordinary failure policy.

Omit the namespace from the manifest and the same file works in whichever namespace you apply it to.

Merge order

When multiple configs (and the workstation’s inline spec) apply, they are merged as config layers in ascending priority order: lower priority applies first, and later layers win on conflict. To see the merged result for a given workstation, run rl workstation get-resolved-configuration <name>.

Give an explicit reference a priority

The 50/75 defaults above apply to a selector match. An entry in a workstation’s spec.configs carries the priority written on the reference, with no default, so - name: team-base with no priority layers at 0, the lowest rung of all, and a personal layer at 75 would override the config you hand-picked for that workstation. Write the number you mean:

spec:
  configs:
    - name: team-base
      priority: 100

The workstation’s own spec is the base, not the top layer

The merge starts from the workstation’s inline spec and applies every layer over it, so a config layer at any priority, including a negative one, wins on environment, toolconfigs and every other last-wins field. Setting a value inline on your own workstation does not override a config that reaches it; author a config layer of your own instead.

Only image, defaultLocalBinding and tailscale are workstation-supreme: the workstation re-applies those at the end, so its own value wins. This is what makes an administrator’s Integration, which compiles to a layer below every hand-written config, still override an inline ANTHROPIC_BASE_URL.

Example

apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfig
metadata:
  name: ide
  namespace: dev
spec:
  selector:
    matchLabels:
      tier: dev
  priority: 100
  image:
    os: linux
    distribution: debian
    version: "13"
  identity:
    shell: /bin/bash
  packages:
    - git
    - name: curl
      updatePolicy: latest
  devtools:
    - name: nodejs
    - name: vscode-web
  ports:
    - 8080
  securityUpdates: true
  defaultLocalBinding:
    enabled: true
    autoForward:
      forwardAll: true

Attachment fields

FieldTypeDescription
selectorobject{matchLabels: {key: value}}: which workstations auto-receive this config. Empty matches none. A member’s selector reaches only workstations they own; an admin’s reaches the namespace.
selector.ownerScopeboolAlso require the workstation’s owner to equal the config owner (default false). Lets an admin self-scope a namespace-wide config to their own workstations. With no matchLabels it makes the config a personal layer.
priorityintConfig-layer priority; lower applies first. Default 50, or 75 for a personal layer.

Every other field below is a config-layer provisioning field, the same set a Workstation may carry inline. They are documented here because a config is where you usually put them. All are optional; unknown top-level keys are rejected.

providerConfig is not a config-layer field

providerConfig (VM sizing, cloud project/zone) is resolved from the Workstation spec only and is never merged from config layers, so declaring it here is refused at apply rather than quietly ignored. Keep it on the Workstation, or, for a fleet-wide default, use a CloudIdentity’s defaultProviderConfig / overrideProviderConfig.

Packages & repositories

packages installs OS packages. Each entry is either a bare name or an object:

packages:
  - git                       # bare name → system package
  - name: ripgrep
  - type: system              # system (default) | npm
    name: htop
    version: "3.2.2"          # optional version pin
    updatePolicy: latest      # pinned (default) | latest (re-upgrade on the slow tick)
  - type: npm
    name: prettier
    bin: prettier             # optional installed-check override (npm)

packageRepositories adds third-party apt repos or Launchpad PPAs before packages install. aptSources is a legacy alias normalized into this shape.

packageRepositories:
  - name: docker              # basename for the keyring + source files
    type: apt                 # apt (default) | ppa   (apk/rpm reserved)
    urls: ["https://download.docker.com/linux/debian"]
    key:
      url: https://download.docker.com/linux/debian/gpg   # or: inline | fingerprint
    apt:
      suites: ["bookworm"]
      components: ["stable"]
      architectures: ["amd64", "arm64"]   # optional; defaults to the machine's arch
      types: ["deb"]                        # optional; default ["deb"]
  - name: deadsnakes
    type: ppa
    ppa:
      ownerName: deadsnakes/ppa
      suite: jammy                          # optional; else derived from the OS codename

The apt block also accepts suiteAliases (remap the resolved OS codename for a repo that lags a new distro release) and options (an escape hatch for extra deb822 keys). A private repo adds auth: {login, password} (each may be a ${secret:NAME} reference).

Devtools & tool config

Two separate channels: devtools installs software, toolconfigs configures it. Each has its own reference section. The summary here is the shape; the detail is one page per tool.

devtools runs curated install recipes, each {name, version?, config?}. The built-in recipes are:

go · golangci-lint · docker · devcontainer-cli · nodejs · python · claude-code · codex · vscode-web · kind · kubectl · helm · fzf · kubectx · kubens · gcloud · aws · az · git · gh · playwright · passthrough-www-browser · spire-agent.

devtools:
  - name: go
    version: "1.22.5"         # recipe-defined; omit for the recipe default
  - name: docker
  - name: claude-code
  - name: kind
    config:                   # opaque, recipe-interpreted (kind, playwright)
      cluster: dev            # cluster name (default "kind")
      ingress: nginx          # nginx (default) | none
      loadBalancer: cloud-provider-kind  # cloud-provider-kind (default) | metallb | none
  - name: playwright
    config:
      browsers: ["chromium", "firefox"]   # engines to install (default ["chromium"])

kind, playwright, vscode-web and spire-agent interpret a config blob; the other recipes take just an optional version, whose meaning is recipe-specific. An entry may also carry fatal and retries; see Step failure policy. Devtools install in declared order, dedupe by name across layers (last-wins on the whole entry), and an unknown name causes setup to fail. See Devtools for every recipe.

toolconfigs configures already-installed tools without reinstalling them, {id?, name, config}, where config is an opaque per-tool blob applied inside the workstation on each configuration pass. The tools that accept a config channel are claude-code, codex, vscode-web, vscode-server, git, and gh:

toolconfigs:
  - name: vscode-web
    config:
      port: 8080
      auth: none              # none | password
  - name: git
    config:
      userName: Ada Lovelace
      userEmail: ada@example.com

Tool entries dedupe by id (falling back to name), last-wins on the whole entry: a higher-priority layer replaces the lower one rather than merging field by field. Values may carry ${secret:NAME} references. See Tool configuration for each tool’s fields.

Workspace trust

trustedFolders lists absolute paths the workstation’s coding tools pre-trust, so they never open with a “do you trust this folder?” prompt. Every sources[].path is trusted automatically; this field extends that set.

trustedFolders:
  - /home/dev/scratch

Code sources

sources syncs code into the workstation, in declared order. A source is a git clone or a local directory stream, synced to path:

sources:
  - name: app
    path: /home/dev/app
    updatePolicy: latest      # pinned (default; clone once) | latest (fast-forward, git only)
    git:
      url: https://github.com/acme/app.git
      ref: main               # branch/tag/commit; empty = remote default
      credentialRef:          # Secret with a deploy key (SSH) or PAT (HTTPS)
        name: gh-deploy-key
        key: token
  - name: local-lib
    path: /home/dev/lib
    local:
      path: /host/checkout/lib   # host dir streamed in (ringleader-on-ringleader)

A source whose repository carries a devcontainer.json can also run it on the workstation: add devcontainer: {enabled: true} to the entry and rl shell lands inside the built container. See Devcontainers for the details and the supported subset of the format.

Scripts

scripts are provisioning steps run in order, after packages and devtools:

scripts:
  - name: setup
    phase: system             # system (root) | user (login user, in $HOME)
    content: |
      apt-get install -y build-essential
    runPolicy: onChange       # once (default) | onChange | always
    watchPaths: ["/etc/foo"]  # onChange: re-run when these change
    failOnError: true         # abort remaining scripts on non-zero exit
    timeoutSeconds: 300       # 0 = no timeout
    env:
      TOKEN: ${secret:ci-token}

runAsDefaultUser: true is the canonical equivalent of phase: user.

A script may take its body from a ConfigMap instead of inlining it:

scripts:
  - name: bootstrap
    phase: system
    contentFrom:
      configMapRef:
        name: bootstrap
        key: setup.sh

contentFrom and content are mutually exclusive: declaring both is refused at apply.

Managed services

services declares long-running units, either a full systemd unit or a command Ringleader synthesizes a unit from:

services:
  - name: api
    command: /home/dev/app/serve --port 3000
    runAs: dev
    enable: true              # start on boot (default true)
    start: true               # start now (default true)
    environment:
      PORT: "3000"

Files

files materializes files on the workstation (after packages, before scripts):

files:
  - path: /etc/foo.conf
    content: "key = value\n"       # or contentBase64, url, or contentFrom
    owner: root:root               # "user[:group]"; default root (or the login user for ~/ paths)
    mode: "0644"
    parents: true                  # create parent dirs
  - path: ~/.config/app/config.yaml
    contentFrom:                   # from a ConfigMap key
      configMapRef:
        name: app-defaults
        key: config.yaml
    removeWhenUndeclared: true     # delete the file when this entry goes away

Content may contain ${secret:NAME} references. Exactly one content source per entry (content, contentBase64, url or contentFrom), and declaring two is refused at apply.

Removing a file when it stops being declared

By default a declared file is write-once: delete the entry (or the whole config that carried it) and the file stays on the workstation. Set removeWhenUndeclared: true to hand the path to Ringleader instead. While the entry is declared the file is materialized as usual, and once the entry leaves the resolved configuration the next configuration pass deletes it.

  • It is opt-in, per entry. A configuration can lose a layer without anyone editing anything, so removing every undeclared file by default would delete healthy files on that observation alone.
  • Withdrawing the flag withdraws the claim on the very next pass.
  • A partial configuration removes nothing. If a contentFrom reference cannot be resolved, the file is left exactly where it is rather than deleted on the strength of an incomplete picture.
  • The file’s content is not checked first. A declared file already has out-of-band edits overwritten on every pass; refusing to delete an edited one would leave a file behind in precisely the case where somebody tampered with it.

Identity & users

identity is the one place the login user is declared; the agent owns user creation:

identity:
  user: dev
  uid: 1000
  gid: 1000
  group: dev
  groups: ["docker", "sudo"]
  shell: /bin/bash            # default /bin/bash
  sudo: false                 # passwordless sudo is on by default; false opts out

The login user gets passwordless sudo by default. Set sudo: false to create a workstation whose login user cannot elevate, which suits a locked-down or shared machine.

Environment & shell

environment:                  # → /etc/environment + /etc/profile.d (system-wide)
  EDITOR: vim
  DB_URL: ${secret:db-url}
dotfiles:                     # path → content, written as managed blocks
  "~/.bashrc": |
    alias ll='ls -la'

Dotfiles repository

dotfilesRepo clones your own dotfiles repository into the workstation and installs it by running the repository’s own install script. It is the natural companion to a personal layer.

dotfilesRepo:
  url: https://github.com/me/dotfiles
  ref: main                   # branch/tag/commit; empty = the remote's default branch
  path: .dotfiles             # HOME-relative checkout dir (default .dotfiles)
  installScript: setup.sh     # repo-relative entry point; empty = probe (see below)
  updatePolicy: pinned        # pinned (default; clone once) | latest (fast-forward each pass)
  timeoutSeconds: 120         # bounds the clone AND the install script, separately
  credentialRef:              # a Secret with a deploy key (SSH url) or a PAT (HTTPS url)
    name: dotfiles-deploy-key
FieldTypeDescription
urlstringRequired. https://, http://, ssh://, git@host:path or git://.
refstringBranch, tag or commit. Empty clones the remote’s default branch.
pathstringHOME-relative checkout directory. Default .dotfiles.
installScriptstringA repo-relative script to run instead of probing. It is executed as a file inside the checkout, never evaluated as a command line.
credentialRefobject{name, key} naming a Secret, for a private repository.
updatePolicystringpinned (default) clones once; latest fast-forwards on each configuration pass.
timeoutSecondsintBounds the clone and the install script separately. Default 120.
fatalboolDefault false, so a broken dotfiles install never blocks Configured.
retriesintExtra attempts within one pass (0–5).

How it installs

Everything runs as the login user, so the checkout and everything it writes belong to them, never root-owned files in someone’s home.

  1. Clone (or fast-forward, for updatePolicy: latest) into $HOME/<path>.
  2. Run the install script: your installScript when set, otherwise the first of these that exists and is executable, with the checkout as the working directory: install.sh, install, bootstrap.sh, bootstrap, setup.sh, setup, and then the same six again under script/.
  3. With no install script, fall back to a symlink sweep: every top-level entry beginning with . is linked into $HOME (directories included, so .config/ works), skipping .git* except .gitconfig, which is linked.

An existing path is never overwritten. The sweep skips anything already at the destination, which is what makes it safe to re-run on every configuration pass and keeps it from displacing a file the dotfiles map, a files[] entry or a toolconfig owns.

Host-key trust is the workstation’s own. The clone uses whatever sshKnownHosts pinned; it never disables host-key checking.

One repository per workstation. A higher-priority layer replaces the whole block rather than merging into it, including the workstation’s own inline spec, which claims no override here. This field’s natural author is a personal layer, so a box’s own manifest does not quietly beat it.

It is not a sources[] entry. A sources[].path is pre-trusted by the workstation’s coding tools, and a dotfiles checkout is not a working folder.

Step failure policy: fatal and retries

Most provisioning entries accept two extra keys: fatal and retries. They are supported on scripts, sources, packages, devtools, files, services and dotfilesRepo.

FieldTypeDescription
fatalboolDoes this step’s failure block the workstation reaching Configured (and therefore Available)?
retriesintExtra attempts within one configuration pass after a failure. Clamped to 0–5; a negative value means none.

Defaults:

StepDefault fatal
scripts, sources, packages, devtools, files, servicestrue
the same steps contributed by a personal layerfalse
dotfiles (the map) and dotfilesRepofalse

An unset retries is 2 for a non-fatal step and 0 for a fatal one. A fatal step already gets re-attempted by the workstation’s own retry schedule, since it fails the pass and the next pass comes quickly, whereas a non-fatal failure does not raise that alarm, and the in-pass attempts are what fix a transient failure at boot.

fatal is not failOnError

scripts[].failOnError decides whether the rest of the pass is skipped. fatal decides whether this step’s failure blocks Configured. They are independent and both are honored.

Two residues worth knowing: entries merged into one step across layers (the dotfiles map, environment, sysctls, hostname, timezone, locale, limits) carry no per-entry policy, and the package phase’s shared index refresh stays fatal even when the package that triggered it is not.

Workstation capabilities

capabilities declares what a workstation’s own identity may do: the box acting for itself, distinct from you acting on it.

capabilities:
  - self:read
  - self:power
CapabilityWhat it allowsDefault
self:readThe box reads its own Workstation record: phase, conditions, status.on
self:powerThe box writes spec.stopped / spec.restartNonce on its own record, so it can stop or restart itself.off
sa:assumeThe box may assume a ServiceAccount at all. Which one is decided by that account’s own trust rules.off

An unknown name is refused at apply, so a typo cannot quietly declare nothing.

Both tiers must agree, and neither can widen past the owner. What a box ends up holding is the intersection of what admin-authored layers declare and what member-authored ones do (the box’s own spec counts as member-authored), and it is then bounded by what the box’s owner is themselves permitted to do. So:

  • An admin’s layer grants; declaring one extra capability does not silently revoke self:read, because the defaults are the base rather than a competing term.
  • An admin’s layer declaring capabilities: [] opts the namespace out of the defaults entirely.
  • A member can only narrow. Naming a capability no admin granted yields nothing; naming a subset narrows to that subset.
  • If no member-authored layer mentions the key at all, the member tier constrains nothing.

Layer priority is irrelevant here: an intersection is order-free, and merge order is not what decides an authorization.

System settings

hostname: dev-box
timezone: America/New_York    # IANA name
locale: en_US.UTF-8
sysctls:                      # → /etc/sysctl.d/
  vm.max_map_count: "262144"
limits:                       # pam_limits → /etc/security/limits.d/
  - domain: "*"
    type: soft                # soft | hard | -
    item: nofile
    value: "65536"

Outbound SSH trust

sshKnownHosts pins host keys into ~/.ssh/known_hosts for outbound SSH. Each entry is a bare hostname (resolved from a built-in catalog) or {host, keys}:

sshKnownHosts:
  - github.com                     # catalog-resolved
  - host: git.internal
    keys: ["ssh-ed25519 AAAA…"]

Security updates & diagnostics

securityUpdates: true         # unattended OS security upgrades (Debian; no-op on Alpine)
diagnostics:
  ledger:
    enabled: true             # default true
    retain: 30d               # default 30d
    maxOutputBytes: 65536     # per-step stdout/stderr cap

maxOutputBytes caps each step’s stdout and stderr. A value above 128 KiB resolves to 128 KiB.

Tailscale

Join the workstation to a Tailscale tailnet. The join credential is always a Secret reference. An inline key is rejected.

tailscale:
  enabled: true
  authKeyRef:
    name: tailscale-authkey
    key: authkey
  ssh: true                        # --ssh
  hostname: dev-box                # --hostname
  tags: ["tag:dev"]                # --advertise-tags (must match the key's ACL tags)
  advertiseRoutes: ["10.0.0.0/24"] # subnet router
  acceptRoutes: true
  exitNode: "100.x.y.z"            # route egress via this node
  exitNodeAllowLANAccess: true     # keep the local LAN reachable while using an exit node
  advertiseExitNode: false         # make THIS workstation an exit node
  acceptDNS: true                  # MagicDNS (default on)
  loginServer: ""                  # custom control plane / Headscale

Ports & forwarding

ports declares the workstation’s listening ports. defaultLocalBinding is a template the daemon uses to seed a LocalBinding once, when the workstation first reaches Running, so ports forward to your device automatically. See the LocalBinding reference for the full autoForward/ports/sockets/urlForward shape.

ports: [8080, 3000]
defaultLocalBinding:
  enabled: true
  scope: owner                # owner (default) | accessors (grantees' devices seed it disabled)
  autoForward:
    forwardAll: true
    portOffset: 10000         # workstation 8080 → host 18080

Restricting outbound connections

By default a workstation reaches anything your network routes. egress narrows that to an allowlist, enforced by your cloud’s own firewall, by an edge instance, or on your own machine, on hcs, vz and qemu, by the workstation’s own network process or an edge VM. Each of those sits outside the workstation, so nothing running on it (including its own root) can undo the restriction.

egress:
  enforcement: strict           # strict | bestEffort; required, no default
  destinations:
    - cidr: 10.20.0.0/16        # a network, or a bare address
    - cidr: 203.0.113.7
      ports: [443, 5432]        # defaults to [443]
    - host: github.com          # a name; 80 and 443 only
    - host: "*.githubusercontent.com"

enforcement is required and has no default: “my egress policy is enforced” must never be something you believe by accident.

ValueMeans
strictA workstation whose provider cannot hold this policy is refused when you apply it, so you find out while you can still change what you wrote. The workstation must name its provider. Once it is running, a strict workstation is never failed for its policy: while no Edge serves a policy that needs one, the workstation is held closed.
bestEffortThe workstation is set up either way, and any shortfall is reported on its EgressEnforced condition.

Each destination is a cidr or a host, never both:

FieldNotes
cidrA network in prefix form (10.20.0.0/16) or a bare address. A network with host bits set below the prefix length is refused. Any port. Behind an edge instance, a private range cannot be reached on GCP and Azure, and a range inside the VPC cannot be reached on AWS.
hostA name, optionally with a leading *. wildcard. Only ports 80 and 443, because a name is read off the connection itself: the TLS server name on 443, and the HTTP Host header on 80. Enforcing a name needs an Edge serving the workstation’s provider and region. On hcs, vz and qemu, the workstation’s own network process enforces it with no Edge. See Where it can be enforced. A name that resolves to a private address is refused. To reach another port, or to have the cloud firewall hold it directly, declare the address range as a cidr.
portsTCP ports, 1–65535. Defaults to [443].

Omitting the block entirely means no restriction. An empty block is not the same thing: it is a policy, and it is refused, as is one naming no destinations at all. A policy permitting nothing would keep the workstation from reaching its own control plane.

You never have to list the infrastructure a workstation needs to come up and stay managed. Those destinations are added to every compiled policy automatically, so a policy cannot accidentally cut the workstation off from Ringleader itself.

Ringleader also adds an attached Integration’s endpoint, a host that receives an injected credential, and an MCP server’s host. It adds each one only on the ports it needs. An inspect: true rule is different: it checks a host that the policy already allows, but it does not add that host to the allowlist. rl workstation describe names the Integration beside each destination it added.

Where it can be enforced

Enforcement is a property of the provider. On the cloud providers (AWS, Azure and GCP) it is a firewall object your cloud applies to the VM, compiled from the policy. Those objects match addresses, so a cidr destination is held exactly by the cloud itself. The local providers answer differently, and the end of this section says how.

A host is a name, and a firewall that matches addresses cannot decide on a name: the name appears on the connection, not in the packet’s destination address. Enforcing one needs something that reads the name off the connection as it passes. That is what an Edge does. It is a VM that a namespace administrator declares once per cloud and region, and the workstations in that region send their traffic through it.

So what a host destination gets depends on whether an Edge serves your workstation’s provider and region. The last case costs you the whole policy, not just the names, so read it before you declare a host:

What happens
An Edge serves itThe name is enforced on ports 80 and 443, by the edge instance reading it off the connection.
An Edge is declared and not serving it yetThe workstation is held closed, whatever its enforcement, and reports EgressEnforced: False with reason GatewayNotServing.
No Edge is declaredThe workstation reports EgressEnforced: False with reason NoEdge, and the message names the provider and region. With bestEffort, nothing in the policy is enforced, your cidr destinations included, because a rule set is built whole or not at all. A workstation that was already enforcing an earlier policy keeps that one instead, and the message says which. If that earlier policy was enforced behind an edge instance, keeping it restricts nothing. With strict, the workstation is held closed.

A workstation that is held closed reaches only what it needs to stay managed by Ringleader, and the endpoints of its attached Integrations.

A strict policy can name a host on a cloud workstation when the same namespace declares an Edge for that provider. This check does not need a matching region or a ready edge instance, and an Edge that is being deleted does not count. Enforcement still depends on an edge instance serving the workstation’s region and holding its policy. Read EgressEnforced for the result.

On a local workstation the answer depends on the provider. An hcs workstation, on Windows, needs no Edge. Its own network process enforces names and addresses, so a strict policy is accepted when you apply the workstation. An Edge cannot be declared for hcs because the process is the workstation’s Edge.

A vz workstation, on a Mac, needs no Edge. The network process Ringleader runs for it enforces the policy, names and addresses both, as an edge instance does on a cloud. A strict policy on vz is accepted when you apply the workstation, with or without an Edge. A namespace administrator can declare an Edge for vz to run an edge VM on your machine instead.

A vz workstation created before vz gave each workstation its own address cannot be served by its own network process, and it cannot sit behind an edge VM either. With no Edge, a bestEffort one runs unrestricted and reports NoEdge, and a strict one is held closed. Delete it and create it again.

A qemu workstation, on Linux, works the same way. The network process Ringleader runs for it enforces the policy, names and addresses both, and a strict policy is accepted with or without an Edge. A namespace administrator can declare an Edge for qemu to run an edge VM instead. A qemu workstation created before qemu gave each workstation its own address, or before Ringleader could set up the network cards its own Edge needs, is served neither way: with no Edge, a bestEffort one runs unrestricted and reports NoEdge, and a strict one is held closed. Delete it and create it again.

lima and wsl2 enforce nothing outside the machine. A workstation on one accepts a bestEffort policy, is set up, and reports ProviderCannotEnforce for everything it declared: the restriction is recorded on the workstation and not in force. strict on such a workstation is refused at apply.

An administrator can also put a floor under this that a member cannot widen; see the egressDestinations rules in Policy. The merge that combines configuration layers unions destinations, so a layer can only ever add to what a workstation may reach; the bound has to come from a policy instead. The same merge keeps the stricter enforcement, so one layer declaring strict makes the whole policy strict. A WorkstationConfig is never refused for the workstations it reaches. Each workstation is checked when you next change its own egress, provider, labels or configs, and reports what is held on EgressEnforced meanwhile.

The onboarding assets grant the cloud-side permission this needs, on by default. See Cloud Onboarding.

What a policy does not reach

On a cloud workstation a policy applies to addresses inside your own network as well as to the public internet. On a local provider, read Where it can be enforced for what is held and what is not.

The destination to plan around is your cloud’s instance metadata endpoint at 169.254.169.254, which hands out the VM’s own cloud credentials. On GCP the cloud serves that address to the VM underneath the firewall it applies, so the workstation reaches it whatever the policy says, and a policy is not what keeps a GCP workstation away from its own instance credentials. AWS and Azure serve the same endpoint. Whether a policy can close it there is a property of those clouds, so read their documentation rather than assuming a policy covers it.

Status

None. A WorkstationConfig has no status: it is consumed by the config-layer merge when a workstation is resolved.