Adopting a repo as a Workspace
How to take a repository that has (or needs) a devcontainer and run it as a billet Workspace on a shared Host. Onboarding is two halves that change hands cleanly:
- Repo-side — one PR to the repository, giving its devcontainer an in-container
sshd published to the VM loopback. Template files live in
templates/workspace/. - Operator-side — one
[workspaces.<key>]block in~/.config/billet/config.toml, thenbillet add/start/ssh-config/connect.
billet's own .devcontainer/ is the reference implementation: billet runs itself as a
Workspace beside gswa-backend on one Host, and the templates are extracted from it.
What billet reads from the repo (the contract)
billet never duplicates container facts into its config. It reads five fields live from
the repo's .devcontainer/devcontainer.json on the Host
(ADR-0002):
| Field | Used for |
|---|---|
service |
which compose service is the Workspace container |
dockerComposeFile |
the compose file(s), resolved relative to .devcontainer/ |
workspaceFolder |
where postCreateCommand / verify_cmd run |
remoteUser |
the in-container user connect lands as |
postCreateCommand |
the bootstrap billet runs on every billet start |
Everything else the repo's compose stack must provide itself — most importantly a way
in: billet connect reaches the container by SSH via ProxyJump through the Host, so the
container runs its own hardened sshd published to the VM loopback at the port billet
assigns (ADR-0003).
devcontainer features are not applied. billet drives the stack with raw
docker compose, not the devcontainer CLI — the features block in
devcontainer.json is VS Code tooling and does not run under billet start. Any tool
a feature would install (e.g. gh) must be baked into the image or added to
postCreateCommand — which is why gh and az ship as
opt-in image recipes rather than as a features entry.
Repo-side: the PR to the repository
Copy these from templates/workspace/
verbatim into .devcontainer/:
sshd.conf— key-only / non-root /dev-only sshd drop-in, host keys on a named volume.dev-entrypoint.sh— three jobs. It repairs the ownership of named-volume mount targets under/home/devthat came up root-owned and empty, and ensures~/.ssh(see Locker ownership at mount time below); it generates the persisted host keys on first boot,sshd -tfail-fasts, starts sshd via sudo, thenexec "$@"; and, just before sshd starts, it snapshots the container's own environment into/etc/environmentso imageENVand composeenvironment:values are visible in sshd login shells.berth.version— the Berth version these files carry (one integer; see the templates README). The entrypoint reads it from beside itself and logsdev-entrypoint: berth=Non every start, so copy it every time you re-copy a Berth file; a missing file logsberth=unknown.authorized_keys-stub— tracked empty fallback, mounted wheneverBILLET_AUTHORIZED_KEYSis unset, so a build away from the VM never hard-fails. Under billet it is never unset:startexports the Host admin user's~/.ssh/authorized_keysbefore every compose call, so the same key that opens the Host opens the container with no file to copy.
That /etc/environment snapshot is how non-secret image and compose environment reaches
billet connect, tmux, and the fleet runners: an sshd login shell inherits nothing from the
container's PID 1, so the entrypoint republishes the values into the file pam_env reads on
every session (the ADR-0003 amendment has
the mechanism and its limits — the file is world-readable, and a value containing ", a
backslash, or a control character is skipped with a warning). It is not a secret channel:
credentials keep travelling through ~/.claude/settings.json
(ADR-0006), never compose environment:.
Credential-shaped variables are withheld (Berth 2)
From Berth 2 the snapshot is also a backstop for a credential put in compose environment:
anyway: a variable whose name looks like one (*TOKEN*, *SECRET*, *PASSWORD*, *_KEY,
*_PAT and the rest of the list in the
ADR-0003 amendment),
or whose value is a URL carrying a password (scheme://user:password@…), is left out of
/etc/environment. The container log names it, never its value:
dev-entrypoint: withholding REDIS_URL (looks like a credential; list it in BILLET_ENV_PUBLISH to publish)
The variable is still in the container's own environment (docker compose exec sees it);
only ssh sessions lose it. If they need it — a dev-only database password that tests read
over ssh, say — list the exact name in BILLET_ENV_PUBLISH on the same compose service,
with a comment saying why:
environment:
# Dev-only values, committed in plaintext by design; tests run over ssh read them.
BILLET_ENV_PUBLISH: "REDIS_URL APP_DB_PASSWORD"
Names are space-separated and exact; there are no globs. The list lifts the credential check
only: it cannot publish a per-session name such as HOME or PATH, nor a value the file
cannot express, and BILLET_ENV_PUBLISH itself is never published. Listing a name that is
unset or not credential-shaped does nothing. A misspelt name also does nothing, so the real
variable stays withheld and keeps logging its line.
Locker ownership at mount time (Berth 1)
A Locker is a named compose volume that persists one tool's state under the login
user's home — <service>_claude_home on ~/.claude, <service>_gh_config on
~/.config/gh, <service>_azure_home on ~/.azure. Docker initialises a fresh volume from
whatever the image has at the mountpoint; when the image has nothing there, the directory
comes up root:root and the tool cannot write it. From Berth 1 the entrypoint, not the
image, guarantees ownership
(ADR-0013): at every start,
before generating host keys, it reads /proc/self/mountinfo and applies one policy to
every Docker named volume mounted under /home/dev:
| Target state | What happens | Log line |
|---|---|---|
| directory, owned by uid 0, empty | sudo -n install -d -o <uid> -g <gid> -m 0700 |
dev-entrypoint: repaired <path> (was root:<group> <mode>) |
| directory, owned by uid 0, populated | left alone | dev-entrypoint: warning: <path> is root-owned and not empty; not repaired |
| directory, owned by another uid | left alone | dev-entrypoint: warning: <path> owned by uid <n>; not repaired |
| directory, owned by the login user | left alone, mode included | none |
directory that cannot be stated |
left alone | dev-entrypoint: warning: cannot stat <path>; not repaired |
| directory whose entries cannot be listed | left alone | dev-entrypoint: warning: cannot read <path>; not repaired |
missing, where the rule is applied with creation (~/.ssh) |
sudo -n install -d -o <uid> -g <gid> -m 0700 |
dev-entrypoint: created <path> |
| not a directory, or missing without creation | skipped | none |
| the repair or the creation fails | sshd still starts | dev-entrypoint: warning: repair of <path> failed; continuing |
Every line carries a dev-entrypoint: prefix; the warnings carry a further warning:
and go to stderr. A root-owned, 0700, empty directory takes the cannot read row
rather than the repair row — the entrypoint runs as the login user, so it cannot list the
directory and cannot establish that it is empty. Docker's own mountpoints come up 0755, so
every target this exists for still takes the repair row.
~/.ssh gets the same rule applied to a known path (created dev-owned 0700 if missing),
because sshd's authorized_keys bind mount lives under it; it is Berth infrastructure,
not a Locker. Bind mounts are skipped by source. Nothing is recursive and nothing is
chown -R. The consequence for the repo is that a Locker is one compose volumes: line
and nothing in the Dockerfile: the image need not pre-create the mountpoint, and the
Dockerfile.snippet keeps only ~/.ssh.
Migration for a Workspace already bitten by this — a Locker whose first use failed with
Permission denied — is nothing more than re-copying the Berth 1 entrypoint (with
berth.version) and running the next billet start: an already-broken empty volume is
repaired on that start, and no docker volume rm is involved. A populated root-owned volume
is only reported; decide whose files they are before re-owning them by hand.
Then merge the two snippets:
docker-compose.snippet.ymlinto the repo's compose service: the127.0.0.1:${BILLET_CONTAINER_SSH_PORT:-<port>}:22publish, the entrypoint wiring,init: true, the${BILLET_AUTHORIZED_KEYS:-…}bind mount ofauthorized_keys, the host-keys named volume, and the Claude Locker —<service>_claude_homeon~/.claudewithCLAUDE_CONFIG_DIR: /home/dev/.claudeunderenvironment:. The Locker is where the token billet injects lands (ADR-0006); the variable pinsclaudeto the same directory, and its value is fixed because the injector hardcodes~/.claude/settings.json. No image-side mountpoint is needed: the entrypoint re-owns a fresh Locker at start (ADR-0013). Use the Workspace's own assigned port as the interpolation default so a manualdocker compose upon the VM cannot collide with another Workspace's port; billet always exportsBILLET_CONTAINER_SSH_PORTbefore compose, so the default never applies under billet. Theauthorized_keysmount interpolatesBILLET_AUTHORIZED_KEYSthe same way — also exported by billet — falling back to the tracked empty stub so a build away from the VM never hard-fails.Dockerfile.snippetinto the dev-container image:openssh-server+sudo, a non-rootdevuser (uid/gid 1000 — matches the VM admin user so the bind mount needs no chown), a pre-created~/.ssh(0700, dev-owned, so the runtimeauthorized_keysbind mount is StrictModes-clean), and theCOPYofsshd.confinto/etc/ssh/sshd_config.d/.
Both snippets carry nothing beyond what every Workspace needs — no toolchain, and no
authentication tooling. The one Locker the compose snippet ships is Claude's, because every
Workspace receives a token; gh and az Lockers are the volume part of their recipes.
Optional: auth tooling (gh, az)
A Workspace that runs gh or az opts in by merging a recipe from
templates/workspace/auth-tooling/.
Take gh, az, both, or neither — billet itself neither installs these CLIs nor reads
their credentials (ADR-0011).
A recipe is two parts, and both are required:
| Part | Snippet | Why it is not optional |
|---|---|---|
| Binary — the CLI, in the image | <tool>.Dockerfile.snippet |
A feature will not install it (see the warning above), so the binary otherwise lands in ~/.local/bin by hand — which is on no volume, so every compose up --build wipes it |
| Locker — its credentials, on a named volume | <tool>.docker-compose.snippet.yml |
The token is written to the container filesystem, so without the volume every rebuild demands gh auth login / az login again |
gh mounts <service>_gh_config on ~/.config/gh, az mounts <service>_azure_home on
~/.azure — the same persistence pattern as <service>_claude_home
(ADR-0006). There is no third, image-side part:
the entrypoint re-owns a fresh Locker at container start
(ADR-0013), so a Locker is
one compose volumes: line and nothing else. Adopting only one part looks fine until the
next rebuild, which is exactly when it is hardest to connect to the merge that caused it.
auth-tooling/README.md
has the merge detail: where the binary part goes, why the az binary part requires a
consumer-built image (the shared toolchain image never ships azure-cli), and the caveat
that nothing migrates a running container's existing token onto the fresh volume — so
adopting a recipe costs one last gh auth login / az login.
Dotfiles: chezmoi (the standard)
Workspaces standardize on chezmoi-managed dotfiles. Bake the chezmoi
binary into the dev-container image (billet's own Dockerfile pins it into /usr/local/bin),
and pull the dotfiles at container start with chezmoi init --apply rinman24 (first time) or
chezmoi update --apply (thereafter). billet's global personal_bootstrap_cmd does exactly
this on every billet start; billet's own devcontainer.json postCreateCommand repeats it
so a direct devcontainer open gets dotfiles too. Both paths converge on the same
rinman24/dotfiles repo, which owns the tmux
config — so no tmux config is baked into any image.
Rendering billet's Workspace identity (the consuming half)
billet never writes status-style, status-left, or any other presentation option — the
theme owns those (ADR-0008). Instead
connect publishes three tmux user options into the session, and your own tmux config
decides whether and how to render them:
| Option | Value |
|---|---|
#{@billet_workspace} |
the Workspace key |
#{@billet_host} |
the Host key |
#{@billet_color} |
the Workspace's status_color — hex, with its leading #; unset when the block omits it |
Consuming them takes two primitives (verified on tmux 3.7b):
#{?#{@billet_workspace},…present…,…absent…} # ternary guard: unset options expand to ""
#[bg=#{@billet_color}] # correct — expands to #[bg=#C05CE0]
#[bg=##{@billet_color}] # WRONG — ## is tmux's literal-# escape, so
# this yields the uninterpolated text
# #[bg=#{@billet_color}]
The color already carries its #; interpolate it directly and never double it. Guard every
segment with the ternary so the same config still works in a plain shell tmux, where the
options do not exist.
Rendering is optional. tmux_session defaults to the Workspace key, so stock tmux's default
status-left of [#{session_name}] already tells you which Workspace you are in, and
tmux show -g @billet_workspace answers it exactly.
Worked example: a one-line status segment
Nothing below depends on a theme or a plugin manager — the options are plain tmux user
options, so any config can read them. The most portable form is a single append to
status-right:
set -ag status-right '#{?#{@billet_workspace},#[#{?#{@billet_color},bg=#{@billet_color}#,fg=#11111b,default}] #{@billet_workspace}#{?#{@billet_host}, @ #{@billet_host},} #[default],}'
The outer ternary drops the segment whole when billet published nothing. The inner one styles
the label with the brand color when status_color is set and falls back to default when it
is not, so the label still renders on an uncolored Workspace — #, is the escape for a
literal comma inside a ternary branch, and #11111b is a near-black picked to stay legible
on the brand hues. Expansions (tmux display-message -p '#{E:status-right}', tmux 3.7b):
| published | segment expands to |
|---|---|
| workspace + host + color | #[bg=#C05CE0,fg=#11111b] billet @ devbox #[default] |
| workspace + host, no color | #[default] billet @ devbox #[default] |
| workspace only | #[default] billet #[default] |
| nothing | (empty) |
Live state is yours, not billet's
Those three options are the whole set, and the set is closed
(ADR-0009). A fourth is admitted only if it is
in hand on the connect path with no new I/O, stable across connects, not derivable more cheaply
in-session, and identity rather than telemetry. Host power state, public IP and container
running state each fail at least one of those, and billet ls already reports host IP and
running state — a status bar is not where they are needed.
Branch and dirty state is the thing you will most want on the bar, and it is the clearest
non-candidate. The prelude leads new-session -A, so it re-publishes on every billet connect
— but nothing updates the options between connects: connect execvps into ssh and leaves
no billet process behind. A published branch name would be honest until your next checkout and
stale after it. It is also the cheapest thing to compute in-session, so put it in your own
config as a #() segment:
set -ag status-right '#(git -C "#{pane_current_path}" rev-parse --abbrev-ref HEAD 2>/dev/null | sed "s|.*| &|")'
git -C against the pane's own directory, 2>/dev/null so a pane outside a repo renders
nothing, and the sed supplies the leading space only when there is a branch — so the segment
disappears whole rather than leaving a gap. (Do not verify this one the way you verified the
options above: display-message -p does not run #() jobs, so it expands the segment to
nothing whether or not the segment works — to prove the job fires, give the command a side
effect such as | tee -a /tmp/probe and tail that file instead.)
The cost is one fork per attached client per status-interval. Measured on tmux 3.7b: 6
invocations in ~6 s at status-interval 1, and 0 in ~8 s at status-interval 15. The cadence
is your dial — a one-second interval with a #() segment is a fork per second per attached
client, paid inside the container, for as long as the client stays attached.
Sanity checks before merging the PR:
devcontainer.jsondeclaresservice,dockerComposeFile,workspaceFolder, andremoteUser: dev, and itspostCreateCommandfully bootstraps a cold container.- Nothing the repo needs day-to-day hides in a
featuresblock (see the warning above). - If the repo's workflow uses
ghoraz, both parts of that recipe are merged — the binary into the Dockerfile (or already in the image), and its Locker both mounted on the service and declared under the compose file's top-levelvolumes:. A volume that is mounted but never declared fails atup, i.e. only on the VM. - The compose service's default command keeps the container alive (
sleep infinity).
Operator-side: config + first start
Add the Workspace to ~/.config/billet/config.toml — the annotated example block in
config.example.toml
documents every key:
[workspaces.my-repo]
host = "devbox"
repo_url = "git@github.com:my-org/my-repo.git"
repo_dir = "my-repo"
container_ssh_port = 2225 # distinct per Host; `billet add` validates
host_alias = "gswa-devbox" # same alias as the shared Host
container_alias = "my-repo-container" # distinct per Workspace
verify_cmd = "make test"
Before the first start, make sure the key that opens the Host is loaded in your ssh
agent. billet forwards the agent and runs every Host-side git non-interactively, so a
passphrase-protected key that is not already unlocked cannot be used and cannot prompt —
the clone fails instead of asking. ssh-add -l lists what the agent holds; ssh-add
~/.ssh/<key> (macOS: ssh-add --apple-use-keychain ~/.ssh/<key>) loads it.
Three keys carry the tricks:
repo_url— must authenticate non-interactively from the Host: an ssh URL reached over the agent billet forwards, never anhttps://URL that would ask for a username.startis unattended, so every Host-side git runs with prompts disabled and an unauthenticated remote fails in seconds naming the cause, instead of hanging. If the Host checkout'soriginlater drifts from this value, billet warns and leaves the remote alone — repointing it is yours to do (ADR-0007 amendment).container_ssh_port— pick the next free loopback port on that Host;billet addrejects a duplicate. Use the same number as the compose default you put in the repo.host_bootstrap_cmd— optional; a general hook that runs inrepo_diron the Host before everycompose up, defaulting to a no-op (":"), which is why the block above omits it. Nothing aboutauthorized_keysbelongs here: billet exportsBILLET_AUTHORIZED_KEYS(the Host admin user's~/.ssh/authorized_keys) andBILLET_CONTAINER_SSH_PORTitself before every compose call. Re-runningstartfetches and, when it is safe to do so, fast-forwards the Host checkout to upstream (ADR-0007); untracked files a hook writes are not treated as a dirty tree, so they survive the advance.
Migrating from billet < 0.4.0. Earlier versions set BILLET_AUTHORIZED_KEYS through a
.env file, wired by host_bootstrap_cmd = "cp -n .devcontainer/.env.example
.devcontainer/.env". Delete those two lines from ~/.config/billet/config.toml (one per
Workspace that carried it) and drop .env.example from the repo's .devcontainer/. The
untracked .env already on a Host is inert — a shell export outranks compose's .env
interpolation — so nothing on the Host needs deleting, and the hook itself is not
deprecated.
Then:
billet add my-repo # validate the block (port uniqueness, host exists, …)
billet start my-repo --verify # clone (or fetch + safe fast-forward), compose up --build, postCreate, verify_cmd
billet ssh-config # re-render ~/.ssh/config.d/billet.conf with the new aliases
billet connect my-repo # ProxyJump in, land in the tmux session
--verify shows what verify_cmd printed — stdout and stderr in the order the command
emitted them — indented beneath the finished checklist, so a version check is readable off
the start instead of costing a second round trip. Long output is trimmed to its last 40
lines with a count of what was elided; -v prints it whole, --quiet prints none of it. A
failing verify_cmd still fails the start, with its output in the error.
The order matters, but it is forgiving: ssh-config reads remoteUser live from the Host, so
running it before start simply skips the new Workspace with a warning and still writes every
other alias — re-run it after start to pick the new one up
(ADR-0010).
connect runs tmux new-session -A, so the session is created on first attach — the
repo does not need to pre-create it. tmux_session is omitted above on purpose: it defaults
to the Workspace key (my-repo), which is what makes the session name identify the Workspace
in #S and in stock tmux's default status-left. Set it explicitly only to attach to a
session some other tool already owns.
Claude credentials in the container
Set [billet].claude_token_cmd once, globally, and every Workspace container gets an
authenticated claude with no interactive login and no per-repo change — billet merges the
token into the container's user-level ~/.claude/settings.json
(ADR-0006). Generate it with claude setup-token,
store it, and point the command at the store.
On macOS the store step needs an account flag:
security add-generic-password -a "$USER" -s billet-claude -w '<token>'
security will happily create the item without -a, but the read side —
security find-generic-password -s billet-claude -w, which is what claude_token_cmd
runs — then fails to match it, and start aborts on empty output. If you hit that, delete
the item and re-add it with -a.
One consequence of injecting a setup-token credential: claude in the container may show
a shorter model list than you get locally, because the picker is filtered by what the
credential is entitled to. The model is still selectable by name —
claude --model <name>
— so a model missing from the picker is not a model you have lost.
Port ledger
billet add enforces per-host port uniqueness, but there is no central reservation, so
config.toml is the ledger — keep every Workspace in it, including other operators', or
the next container_ssh_port cannot be chosen safely.
Allocation starts at 2222 and climbs; the shared Host currently has 2222 and 2224–2228 assigned. That range is a snapshot and will age, so read the live answer out of your config rather than trusting this line:
grep -n container_ssh_port ~/.config/billet/config.toml
billet add rejects a collision on the same Host, but only among the Workspaces your
config knows about — a port another operator assigned and never wrote down is invisible to
it, and the clash surfaces as a container that will not bind on the next start.