Devcontainer¶
Lifecycle + shell against a project's local .devcontainer/ stack, and scaffolding new devcontainer configs from the
flavour templates published to the local OCI registry. Two submodules carry the publishable artifacts — registry (the
local OCI registry) and publish (flavour templates + features) — and all build-time sources live under
~/.just/devcontainer/.framework/.
All lifecycle recipes are [no-cd] — run them from the project root so the devcontainer CLI finds .devcontainer/. See
just devcontainer for the recipe list.
Day-to-day workflow¶
Open a project's devcontainer:
run chains build → up → shell. The build is a no-op when the image is cached, so re-running is cheap.
Already running, want a second shell:
Run a one-off command:
Tearing down and rebuilding¶
The devcontainer CLI upstream has no teardown verb; down fills the gap, mode-dispatched via container labels:
- Image-mode projects (most flavours): the dev container is removed by workspace label. It is disposable by design — your code lives in the bind-mounted workspace and caches in named volumes, and both survive.
- Compose-mode projects (member-shaped flavours like kafka): the whole stack comes down — every service plus the network. Broker/database state is only as durable as the volumes the compose file declares.
rebuild is down + up — the verb for picking up upgraded features (after just devcontainer upgrade), a new base
image, or Dockerfile changes; plain up reuses an existing container and will not.
Flavour sibling stacks (redis-k8s's redis/cluster/k8s) are separate from the dev container and are driven from inside
it: just proj redis down and friends.
Operator or member — why there is usually no compose file¶
Features are this framework's composition mechanism, so a dev container alone needs no compose file — the CLI assembles the image from stock base + features. Compose enters when the environment is multi-service, and the flavour picks one of two shapes:
- Operator (redis-k8s): the services are the subject matter; you drive them from outside via docker-outside-of-docker. Dev container stays image-mode; stacks are project-root compose files.
- Member (kafka): your process participates in the system — it must sit on the compose network so service DNS names
(
kafka:9092) resolve for it exactly as in production. The dev container becomes a compose service, typically built from a multi-stage Dockerfile's dev target.
If the dev process talks to the services, you want member; if it only manages them, operator.
Scaffolding a new devcontainer¶
scaffold republishes the flavour templates to the local registry, applies the chosen flavour into your project via
devcontainer templates apply (substituting the project name), and runs the flavour's init script. Because scaffold
republishes first, edits to the template source under ~/.just/devcontainer/.framework/ are always live — there is no
separate publish step to forget.
cd ~/new-project
just devcontainer scaffold spring-cloud-gcp # name the flavour
just devcontainer scaffold -i # pick via fzf
just devcontainer flavours enumerates what's available. DEVCONTAINER_TARGET overrides the target directory (defaults
to where you invoked just). --variant/-v <tag> scaffolds against a non-default base image variant — any tag of
mcr.microsoft.com/devcontainers/base.
Re-running scaffold in an existing project is how you pick up framework updates: template-owned files
(.devcontainer/, Justfile, help.md, submodule .just files) are overwritten without further warning; everything
else — your code, the init-script artefacts — is left alone. The confirmation prompt lists what's already in the target;
read it before typing y.
Registry, features, and the personal layer¶
Committed .devcontainer/ configs are vanilla — stock image plus public toolchain features — so anyone who clones a
scaffolded project can build it with no access to your machines. Your personal layer (dotfiles-base, which pulls the
machinery and the package tier via its dependencies) is injected by up/build at invocation time and resolves against
the local OCI registry. registry up starts the registry (idempotent), publish features publishes the features, and
registry ls shows every published template and feature with its version and description; the registry also serves a
web UI at its root (http://localhost:5001). Cleanup is automatic: retention policies prune superseded versions and
garbage-collect their blobs.
If up/build fails resolving localhost:5001/..., run just devcontainer registry up and publish features first.
How packages get into a container¶
Four channels, three moments — the mental model for "where does tool X come from, and what does it cost":
- Machinery (chezmoi, just, pre-commit, docker-outside-of-docker) — community features pulled in by
dotfiles-base's dependencies. Installs at image build; cached. - Project toolchain (cargo, java, terraform, …) — the flavour's feature in the committed config, public refs only. Installs at image build; cached.
- Personal CLI tier (nvim, ripgrep, fzf, …) — the
dotfiles-packagesfeature. Its Brewfile is rendered frompackages.yamlat publish time on the host (flags in the feature'stier-config.toml) and travels inside the published artifact, so the layer's cache key follows manifest content. Installs at image build; cached — the first build of a feature combo costs a few minutes, sibling projects and rebuilds pay seconds. - Long tail —
brew installon demand inside the container. Anything you install twice, promote topackages.yaml(one line) and republish; the next build carries it.
postCreate is config-only in effect (~13s): chezmoi applies the dotfiles, and the package hooks no-op against the
already-satisfied manifest (the Using … lines). Current exception: the small apt list still installs per-container —
tracked in the backlog.
Changing the tier — the step people forget is the lock:
- Edit
packages.yaml, thenjust devcontainer publish features. Publishing is content-keyed: "unchanged — nothing to publish" means you were already current, so this doubles as the status check. - Existing projects are unchanged until
just devcontainer upgradebumps their.devcontainer/devcontainer-lock.json(written by the CLI on first build, pinning feature digests), followed by a rebuild. New projects are current on their first build.
flowchart LR
py["packages.yaml"] -- "publish: rendered via<br/>tier-config.toml flags" --> bf["Brewfile inside the<br/>dotfiles-packages artifact"]
bf -- "content change → new version<br/>→ new layer cache key" --> layer["cached docker build layer"]
cfg["config-only dotfiles drift"] -- "absorbed at postCreate<br/>(no rebuild needed)" --> post["container start"]
The container lifecycle, with costs and the lockfile decision made visible:
flowchart TD
up["devcontainer up / build"] --> lock{"devcontainer-lock.json exists?"}
lock -- "no (first build)" --> resolve["resolve :latest from registries"]
resolve --> writelock["write lockfile (pins digests)"]
lock -- yes --> pinned["resolve pinned digests<br/>(republishes invisible!)"]
pinned -. "just devcontainer upgrade" .-> resolve
writelock --> cache{"feature layers in docker cache?"}
pinned --> cache
cache -- hit --> create["create container (~s)"]
cache -- miss --> build["build layers once per feature set<br/>(machinery + toolchain + package tier)"]
build --> create
create --> post2["postCreate (~13s): config-only apply,<br/>bundle no-op, pre-commit install"]
Inspection, when the model and reality seem to disagree: docker history <image> shows the layer reality, and
/usr/local/share/dotfiles-packages.Brewfile inside any container is the exact manifest its package layer was built
from.
Gotchas¶
- Run from the project root. All lifecycle recipes are
[no-cd]. From elsewhere, the devcontainer CLI won't find your.devcontainer/directory and the recipe will fail. runrequires the image to build cleanly. First-time builds can be slow and surface project-specific Docker errors. Ifrunfails, tryjust devcontainer buildto isolate the build from the start phase.scaffoldmutates the current directory. It writes.devcontainer/, aJustfile, and may write project init artefacts — and it overwrites template-owned files without per-file prompts. Run it in a fresh project directory or commit before invoking.scaffoldneeds the docker daemon. It republishes templates to the local registry before applying; with docker down it fails at the registry bring-up, not at file-copying.scaffold -irequiresfzf. Without it, pass the flavour name as a positional argument.- The registry must allow insecure localhost:5001. See the header comment in the module's
.framework/registry/docker-compose.ymlfor the colima configuration.