Skip to content

Architecture

One compose template, many instances

Beetroot never generates custom compose.yaml files. There is exactly one compose template, and it ships inside the beetroot wheel at beetroot/templates/compose.yaml. Every instance uses the same bundled template — the CLI resolves it via importlib.resources, so the same code path works whether beetroot was installed editable (uv sync) or as a tool (uv tool install).

Parameterisation happens via an env file:

docker compose -p alpha -f <bundled-template> \
  --project-directory /path/to/alpha \
  --env-file /path/to/alpha/.env up -d

The -p alpha flag sets the Docker project name, which namespaces all containers and volumes for that instance. Running the same command with -p bravo against a different --project-directory starts a completely independent instance — docker compose -p alpha down cannot affect bravo.

--project-directory is what makes the per-instance bind mounts work: the bundled template declares volumes: ./data:/data (relative), and compose resolves the ./ against the project directory at runtime. So the same template binds <alpha>/data for instance alpha and <bravo>/data for instance bravo.

The --env-file supplies all the per-instance variables that the template reads via ${VAR} substitution: INSTANCE_NAME, BASE_IMAGE, MEM_LIMIT, CPUS, SHM_SIZE, the display knobs (DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_FPS, DISPLAY_GPU), PIDS_LIMIT, the optional MEM_RESERVATION/MEMSWAP_LIMIT, and the BEETROOT_* helper-path overrides (BEETROOT_DENYLIST_PACKAGES, BEETROOT_MAGISK_DB, BEETROOT_MODULES_DIR, BEETROOT_FRIDA_BIN). See render_env() in config.py for the authoritative list. The .env file is generated by the CLI from beetroot.yaml and never hand-edited. Published ports are not in the .env: since v8 (issue #108) the port list is variable-length, which a flat .env can't expand into a YAML list, so the per-instance ports: mapping is supplied via the generated compose.override.yaml — layered on with a second -f — rather than ${VAR} substitution. See config.render_compose_ports_override for the authoritative source.

Image build

The Docker image (docker/Dockerfile) is a single-stage build from the redroid base. The base-image tag is derived at runtime from the instance's beetroot.yaml by config.base_image_tag() — e.g. android: {version: 14, gapps: minimal} produces redroid/redroid:14.0.0_litegapps_houdini_magisk (the intent resolves to a vendor via config.resolve_gapps_vendor()). The tag is injected into the build via the BASE_IMAGE ARG in docker/Dockerfile and ${BASE_IMAGE} substitution in the bundled compose template.

The only things added on top of the base are:

  • docker/entrypoint.sh — copied to /entrypoint.sh (12-line glue that sources the helpers in order).
  • docker/magisk-path.sh, docker/magisk-config.sh, docker/magisk-env.sh, docker/flash-modules.sh, docker/activate-zygisk.sh, docker/launch-frida.sh — copied to / alongside the entrypoint via a single COPY --chmod=755 docker/*.sh / glob. See Boot Scripts for each helper's env-var contract.
  • docker/stealth.rc — copied to /system/etc/init/stealth.rc in the image.

That's it. Magisk is already in the base image (courtesy of the ayasa520/redroid-script patcher run by beetroot build). The magisk --sqlite command ships with Magisk itself, so there's no separate sqlite binary to bundle.

Frida is not in the image, and starting in v0.3 it's also opt-in per instance. When an instance's beetroot.yaml declares a frida: block, the CLI downloads frida-server and writes it to <instance-dir>/frida-server; that path is bind-mounted read-only (:ro) to /data/local/tmp/frida-server inside the container. When the block is omitted (the default for a bare beetroot create), the same path is a 0-byte non-executable placeholder and launch-frida.sh's [ -x ] check skips the launch. Either way, you can change the Frida version per instance without rebuilding the image — see Frida.

Magisk stealth via DB writes

Most Magisk configuration UIs (the Magisk app, magisk --denylist) rely on the running Zygisk process — which isn't available at boot time when entrypoint.sh runs. Beetroot bypasses the UI entirely and writes directly to Magisk's SQLite database at /data/adb/magisk.db.

entrypoint.sh waits for the Magisk daemon to answer (the DB is created by Magisk on first boot; the wait is bounded at ~2 minutes and aborts the boot configuration loudly on timeout), then uses magisk --sqlite to:

  1. Enable Zygisk (INSERT OR REPLACE INTO settings (key, value) VALUES ('zygisk', 1)).
  2. Enable the denylist (INSERT OR REPLACE INTO settings (key, value) VALUES ('denylist', 1)).
  3. Add each package from magisk.denylist as a denylist entry.

This is reliable because the DB is only read by Zygisk after Zygote starts, which happens after entrypoint.sh has already written to it.

Module staging

Magisk modules are staged at <instance-dir>/modules/ on the host (bind-mounted read-only to /data/adb/modules_update inside the container — BEETROOT_MODULES_DIR overrides the target if you need a different path). The CLI is responsible for downloading and placing zips there — the container just reads them at boot.

At boot, flash-modules.sh iterates *.zip files under $BEETROOT_MODULES_DIR (default /data/adb/modules_update) and calls magisk --install-module <zip> for each. Modules are installed once at boot; adding a new zip requires a restart.

Registry

A single user-global JSON file at ~/.config/beetroot/instances.json (respects $XDG_CONFIG_HOME) is the CLI's registry. It maps instance names to:

  • absolute_path — where the instance directory lives on disk.
  • index — the allocated port-stride index.
  • created_at — ISO 8601 timestamp.

The registry is updated under an fcntl.flock advisory lock to guard against parallel beetroot create/destroy calls. Runtime status (running, stopped, etc.) is not cached here — every beetroot ls queries docker compose ps live for redroid rows (and adb devices for adb-adopted rows). The registry tells you where alpha lives; it cannot tell you whether alpha's container is up.