Skip to content

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:

cd ~/project && just devcontainer run

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:

just devcontainer shell        # zsh in the running container
just devcontainer shell bash   # bash instead

Run a one-off command:

just devcontainer exec just lint
just devcontainer exec mvn -pl foo test

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":

  1. Machinery (chezmoi, just, pre-commit, docker-outside-of-docker) — community features pulled in by dotfiles-base's dependencies. Installs at image build; cached.
  2. Project toolchain (cargo, java, terraform, …) — the flavour's feature in the committed config, public refs only. Installs at image build; cached.
  3. Personal CLI tier (nvim, ripgrep, fzf, …) — the dotfiles-packages feature. Its Brewfile is rendered from packages.yaml at publish time on the host (flags in the feature's tier-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.
  4. Long tailbrew install on demand inside the container. Anything you install twice, promote to packages.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:

  1. Edit packages.yaml, then just devcontainer publish features. Publishing is content-keyed: "unchanged — nothing to publish" means you were already current, so this doubles as the status check.
  2. Existing projects are unchanged until just devcontainer upgrade bumps 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.
  • run requires the image to build cleanly. First-time builds can be slow and surface project-specific Docker errors. If run fails, try just devcontainer build to isolate the build from the start phase.
  • scaffold mutates the current directory. It writes .devcontainer/, a Justfile, 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.
  • scaffold needs 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 -i requires fzf. 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.yml for the colima configuration.