envend
guide

Profiles and workspaces

A workspace is what a sandbox is for: a directory, three shell strings and a user. A profile is where that is written down under a name, along with the image, the size and the identities, so that creating a sandbox is a word rather than a paragraph.

The declaration

{
  "dir":    "/work/omega",
  "create": "git clone https://github.com/airbooksio/omega /work/omega",
  "start":  "devcontainer-up /work/omega",
  "exec":   "devcontainer exec --workspace-folder /work/omega --",
  "user":   "ubuntu"
}
FieldWhat it is
dirWhere the hooks run, and what a listing names. If it does not exist yet, hooks run in /work.
createShell, run once per disk, guarded by a marker file at /work/.envend/created.
startShell, run on every boot, after the sandbox's declared services are up.
execA command prefix. Every command a session runs is handed to it.
userWho all of that runs as. Optional — most images say who they are themselves.

Two hooks, because there are two questions with different answers. Creating is a one-time act: a second clone over an existing checkout throws work away, so it is guarded by a marker on the disk rather than by anything the gateway remembers — the gateway can restart, and the evidence is on the disk anyway. Starting is not one-time: on a backend that rebuilds everything outside /work from the image, the containers and daemons are gone after a sleep even though the files are not, and they have to come back on every boot. A start hook has to be idempotent, and that is the author's problem, not envend's.

Empty means nothing runs. There is no default hook, no repository envd knows how to clone and no container it knows how to bring up. A sandbox that should do nothing declares no workspace, or declares one with empty hooks; both are ordinary.

Both hooks run under bash -c (or sh -c on an image with no bash), as the workspace user, with standard input closed — a hook is not attached to anybody, and a command in it that read stdin would wait forever on a pipe nothing will write to — and in a process group of their own, so a hook that runs out of patience can be ended along with everything it started.

Who it runs as

Everything a person runs in a sandbox runs as the workspace user: both hooks, every ssh shell, every exec, --host included. envd itself stays root — it is pid 1, it installs the ssh key and it serves /work — and nothing else does.

That is what makes a checkout usable. A repository cloned by a root shell is one the container's own user cannot edit, that git refuses as having "dubious ownership", and that a build cannot write its output into. Cloned by the workspace user, it belongs to whoever has to build with it, and a file arriving over the mount from a laptop belongs to them too.

Who they are is a fact about the image, decided in four steps and said out loud in the boot log:

  1. the profile's workspace.user — "ubuntu", or "1000:1000";
  2. the image's own USER, which the wrapper records before it forces root so envd can be pid 1 (or a dev.envend.workspace-user label, for an image that has to run as root and says who it means instead);
  3. the first user in the image with a home directory of its own — ubuntu on Ubuntu, node on node:22;
  4. root, for an image with nobody else in it: alpine, distroless, scratch.

They get their supplementary groups, which is what makes docker work from a hook in an image where the user is in the docker group, and passwordless sudo where the image has a sudo to configure. The top of /work becomes theirs so a create hook can make its checkout in it; nothing under /work is touched, because that is somebody's data.

Where a command lands

exec is a command prefix, and that is the whole routing rule.

You runIt lands
env_exec(env:, cmd:)<prefix> bash -lc "<cmd>"
env_exec(env:, cmd:, host: true)on the VM
envend ssh <name> -- <cmd><prefix> bash -lc "<cmd>"
envend ssh <name><prefix> bash -l
envend ssh <name> --host [-- <cmd>]on the VM
scp, sftp, rsync, the NFS mountthe VM, always

Nothing is probed first. envd does not check whether the thing behind the prefix is up, and does not fall back to the VM when it is not: if the prefix fails, its exit status and its standard error are the answer. That is what the same command typed by hand would have given, and it is the only answer envd is in a position to give honestly — a fallback would run your command somewhere you did not ask for and report success.

File transfer stays on the VM because the share is the VM's /work, and the paths under it are the same files a container sees at its own mount point. There is nothing to route.

What a workspace says about itself

