Most projects need nothing from this page: dl owner/repo clones, builds and
attaches. It covers the two places where devlaunch has to hand a project more
than the devcontainer spec provides, and what a project has to do to meet it.
devcontainer.json's initializeCommand runs on the host, before the
container exists. Projects use it to prepare bind-mount targets and to generate
the .env that docker compose interpolates.
A project that supports several checkouts at once has to name each one. The
container name, the compose project name and any per-checkout image tag all have
to differ, or two branches fight over one container. Such projects normally
derive that name from the checkout's path. That doesn't work here: devlaunch
clones every branch to <repo>/<branch>, so sibling branches share every path
component that a heuristic would key on.
devpod passes initializeCommand no workspace identity of its own, so devlaunch
injects one with devpod's --init-env, which reaches the hook:
| Variable | Value |
|---|---|
DEVLAUNCH_WORKSPACE_ID |
The workspace id, e.g. myrepo-nb4 |
A project's hook should prefer it over any path-derived guess, and fall back to
the guess when it is unset (a plain git clone opened in VS Code):
INSTANCE_ID="${DEVLAUNCH_WORKSPACE_ID:-$(basename "$(dirname "$PWD")")}"devlaunch puts the host's gh token in every workspace it opens, as GH_TOKEN in
the container environment:
| Variable | Value |
|---|---|
GH_TOKEN |
The host's GitHub token, from gh auth token or the host's own GH_TOKEN/GITHUB_TOKEN |
Two consequences for a project that arranges its own GitHub auth:
- A
~/.config/ghbind-mount is no longer needed for containers opened withdl. Keep it if the project is also opened with plaindevpod upor VS Code's Reopen in Container, which do not go through devlaunch;GH_TOKENsimply wins over the mounted config when both are present. - A
GH_TOKENthe project sets for itself incontainerEnvorremoteEnvis overridden, because devpod's workspace env is applied after the devcontainer's own. A project that needs its own token there has to name the variable something else, or the workspace has to be opened withDEVLAUNCH_NO_GH_TOKEN=1.
While GH_TOKEN is set, gh auth login, gh auth switch and gh auth logout all
refuse to act inside the container; the environment variable is the login.
Repos that build for more than one target keep several variants: a second
architecture, or a GPU or simulator mode on a different compose file. The spec
discovers them at .devcontainer/<name>/devcontainer.json, one level deep, so a
bare name is enough:
dl org/repo --devcontainer sim # .devcontainer/sim/devcontainer.json
dl org/repo --devcontainer ./somewhere-else.jsonA bare name expands to the spec's variant location; anything containing / or
ending in .json is used as given. Both are handed to devpod as
--devcontainer-path. (devpod's own --devcontainer-id takes a bare variant name
and looks like the same thing, but is silently ignored in devpod 0.26.1: it parses
the default config and stores no id.)
This is a one-time argument. devpod stores the chosen config with the
workspace, so later dl org/repo calls reuse it, including after a stop. Only
switching an existing workspace to a different variant needs a rebuild:
dl org/repo recreate --devcontainer robotNote that the spec gives exactly one zero-argument config,
.devcontainer/devcontainer.json, and every other location needs an explicit path.
A project whose primary workflow is one particular variant should put that
variant in the root file, not in a subfolder.
Workspace ids do not encode the variant, so one branch holds one variant at a time. Use a second branch-workspace if you want both at once.
devpod re-parses a workspace's devcontainer.json when deleting it, to tear the
container down. So if a project moves or renames the config an existing workspace
points at, dl <ws> rm fails, because the file it recorded is gone. Recover with:
devpod delete <ws> --force # drops devpod's record
docker rm -f <container> # then clean up by handdl keeps the local clone whenever devpod's delete fails, so the workspace stays
retryable: restore the old path and the normal delete works again. Projects
restructuring their variants should expect existing workspaces to need this.
devpod names the compose project itself, from the workspace id, and does not
honour COMPOSE_PROJECT_NAME from the project's .env. Two consequences for
compose-based devcontainers:
- Every service the devcontainer needs must be in
runServices. A sidecar started later by the project's own tooling (which computes its own project name) lands in a different compose project, so it gets a different default network and container-to-container discovery silently fails. - Without
runServicesa spec-conforming tool starts all services. Compose files that carry an abstract template service bring that template up as a real container. Such a service exists only to beextends:-ed, and is visible because another fileinclude:s it. List the services you actually want.
Each compose project also gets its own bridge network, and docker draws those
from a fixed set of address pools. The default set is small. 172.17.0.0/12
split into /16s yields 15, because it starts at 172.17 rather than 172.16, and
192.168.0.0/16 split into /20s yields 16. That is 31 networks for the whole
host, and fewer in practice, because docker skips any block that collides with a
network the host already has. The default bridge takes 172.17 itself, and a LAN
on 192.168.1.0/24 or a VPN on 192.168.194.0/24 costs a slot each.
Past that, the next launch dies inside devpod's compose call.
Error response from daemon: all predefined address pools have been fully subnetted
It arrives from docker through devpod unmapped, so it reads like a launch failure rather than a host with no subnets left to hand out. Three things about the ceiling:
- Only compose devcontainers consume a slot. A single-container workspace joins the default bridge and allocates nothing, so the limit stays invisible until a project moves onto a compose file.
- It is the host's ceiling, not devlaunch's. Every other network on the machine shares it, compose or not.
stopkeeps the subnet,rmreturns it. devpod stops a compose workspace withcompose stop, which leaves its network standing, and deletes it withcompose down, which removes it. So a workspace stopped to free memory is still holding a slot.dl <ws> rmgives one back anddl --purgegives back every workspace devlaunch owns.docker network prunereclaims whatever no container is attached to, which is the quickest way out of a launch that has just failed.
The lasting fix is to give docker more pools, in /etc/docker/daemon.json:
{
"default-address-pools": [
{ "base": "172.17.0.0/12", "size": 16 },
{ "base": "10.128.0.0/12", "size": 24 }
]
}That is 4096 /24s behind the 15 /16s the host already had. The shape of the list matters more than the numbers in it.
default-address-pools replaces the defaults rather than adding to them, so
whatever is in the list is the whole set. Listing the stock 172.17.0.0/12 pool
first is what keeps it. Dropping the stock 192.168.0.0/16 pool is the
deliberate part, because that is the range most likely to collide with the LAN
the host is on.
Docker fills the pools in order, taking the first free block of the first pool
that has one. A host with the config above still hands out 172.18, 172.19,
172.20 and so on exactly as it did before, and only reaches the 10.128 pool once
the /12 in front of it has nothing free. How many networks that is depends on the
host, because the collision skipping above applies to the /12 pool as much as to
the one it replaces. A host whose ZeroTier interface sits on 172.29.0.0/16 gets
thirteen out of that pool rather than fifteen, with 172.17 held by the default
bridge and 172.29 skipped.
That ordering is also what keeps the default bridge where it is. docker0 is the
exception to everything else here, because its subnet is re-derived at daemon
start rather than stored, so it comes back on the first free block of the first
pool. With the config above that is 172.17.0.0/16, unchanged. With a single 10.128 pool it is 10.128.0.0/24,
which both moves docker0 and resizes it to that pool's size. A /24 leaves
about 253 addresses for containers started with no network of their own, which is
ample on a devlaunch host where every workspace has a network of its own, but
172.17.0.1 is hardcoded in a decade of scripts and images, and keeping the
172.17 pool in front means never finding out which of them are on the host. Set
bip if a host needs docker0 pinned regardless of the pools.
Check ip -4 route before settling on a second base. A VPN that routes part of
10/8, which ZeroTier and Tailscale both can, wants a base outside what it
routes. GCP hands its default VPC subnets out of 10.128.0.0/9, so a host that
dials into one of those wants a base outside that as well.
systemctl restart docker applies it. That restarts the daemon, not the machine,
so no reboot is involved, but it stops every running container, so pick the
moment. User-defined networks that already exist keep the subnets they hold, and
only new ones draw from the new pools.
Workspaces are cloned from a local bare cache that holds no LFS objects, so
devlaunch clones with GIT_LFS_SKIP_SMUDGE=1 and then runs git lfs pull
against the real remote once the origin URL is set. Nothing is required of the
project, but a repo whose LFS objects the remote no longer has will fail there
rather than silently leaving pointer files in the tree.