WorkstationConfig

A reusable configuration layer applied to workstations by selector or explicit reference.

A WorkstationConfig is a reusable configuration layer. It carries the same machine-contents and system-settings fields a Workstation does, plus a selector and a priority. 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.

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>.

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.
priorityintConfig-layer priority (default 50); lower applies first.

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 not merged from config layers. Keep it on the Workstation, not here.

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 · nodejs · claude-code · codex · vscode-web · kind · kubectl · helm · fzf · kubectx · kubens · gcloud · aws · az · git · gh · playwright · passthrough-www-browser.

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"])

Only kind and playwright interpret a config blob today; the other recipes take just an optional version, whose meaning is recipe-specific. 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)

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.

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, or url
    owner: root:root               # "user[:group]"; default root (or the login user for ~/ paths)
    mode: "0644"
    parents: true                  # create parent dirs

Content may contain ${secret:NAME} references.

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, ON by default — set false to opt out

The login user gets passwordless sudo by default. Set sudo: false to create a workstation whose login user cannot elevate — useful for 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'

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

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 (also seed on grantees' devices)
  autoForward:
    forwardAll: true
    portOffset: 10000         # workstation 8080 → host 18080

Status

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