Rooz is a CLI tool that enables you to work in containers. It is intended for developers and DevOps engineers. Because of that, it comes with a built-in support for git repositories, SSH keys generation, support for git-safe secrets, shared caches, sidecar containers, declarative configuration, and a robust CLI. Rooz is designed to share as little as possible with the host so it can also orchestrate your workspaces on remote Docker/Podman hosts.
Rooz requires one of the following container backends:
- Docker >= 29.5.0
- Podman >= 6
- Rancher Desktop - untested atm
curl -sSL https://github.com/queil/rooz/releases/latest/download/rooz-aarch64-apple-darwin -o ./rooz && chmod +x ./rooz && sudo mv ./rooz /usr/local/bincurl -sSL https://github.com/queil/rooz/releases/latest/download/rooz-x86_64-unknown-linux-gnu -o ./rooz && chmod +x ./rooz && sudo mv ./rooz /usr/local/binTo initialize rooz run the following command:
rooz system initThe command creates:
-
an SSH key pair (ed25519) intended to use for auth wherever SSH keys can be used (like github.com)
The generated key gets stored in a volume and then mounted read-only under
~/.sshto all rooz workspace containers, so a workspace cannot overwrite or delete it. Note that workspace containers can still read the key - that is the point of mounting it - and the workspace image and user come from the repository's configuration. Do not open repositories you would not trust with that key. -
an age encryption key pair intended to use for encryption of sensitive config data (i.e. secrets)
The generated key gets stored in the system config volume.
ℹ️ It's important to back up the generated age identity. Run
rooz system configureto view it. If the key is lost all the existing config files with encrypted vars won't decrypt and re-encrypting will be required. To init rooz with an existing age identity use the--age-identityswitch.
You can regenerate the keys by specifying the --force parameter. Please note that the existing keys will be wiped out.
ℹ️ Read more in the Configuration section
Rooz works best with user's provided custom image(s).
You can pass image, shell, and user via cli parameters but it's much more convenient to set it up
in your host's ~/.bashrc (or a non-bash equivalent), and only override it via cli when required.
export ROOZ_USER=queil
export ROOZ_IMAGE=ghcr.io/queil/image:latest
export ROOZ_SHELL=bash
export ROOZ_CACHES='~/.local/share/containers/storage/'Rooz exposes some system config via rooz system configure. At present it only allows to specify .gitconfig used by rooz for cloning. It can be used e.g. to specify aliases. This feature is new and may be extended in the future. It also contains the age key for secret's encryption/decryption.
rooz new myworkspacerooz new -g git@github.com:your/repo myworkspace2rooz enter myworkspace2rooz tmp --image alpine --shell shℹ️ Rooz supports yaml as configuration formats.
Most of the settings can be configured via:
- environment variables
- a config file in the cloned repository (if any) (
.rooz.yaml) - a config file specified via
--config(onrooz new) :information_source: it can be a local file path or a remote git file like:git@github.com/my/configs//path/in/repo/config.rooz.yaml - cmd-line parameters
The configuration file provides the most options: example
ℹ️ the default image is docker.io/chainguard/git:latest-dev
There are a few ways of specifying images:
-
via the
ROOZ_IMAGEenv variable -
via the
--imagecmd-line parameter -
if creating a workspace with a git repository via
.rooz.yamlin the root of that repository:image: ghcr.io/queil/image:dotnet shell: bash
rooz runs as uid 1000 (always - it's hard-coded) so make sure it exists in your image
(with rooz_user as the name - it can be overridden via ROOZ_USER or --user)
The default shell is bash but you can override it via:
ROOZ_SHELLenv var--shellcmd-line parameter (onrooz enter)- in
.rooz.yamlviashell
rooz supports basic path-keyed shared caches. It can be set per-repo like:
caches:
- ~/.nugetAll the repos specifying a cache path will share a container volume mounted at that path enabling cache reuse.
It also can be set globally via ROOZ_CACHES (comma-separated paths). The global paths get combined with repo-specific paths.
ℹ️ Cache volumes are scoped to the operator, not just the path. The scope defaults to the
uid of the rooz process, so one person's workspaces share their caches while unrelated operators on a
shared engine (a remote DOCKER_HOST, a multi-user socket) get separate volumes. Toolchain caches hold
configuration that the toolchain executes (~/.cargo/config.toml, ~/.m2/settings.xml, ...), so sharing
them across operators means sharing code execution.
If several machines share one remote engine and their local uids collide (both 1000, say), set a
distinct ROOZ_CACHE_SCOPE per operator. Note that the scope is a collision boundary, not an
authorization boundary: anyone able to create workspaces on an engine can name any scope.
A privileged container has full access to the host running the container engine, so rooz does not
grant it on a config file's say-so. If privileged: true appears in a config file (in-repo .rooz.yaml,
a --config file, or a sidecar), workspace creation aborts and names the containers involved. To go
ahead, confirm explicitly with either:
ROOZ_ALLOW_PRIVILEGED=<names>- a comma-separated list of the container names you are allowing, e.g.ROOZ_ALLOW_PRIVILEGED=dkr. Pure consent: the configuration is applied exactly as written, and anything not named is still refused.ROOZ_ALLOW_PRIVILEGED=true- allows whatever the configuration privileges. Convenient for CI, blunt everywhere else.--privileged true- consents and makes the work container privileged whether or not the configuration asked for it.
Name the containers rather than reaching for true. The common case is a container-engine sidecar
(docker-in-docker and friends): the daemon's container needs the privilege, the workspace talking to
it over DOCKER_HOST does not. Naming it keeps the setting safe to leave in your shell profile - a
standing =true would wave through a privileged container that a repository's own .rooz.yaml
slipped into the merged configuration, which is exactly what this check exists to stop.
Port mappings for the work container can be specified via .rooz.yaml only:
ports:
- "80:8080"
- "22:8022"Rooz supports basic variable replacement/templating:
- handlebars syntax is used
- variables are declared via
vars - secrets are declared via
secrets - secrets are decrypted before expanding
- handlebars can be used in the most of the config values but not in YAML keys
- vars/secrets replacement works within
varsthemselves too. However, only if the var usage is below the var definition (in the document order). - the secret section does not support var/secrets replacement
secrets:
sqlPassword: '----BEGIN AGE ENCRYPTED FILE-----|YWdlLWVuY3J ... truncated'
vars:
sqlUser: admin
sqlConnectionString: "uid={{ sqlUser }};pwd={{ sqlPassword }}"
env:
SQL_CONNECTION_A: "database=A;{{ sqlConnectionString }}"
SQL_CONNECTION_B: "database=B;{{ sqlConnectionString }}"
sidecars:
tool:
env:
SQL_USER: "{{ sqlUser }}"
SQL_PASSWORD: "{{ sqlPassword }}"
Rooz uses age encryption to safely store sensitive data (like API keys) in config files.
Make sure your rooz has been initiated with rooz system init
Run touch example.yaml and rooz config edit example.yaml. Your default editor should open.
Paste the following config, save & quit.
secrets:
apiKey: 1744420283158995
env:
API_URL: https://api.rooz.dev/v1
API_KEY: "{{ apiKey }}"
Now cat example.yaml and you should see something like the below:
secrets:
apiKey: '----BEGIN AGE ENCRYPTED FILE-----|YWdlLWVuY3J ... truncated'
env:
API_URL: https://api.rooz.dev/v1
API_KEY: "{{ apiKey }}"
Now, we can create a new workspace like:
rooz new secrets-test --config ./example.yamlAnd enter the container and the API_KEY var is value gets decrypted and injected:
rooz enter secrets-test
> echo $API_KEY
1744420283158995Secrets are expanded only when the whole configuration is operator-provided - a local file passed
with --config. If the workspace also merges configuration authored by the repository being opened
(its in-repo .rooz.yaml, or a --config git:... source), rooz refuses to expand secrets and fails
with an explanation.
The reason: every templated field - env, install, command, data content, sidecar env - can
be written by the repository. A repo shipping env: {LEAK: "{{ MY_SECRET }}"} would otherwise receive
your decrypted secret inside a container whose image and entrypoint it also controls. vars keep
working everywhere, since they are not sensitive.
If you hit this, move the settings you need out of the repository's .rooz.yaml and into your own
--config file.
Decrypted secret values are never written to the workspace-config volume. The container's environment
and generated files are populated from the real values at creation time, but the persisted
runtime.config - the file rooz config show <ws> --part runtime prints - has every occurrence of a
secret replaced with ***. Config files in that volume are written owner-only.
ℹ️ shell is the one field left unmasked, because rooz enter executes it. Do not
reference secrets from shell.
It's similar to docker-compose but super simple and limited to bare minimum.
-
roozhas a limited support for sidecars (containers running along). It is only available via the config filesidecars: sql: image: my:sql command: - --some env: TEST: "true" tools: image: my:tools
imagemay be omitted in individual config layers (e.g. an overlay extending a sidecar defined in a base viabases:) but must be set for each sidecar once all layers are merged, otherwise creating the workspace fails.
Workspace networking follows a hub-and-spoke model:
- the work container can reach every sidecar — it shares a dedicated pair network with each one
- a sidecar can always reach the work container (pair-network reachability is symmetric; keep listeners in the work container minimal)
- sidecars cannot reach each other unless explicitly connected via
peers: - workspaces are isolated from each other
peers: lists other sidecar names a sidecar may talk to. Each declared relation gets its own network, a: peers [b] and b: peers [a] are equivalent, and peer names must refer to sidecars defined in the same workspace. Sidecars resolve each other by name over their peer network:
sidecars:
claude:
peers: [proxy] # claude's only egress path is the proxy
proxy:
egress: true # reachable only by the work container and claude
dkr:
peers: [images] # docker-in-docker pulling via the mirror
images:
egress: true # registry pull-through cacheUnlike other list fields (replaced by higher config layers), peers merge as a deduplicated union across layers: an overlay can add reachability edges but cannot remove inherited ones.
default-address-pools with a smaller subnet size (e.g. "size": 24) in daemon.json. Podman (netavark) is not affected.
The work container addresses sidecars by their names (sql and tools in the first example above). The usual container ID and IP work too, but are not as convenient.
- the
entercommand lets you specify--containerto enter (otherwise it enters the work container).
Supported keywords:
image- set containers imageenv- set environment variablescommand- override container entrypoint (ENTRYPOINTin Dockerfile)args- override container entrypoint arguments (CMDin Dockerfile)mounts- mount automatically-named rw volumes at the specified paths (so they can survive container restarts/deletes). It supports:
mounts:
# simple empty rw volumes
- /my_test/data/
# config files with a specified content
- mount: /etc/tool/
files:
config.json: |-
{"some": "config"}
other.yaml: |-
some: configports- port bindings in the"8080:8080"formatpeers- names of other sidecars this sidecar may reach (see Networking; merged as a union across config layers)work_dir- set working directorymount_work(bool) - if true then the work volume is mounted at/work
-
cloned git repos are under
/work/{repo_name}whererepo_nameis the default one generated bygitduring cloning. -
you can enable
roozdebug logging by setting theRUST_LOG=roozenv variable -
if
roozmisbehaves you can go nuclear and runrooz system pruneto remove ALL the rooz containers and volumes. You can also remove just the workspaces, (leaving shared caches volumes, and the ssh volume untouched), by:rooz rm --all --force⚠️ rooz system prunedeletes all your state held withroozso make sure anything important is stored before.
- cache volumes gained an operator scope in their name (
rooz_cache_<scope>_<path>), so caches created by earlier versions are orphaned on first use of the new version. Caches are disposable - they get repopulated, androoz system pruneremoves the leftovers.
- auto-resizing rooz session to fit the terminal window (if resized) is not implemented. Workaround: exit the session, resize the window to your liking, enter the container.
Rooz connects to a local Docker daemon by default. However, it can connect to remote hosts via SSH and forward a local unix socket to the remote's Docker/Podman socket.
-
In
~/.bashrcat the top:- For Docker as root:
export DOCKER_HOST=/var/run/docker.sock - For rootless Docker:
export DOCKER_HOST=/run/user/1000/docker.sock(assuming your-user has uid=1000) - For Podman:
export DOCKER_HOST=/run/user/1000/podman/podman.sock(assuming your-user has uid=1000)
- For Docker as root:
Add to your ~/.bashrc:
export ROOZ_REMOTE_SSH_URL=ssh://your-user@remote-hostexport DOCKER_HOST=unix:///home/your-user/.rooz/remote.sock
Now run: rooz remote. If any remote containers
expose ports, these will be automatically forwarded.
ℹ️ To enable VsCode to attach to remote containers also set the below in settings.json:
"docker.host": "unix:///home/your-user/.rooz/remote.sock"- Make sure podman remote socket is enabled:
podman info should contain the following YAML:
remoteSocket:
exists: true
path: /run/user/1000/podman/podman.sockIf exists: true is missing, try this command: systemctl --user enable --now podman.socket
- Make sure you have the podman socket exposed as the
DOCKER_HOSTenv var like:
export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
-
Use fully-qualified image names or define unqualified-search registries
/etc/containers/registries.conf -
When running more complex podman in podman scenarios (like networking) you may need to run rooz with
--privilegedswitch more info.
