multiversectl GitHub

Storage and data

Where a universe's code, images, volumes and secrets live, which of them are shared, and what removing one destroys.

What a universe owns and what it shares

ThingPer universeShared
CheckoutsOne worktree per repositoryThe clone's git object store
ContainersOne runtime project per appThe container runtime and its image cache
VolumesNamed after the app's projectNothing
NetworksOne per app, plus the proxy networkThe proxy network
SecretsCopied into each checkoutThe originals in the clone
HostnamesOne per host keyThe domain and the proxy's ports

The sharing is the point: a universe is cheap because it reuses the expensive things. The separation is also the point: those six per-universe items are exactly what collides when you try to run a stack twice.

Code: one clone, many worktrees

Each repository is cloned once, under the workspace root. Every universe gets a git worktree of that clone:

~/acme/
├── api/                  # the clone: history, remotes, objects
├── web/
└── universes/
    ├── 3/
    │   ├── api/          # a worktree on branch u3
    │   └── web/
    └── fix-login/
        ├── api/          # a worktree on branch fix/login
        └── web/

A worktree is a full checkout with its own index and its own branch, sharing the clone's objects. Fetching once updates every universe's view of the remote, and a large repository's history is stored once no matter how many universes exist.

They are ordinary directories: open one in your editor, run git in it, commit and push from it. multiversectl has no opinion about what you do inside.

Two worktrees cannot hold the same branch. That is git's rule, not ours. If --branch repo=main fails with "main is already used by worktree at …", it is because the clone itself has main checked out. Pick another branch, or point that repository at a copy of it.

Data: a volume per app, per universe

Volumes are namespaced by the runtime project, and each app of each universe has its own project. So a database volume declared once in a compose file becomes a different volume in every universe, with no change to the file:

$ docker volume ls | grep acme-u3
acme-u3-api_postgres_data
acme-u3-web_node_modules
$ docker volume ls | grep acme-fix-login
acme-fix-login-api_postgres_data   # a different database entirely
acme-fix-login-web_node_modules

Universe 3 can be migrated, seeded or dropped without touching fix/login. That independence is the main reason to run a second universe at all: a destructive migration on a review branch cannot reach the data you were working with this morning.

ls -o wide names them, which is how you check the claim rather than taking it on trust:

$ multiversectl ls -o wide
...
on disk
REPO  WORKTREE                    PROJECT      VOLUMES
api   ~/acme/universes/3/api      acme-u3-api  acme-u3-api_postgres_data
web   ~/acme/universes/3/web      acme-u3-web  acme-u3-web_node_modules

Every name carries the universe's own prefix. That column of prefixes is the evidence that nothing is shared.

Images and build cache

Images are not namespaced, and that is deliberate: they are expensive to build and identical across universes more often than not. Two universes building the same Dockerfile from the same base reuse the daemon's layer cache, so the second build is fast.

The thing to be aware of is the other side of that: an image tag is shared, so if two universes build the same tag from different code, the last build wins for anything that starts afterwards. Compose gives each project its own build, so this bites mainly when a compose file pins an explicit image: name that it also builds. Give such a service a tag that includes ${UNIVERSE_SLUG} if you hit it.

Secrets

Some files a stack needs are deliberately not in git — a local .env, a service account key. A worktree therefore does not have them, and the app half-starts with a confusing error. Name them and they are copied from the clone into every universe's checkout:

apps:
  - name: api
    repo: api
    secrets: [.env, service-account.json]

The clone is the source of truth: put the file there once and every universe gets it. An existing file in a universe is never overwritten, so a universe can diverge on purpose. doctor fails when one is missing from the clone, which is the point at which it is still cheap to fix.

A copied file cannot see a universe's variables. A static list of origins in a committed .env is the same in every universe. For anything that depends on the universe — allowed hosts, trusted origins, CORS — use the multiverse.env.* labels, which win over env_file:.

Generated files

Two kinds of file appear inside a universe's directory, both rewritten on every start and both safe to delete:

FileWhat it is
.multiverse-routes-<app>.ymlThe proxy labels, the proxy network, the dropped published ports and the variables asked for by label.
.multiverse-overlay-<app>.ymlThe app's inline overlay: from the manifest, written out so the runtime can read it like any other file.

They sit next to the checkouts rather than inside them, so they never show up in git status.

What removing a universe destroys

rm is the one destructive command, so it prints what it is about to do and requires the universe's name typed back:

  • Containers and networks of every app — gone.
  • Volumes of every app — gone, with the data in them.
  • Worktrees — gone, and uncommitted work in them is lost.
  • The state file — gone.
  • Branches — kept. Committed work survives, and re-creating a universe of the same name picks the branch up again.
  • The clones, the images, the proxy — untouched.

Use down rather than rm when you only want the containers stopped: it keeps volumes and checkouts, so starting again is where you left off.