Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

Settings come from env vars, flags and a YAML config file. The file covers most options; pass it with --config on rooz new (examples).

--config takes a local path or a file in a git repository, <repo-url>//<path-in-repo>, e.g. [email protected]:my/configs//path/in/repo/config.rooz.yaml. Rooz clones the repository with its SSH key, so only SSH URLs work: scp-like git@host:repo or ssh://git@host[:port]/repo (also git+ssh://, ssh+git://). Other schemes are refused.

Rooz never reads config from a repository it clones. Pass it explicitly:

rooz new ws -g <url> --config <url>//.rooz.yaml

Remote config

Config from a remote source (a --config <url>//<path>), and any config that merges a bases layer not in ROOZ_TRUSTED_CONFIGS, may set the fields scoped to the workspace: bases, git_ssh_url, image, shell, command, args, env, install, ports, data, mounts, sidecars, vars, user, init.

These are refused unless you allow them:

FieldWhyTo allow
secretsplaintext lands in a container the config controlslist the source in ROOZ_TRUSTED_CONFIGS (Trusted config sources), or use a local --config file
cachesshared with your other workspacesthe same paths in ROOZ_CACHES (or --caches, which covers rooz new only, not rooz update), or the source in ROOZ_TRUSTED_CONFIGS
extra_reposcloned with your ssh key into the workspacethe source in ROOZ_TRUSTED_CONFIGS, or a local --config file
install step with root: trueruns as root in the workspacethe source in ROOZ_TRUSTED_CONFIGS, or a local --config file (Install steps)
privilegedfull host accessROOZ_ALLOW_PRIVILEGED=<name>@<fingerprint>, required for any config file
sidecar vpnkernel netfilter access as root on a rootful engineROOZ_ALLOW_VPN=<name>@<fingerprint>, required for any config file

Clone transports

Clone URLs (the root repo and extra_repos) accept ssh://, git+ssh://, ssh+git://, scp-like user@host:path, https://, http://, git:// and file://. Anything else is refused, including ftp:// and remote helpers (name::address, e.g. ext::). Remote --config sources take SSH URLs only (see above).

http:// and git:// are unauthenticated, so a network attacker controls the clone. Rooz warns about them; set ROOZ_REQUIRE_SECURE_TRANSPORT=true to refuse them.

Image

Default: docker.io/chainguard/git:latest-dev. Set it with ROOZ_IMAGE, --image, or in a config file:

image: ghcr.io/queil/image:dotnet
shell: [bash]

User

Rooz runs as uid 1000 (--uid on rooz new changes it). The image must have this user, named rooz_user unless you set ROOZ_USER or --user. Names may contain letters, digits, _, - and ., and must not start with - or ..

Home is /home/<user>: the SSH key volume mounts there, and ~/ in mount targets expands to it.

Shell

Default: sh (bash for rooz tmp). Set it with:

  • ROOZ_SHELL, read by rooz new, rooz update and rooz tmp
  • --shell on rooz enter and rooz tmp
  • shell in a config file, as a list, e.g. [bash]

Caches

Shared caches, keyed by path:

caches:
- ~/.nuget

Workspaces with the same cache path share one volume at that path. ROOZ_CACHES (comma-separated, merged with the config) and --caches add more.

Warning

Cache contents (cargo sources, linker config, build scripts) execute in every workspace that mounts the path. So cache paths from remote config (a --config <url>//<path> source not in ROOZ_TRUSTED_CONFIGS) are refused unless you also list them in --caches (on rooz new) or ROOZ_CACHES. rooz update re-reads remote config on every run, so only ROOZ_CACHES covers it. Local --config files are not affected, unless they merge a bases layer not in ROOZ_TRUSTED_CONFIGS.

Cache volumes are scoped per operator, by default the uid running rooz, so operators sharing an engine (a remote DOCKER_HOST, a multi-user socket) get separate volumes: toolchain caches hold executable config such as ~/.cargo/config.toml and ~/.m2/settings.xml. If uids collide across machines on one engine, give each operator a distinct ROOZ_CACHE_SCOPE. The scope is not access control: anyone who can create workspaces on the engine can use any scope.

Dry run

--dry-run on rooz new and rooz update prints the plan and changes nothing:

$ rooz new demo --config demo.rooz.yaml --dry-run
pull     image     docker.io/alpine:latest
create   volume    rooz_demo_work
reuse    cache     rooz_cache_uid-1000_---nuget  ~/.nuget
create   network   demo_egress
create   network   demo_net_db
create   sidecar   db  image=docker.io/postgres:17 install: 00-x[root]
create   work      demo  image=docker.io/alpine:latest user=rooz_user uid=1000 env=PGHOST
run      install   00-tools  root,skip_if

Dry run: nothing was changed.
  • Config is resolved and checked as in a real run (bases, templating, secrets, consent, trust), with the same errors.
  • For update, the plan also lists containers to remove, and volumes with --purge.
  • Env values, install scripts and secrets are not printed; only env names and step names and flags.
  • Rooz queries the engine but creates nothing: no volumes, networks, containers or clones. A --config <url>//<path> source is fetched in a temporary helper container.
  • skip_if runs in the container, so a planned step may still be skipped.

Install steps

install runs shell steps in the work container when the workspace is created. A step is a string or an object:

vars:
  dotnet: 11.0.100-rc.2.26504.105

install:
  00-tools:
    run: apk add jq
    root: true
  10-dotnet:
    skip_if: dotnet --list-sdks | grep -q '^{{ dotnet }}'
    run: ./install-dotnet.sh {{ dotnet }}
  • Steps run in key order as the workspace user, or as root with root: true. Sidecar steps run as the sidecar’s uid (default root).
  • skip_if runs first, as the same user; exit 0 skips the step. Pair it with a cache to avoid repeat downloads. run and skip_if are templated.
  • skip_if sees the container’s env: put cached tools on PATH (via env) or use absolute paths.
  • Containers install side by side: sidecar images build while the work container’s steps run. On a terminal each gets a pane with its current step and last output lines; a finished one collapses to a summary of its steps (✓ ran, ○ skipped, ↺ a sidecar image reused), a failed one leaves its step’s whole output. Elsewhere, or with ROOZ_PROGRESS=plain, every output line is printed tagged [install:<container>:<step>].
  • bases layers add steps by key, replace whole steps, or remove them with null.
  • root: true is refused from untrusted config (a --config <url>//<path> source or bases layer not in ROOZ_TRUSTED_CONFIGS). Trust the source or use a local config.

Sidecar images

A sidecar with install steps is built once: rooz runs the steps, commits localhost/rooz/<workspace>/<sidecar>:latest and starts the sidecar from it.

  • The sidecar runs the committed image by id, not by name, since tags and labels can be forged. The id is recorded on the sidecar container (dev.rooz.runtime-image).
  • rooz update pulls the base image (unless --no-pull) and reuses the built image while the base image id, uid, command, args and install steps are unchanged, skipping the install. rooz update --rebuild builds it again anyway, e.g. when an install step that exits 0 left it broken; volumes are kept. A change to any of them, or --purge, builds it again from the base image.
  • A rebuild starts from the base image, so skip_if sees nothing earlier builds installed. It only skips steps whose output is on a volume or cache.
  • Install steps run without the sidecar’s env, as its uid. Use absolute paths there; env applies when the sidecar runs.
  • Removing a workspace removes its built images.

Privileged containers

privileged: true in any config file (local or git, sidecars included) stops workspace creation and names the containers. To allow it:

  • ROOZ_ALLOW_PRIVILEGED=<name>@<fingerprint> allows exactly that definition of the container (comma-separated for several), e.g. ROOZ_ALLOW_PRIVILEGED=dkr@7f3fa966d667faa1. The refusal prints the value to set. Others are still refused.
  • The fingerprint covers the container’s image, command, args, env, uid, install steps, mounts and the data behind them (secrets masked). Change any of them, or let another config declare its own dkr, and consent is needed again. A bare name (dkr) no longer consents.
  • ROOZ_ALLOW_PRIVILEGED=true allows any. Meant for CI.
  • --privileged true allows it and makes the work container privileged, whatever the config says.

Prefer pinned names over true: pin the docker-in-docker sidecar, not the workspace that uses it through DOCKER_HOST. Pinned names are safe to keep in your shell profile, as they cover only the definition you reviewed; a standing =true also lets remote config add privileged containers.

Warning

Only for configs you trust. For repositories you do not control, prefer rootless Podman, which confines the grant to your user namespace.

VPN sidecars

vpn: true on a sidecar grants what WireGuard (wg-quick, kernel or wireguard-go), OpenVPN and the like need: NET_ADMIN, /dev/net/tun and net.ipv4.conf.all.src_valid_mark=1. The grant is scoped to the sidecar’s network namespace; unlike privileged, it gives no host devices or host network.

It does expose kernel netfilter and netlink, an escape surface on rootful engines, so it requires ROOZ_ALLOW_VPN=<name>@<fingerprint> (or =true for any), pinned like privileged consent. This is separate from ROOZ_ALLOW_PRIVILEGED.

sidecars:
  vpn:
    image: docker.io/linuxserver/wireguard:latest
    vpn: true
    egress: true     # to reach the WireGuard endpoint
    init: false      # the image's s6-overlay must run as pid 1
    mounts:
      /config/wg_confs/wg0.conf:
        content: |
          [Interface]
          ...
  • The sidecar must run as uid 0 (the default). vpn with another uid is refused: NET_ADMIN has no effect for non-root.
  • Kernel WireGuard needs the wireguard module loaded on the engine host; containers cannot load it. Without it, wg-quick falls back to wireguard-go.
  • Full tunnels (AllowedIPs = 0.0.0.0/0) work; the work container still reaches the sidecar over its pair network.
  • Omit DNS =: resolvconf conflicts with the engine-managed /etc/resolv.conf.
  • The tunnel belongs to the sidecar alone. Share it through a proxy in the sidecar, e.g. HTTP_PROXY=http://vpn:3128 for the work container, or through peers:.

Data and mounts

data declares volumes and files; mounts maps container paths to them:

data:
  notes: {}                    # empty read-write volume
  gitconfig:
    content: |
      [url "[email protected]:"]
        insteadOf = gh:
  tls-key:
    generate: openssl genrsa 2048
    image: docker.io/alpine/openssl
    once: true
mounts:
  ~/notes: notes
  ~/.gitconfig: gitconfig
  ~/tls.key: tls-key
  ~/.bash_history:             # inline: no data key needed
    content: ""
    once: true

A data entry is one of:

  • {}: an empty volume, rooz_<workspace>_<key>.
  • content:: a file with this text.
  • file:: a file loaded from next to the config. See Files next to the config.
  • generate:: a file holding the stdout of a sh script, run in a one-shot container. image: picks the image (default docker.io/chainguard/git:latest-dev).

Files also take executable: true (mode 755, else 644) and once: true. They are owned by the user of the container that mounts them, and writable.

A mount maps a path to a data key, or holds the same kinds inline. ~/ expands to the home directory in the work container only. The /work volume is implicit.

A data key names its volume, rooz_<workspace>_<key>, with characters other than letters, digits and - turned into -. Keys that end up alike (a.b and a-b), a key named inline and keys not starting with a letter or digit are refused. Inline mounts are files in one shared volume, one per container and path: the same path inline in the work container and a sidecar gives two files, and two paths of one container that end up alike (a.conf and a-conf) are refused.

Sidecars use the same syntax. A key mounted in the work container and in a sidecar is one volume, so they share it, e.g. a cluster’s admin config written by a sidecar and read by the work container.

Lifecycle

  • Volumes ({}) keep their data across rooz update. rooz rm and rooz update --purge delete them.
  • Files follow the config: rooz update writes every file again, overwriting changes made in the container, and runs generate: scripts again. Moving or renaming a workspace keeps them as they are.
  • A once file is written only when nothing is at its path, then belongs to the containers: history, a generated key, a file a sidecar fills in. Its content is only the initial value; later config changes don’t reach it.
  • content: and generate: are templated; file: is not.

Files next to the config

file: loads content from a file, like a Kustomize config map generator. It works wherever content: does: data, inline mounts and sidecar mounts.

data:
  hello.sh:
    file: files/hello.sh
    executable: true
mounts:
  ~/bin/hello.sh: hello.sh
  ~/.config/starship.toml:
    file: files/starship.toml
  • The path is relative to the declaring config’s directory (a base’s own, for a base) and must stay below it: no absolute paths, no ...
  • Git configs read files from the same checkout.
  • Symlinks anywhere on the path are refused.
  • Up to 64 KiB of UTF-8 text. For larger or binary data, use a volume or generate:.
  • Not templated; use content: for {{ vars }}.
  • The stored config keeps the reference, not the content.

Ports

Work container ports, set in the config file only. The format is [<host port>:]<container port>, as in Docker, bound to 127.0.0.1:

ports:
- "8080:80"
- "8022:22"