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: WorkstationConfigHow it attaches
A config applies to a workstation in one of two ways:
- By selector: its
spec.selector.matchLabelsmatch the workstation’smetadata.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). - 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: trueAttachment fields
| Field | Type | Description |
|---|---|---|
selector | object | {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.ownerScope | bool | Also 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. |
priority | int | Config-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 codenameThe 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.comTool 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/scratchCode 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 dirsContent 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 outThe 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 capTailscale
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 / HeadscalePorts & 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 18080Status
None. A WorkstationConfig has no status: it is consumed by the config-layer merge
when a workstation is resolved.