Five states, and every one comes from an exit code — envd never reads a hook's output to decide how it went: pending, creating, starting, ready, and failed with the reason and the last twenty lines kept. Where to look, in increasing order of detail:

  • envend list and env_list show one line per sandbox: workspace: ready — /work/omega, or failed with the first line of the error. It comes from the heartbeat the machine already posts, so a listing costs no extra call.
  • envend logs <name> and env_logs print the hooks' own output as they write it.
  • /work/.envend/hooks.log is everything both hooks have ever written, timestamped and appended. It is under /work, so it survives a wake and can be read straight off the mount.

A failing hook is never fatal. A sandbox whose clone failed is still a computer you can ssh into and fix by hand; an envd that exited over it would leave nothing at all. So envend ssh is never refused by a failed workspace — a broken checkout is the thing you are going in to fix.

Profiles

A workspace is not a field of env_create. What to set up, and what to run afterwards, is a property of the project, and a project's properties belong in its profile where they can be got right once.

A profile fills in only what the call left out — a default, never an override, so a caller who asks for size s on a profile that says xl gets s and no argument about it. It also makes the name optional: the gateway generates <alias>-<four characters>, random rather than counted, because several sandboxes of one project at once is the ordinary case and a counter would be a thing to keep, to persist and to race over.

Profiles come from two places, and the file wins: ENVEND_PROFILES, a JSON object in the gateway's environment, and profiles.json in the gateway's data directory. The file wins because it is the one an operator can change without a redeploy, and a setting you can change is not much use if the deployed one keeps winning. Naming a profile the gateway does not have lists the ones it does.

The worked example: omega

Worth reading as one, because every word about devcontainers in envend is in this JSON and none of it is anywhere else.

{
  "omega": {
    "image": "registry.fly.io/envgw-poc:host-v9",
    "size": "xl",
    "identities": { "github": { "repos": ["airbooksio/omega"] } },
    "workspace": {
      "dir": "/work/omega",
      "create": "git clone https://github.com/airbooksio/omega /work/omega && devcontainer-up /work/omega",
      "start": "devcontainer-up /work/omega",
      "exec": "devcontainer exec --workspace-folder /work/omega --"
    }
  }
}

image is the devcontainer host image — Ubuntu with envd, Docker and the devcontainer CLI in it, declaring dockerd as a service so envd supervises it. It already runs envd, so the gateway boots it as it is rather than wrapping it, and the create answer says (as is). The devcontainer itself runs inside that VM, unchanged, brought up by the ordinary CLI.

identities.github is what makes the clone work. envd installs a git credential helper that mints an installation token per operation, so the git clone in the create hook needs no key in the machine. Nothing is pasted anywhere and nothing is long-lived, and that repos list is exactly what the credential covers.

There is no chown in the create hook, and there is nothing for one to do: the clone is made by the workspace user, which the host image declares as ubuntu. A devcontainer sorts itself out from there — the CLI's updateRemoteUserUID remaps the container's own user to the uid of whoever owns the workspace folder on the host, so a checkout owned by ubuntu comes out editable inside the container with no chown anywhere.

devcontainer-up is not devcontainer up. It is a script in the host image that runs devcontainer up and works around an upstream hang on a cold build: the CLI matches docker events on a field Docker 29 no longer sends, so on a first boot it brings every container up and then waits forever. The script watches for that exact shape, kills the CLI and runs it once more, which takes the warm path. It lives in the image because the bug is the CLI's, and envd is not allowed to know that the CLI exists.

Then, from anywhere:

envend up omega                            # prints the generated name, then the hooks
envend ssh omega-k4zt -- mix test          # through the exec prefix
envend ssh omega-k4zt --host -- docker ps  # on the VM
envend mount omega-k4zt                    # the checkout at ~/envend/omega-k4zt/work/omega

The first boot is minutes — a clone, an image pull and a compose build — and envend up stays and prints it while it happens. Every boot after that is seconds, because all of it is on /work and /work is durable.

Declared services

Related, and often confused with hooks. services declares what a sandbox should be running — up to four long-lived processes, each with a name, a command, a port it listens on inside the machine, and a directory:

services: [{ name: "web", cmd: "mix phx.server", port: 4000 }]

envd starts them at every boot, restarts them if they crash, and brings them back after every sleep with nothing from you. That last part is the point: on a backend where a wake rebuilds everything outside /work, a dev server you started by hand is gone after a sleep while a shared URL is still pointed at it — a live link to nothing. The port publishes nothing; it is the number you would later hand to env_share.