This file documents the Container manager subsystem (devplacepy/services/containers/, plus its routers/projects/containers and routers/admin/containers.py HTTP surface, and the shared ppy Docker image). Claude Code loads it automatically whenever a file under devplacepy/services/containers/ is read or edited.

## Overview

Admin-only. Supervises container instances, all running one shared prebuilt image. Driven through the `docker` CLI via `asyncio.create_subprocess_exec` behind a pluggable **`Backend` ABC**, never a docker SDK. Reference: admin docs `Services -> Container Manager` and the `containers` API group.

## Backend seam

`backend/base.py` defines the `Backend` ABC. `DockerCliBackend` drives `docker` via `asyncio.create_subprocess_exec`, streaming run logs line-by-line (`_stream`). `FakeBackend` is the in-memory test double (its `image_exists` returns `True`). `runtime.get_backend()`/`set_backend(b)` selects the active backend - tests call `set_backend(FakeBackend())`. A future Kubernetes/remote backend implements the same ABC. The container OS workspace mount point is the single constant `backend/base.py` `WORKSPACE_MOUNT` (`/app`).

## Security (load-bearing)

Mounting the docker socket is root-equivalent on the host. Every run/exec/lifecycle/schedule operation is gated `require_admin` (HTTP) and `requires_admin=True` (Devii). `--privileged` is never passed. User input is never shell-interpolated - every call is an arg-list subprocess, never `shell=True`. Resource limits are applied via `--cpus`/`--memory`.

On top of the admin gate, **per-user container isolation** applies (see root CLAUDE.md "Project visibility (is_private) and read-only" convention): only the **primary administrator** (earliest-created Admin) and the instance owner (creator or project owner) may manage an instance; other admins get read-only access, and only when the instance's project is public. Enforced via `content.py` predicates `owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance` (all reusing `is_primary_admin`): the primary administrator sees and manages every container including those on private projects; any other admin can VIEW containers of others only when the instance's project is public (private-project containers of others are invisible; of private containers they see only their own), and can MANAGE (edit/lifecycle/exec/terminal/sync/delete/schedules) only instances they own - non-owners get 403 plus an audit row with `result="denied"` (WS close `1008`). This is enforced at every entrypoint: `routers/projects/containers/` (`project_for` + `manage_guard` + the exec WS), the admin Containers manager (`_decorate` filtering + per-row `can_manage`, `_viewable_instance_or_404`, `_manage_denied`, create + project-search), the Devii container tools (`ContainerController._project` + `_require_manage`), and the live view relay (broadcast topics never carry private-project instances).

## One shared image, no in-app builds (load-bearing)

Every instance runs `config.CONTAINER_IMAGE` (default `ppy:latest`, override `DEVPLACE_CONTAINER_IMAGE`), built ONCE from `ppy.Dockerfile` via `make ppy` (context `devplacepy/services/containers/files`). There are no per-project Dockerfiles, builds, `ContainerBuildService`, or build UI - all removed.

`service._launch` calls `api.run_spec_for(inst, config.CONTAINER_IMAGE)`. `api.create_instance(project, *, name, ...)` fails fast with `ContainerError` if `backend.image_exists(CONTAINER_IMAGE)` is `false` ("run 'make ppy'"); it no longer takes a dockerfile/build. `backend.image_exists(ref)` (`docker image inspect`) is part of the `Backend` ABC.

Migrating off the old per-project-build model: `devplace containers prune-builds` removes the legacy per-project images (`backend.remove_image`) and clears the `dockerfiles`/`dockerfile_versions`/`builds` tables (one-time); existing instances auto-repoint to `ppy` (their stored `build_uid` is ignored).

**Default idle:** `run_spec_for` runs `["sleep", "infinity"]` when an instance has no `boot_command`, so a bare instance stays up instead of exiting (the image `CMD` is the same, but the explicit command makes it independent of the image).

## Data model

`store.py`, indexed in `init_db`:
- `instances`
- `instance_events` (audit trail; also home of the status-history rows written by `_set_status`)
- `instance_metrics` (ring buffer, `METRICS_RING=720`; sweep keeps it bounded)
- `instance_schedules` (cron/interval/once)

## Reconciler (`ContainerService`, `service.py`)

A `BaseService` reconciler; the model is desired-vs-actual, NOT a task per container. Each tick:
1. `backend.ps(label=devplace.instance)` snapshots `docker ps` (label `devplace.instance=<uid>` is the join key).
2. Converge each instance to its `desired_state`.
3. Apply restart policies.
4. Reap orphan containers (labeled but no DB row -> no orphans, no lost state).
5. Fire due `instance_schedules` (reusing `devii/tasks/schedule.py` `cron_next`/`next_run`).
6. Sample `docker stats`.

Only the service lock owner reconciles; HTTP handlers only flip `desired_state` / write events. `docker run --name <slug>` collision is the double-launch guard.

## Workspace materialization

Workspace = `/app` (the `WORKSPACE_MOUNT` constant). `project_files.export_to_dir` materializes the project to `config.CONTAINER_WORKSPACES_DIR/<project>` (`DATA_DIR/container_workspaces`, default `data/`) once, bind-mounted RW; `project_files.import_from_dir` (the inverse) syncs it back on the sync action. Both run via `asyncio.to_thread`.

**Runtime data must live OUTSIDE the `devplacepy/` package and NOT under `/static`**: workspaces (and zips) live in `config.DATA_DIR` (configurable via `DEVPLACE_DATA_DIR`). Never put generated data under `STATIC_DIR` - it is served publicly AND watched by dev `--reload` (scoped to `--reload-dir devplacepy`). The docker daemon must be able to bind-mount `DATA_DIR` for `/app`.

Every exec passes `-w /app` explicitly (`DockerCliBackend.exec` and the PTY exec in `routers/projects/containers/instances.py`), so one-shot `container_exec`, the interactive shell, and tmux sessions all start in the project workspace. The agent is told this (system prompt + `container_exec` tool summary) so it never prefixes a command with `cd /app`.

## Shared operations

`api.py` holds the operations shared by `routers/projects/containers/instances.py` (and the rest of the `routers/projects/containers/` subpackage) and the Devii `ContainerController` (`services/devii/container/`, `handler="container"`, 7 instance tools). The reconciler service defaults **disabled** (needs docker); enable it on `/admin/services`.

## HTTP routing surface

- `/projects/{slug}/containers` - the `routers/projects/containers/` subpackage: `instances.py` (creation/lifecycle/exec/logs/metrics/sync plus the exec websocket), `schedules.py` (cron/interval/once schedules), shared helpers in `_shared.py`. Every instance runs the shared `ppy` image. Discoverable from the project detail page's admin-only **Containers** button (gated by `content.can_view_project_containers` via the `viewer_can_containers` context flag) and from the admin index.
- `/admin/containers` - `routers/admin/containers.py`: lists every instance across all projects with inline actions (start/stop/restart/terminal/edit/delete) plus a create modal (project search-select, run-as user search-select, boot language + source editor, restart policy, start-on-boot, env/ports/limits/ingress); `/admin/containers/{uid}` is the per-instance detail page (lifecycle, live logs/metrics, PTY terminal, schedules, ingress, sync, status history) and `/admin/containers/{uid}/edit` edits run-as user, boot language/script/command, restart policy, start-on-boot, and limits.

**Key reuse rule:** the admin Containers section adds NO lifecycle/logs/exec endpoints of its own - the instance carries its `project_uid`, so the admin detail route resolves the project and its frontend targets the existing `/projects/{slug}/containers/instances/{uid}/...` routes. Add any new instance operation to `routers/projects/containers/instances.py` only; the admin detail page picks it up for free.

## UI - two surfaces over one API set

Both are discoverable (an earlier version of the per-project page had zero links to it - fixed).

1. **Per-project manager**: `templates/containers.html` + `static/js/ContainerManager.js` handles instance creation through an app modal form (the `_macros.html` `modal()` macro + `ModalManager` `.visible` toggle); its instance list links out to the shared detail page. Reached from the project detail page's Containers button, and passes breadcrumbs so content clears the fixed nav.
2. **Admin Containers section**: `routers/admin/containers.py` (mounted `/admin/containers`, sidebar link in `admin_base.html`, `admin_section="containers"`). `GET /admin/containers` lists every instance via `store.all_instances()` (decorated with project title/slug from one `projects` lookup) in an `.admin-table`. `GET /admin/containers/data` is the poll JSON. `GET /admin/containers/{uid}` renders `templates/containers_instance.html` + `static/js/ContainerInstance.js` - a dedicated detail page (lifecycle, poll logs/metrics, schedules add/delete, ingress, sync, interactive exec over a PTY WebSocket gated on the lock owner).

All container CSS (`static/css/containers.css`) uses app design tokens (`--bg-card`, `--text-primary`, `--success`/`--danger`/`--warning`, `--radius`) and the shared `.card` recipe.

## Admin CRUD manager (`/admin/containers`)

`/admin/containers` is a full manager, not a read-only list. `routers/admin/containers.py` adds (all `require_admin`, all reuse `api.*` directly - no docker/exec backend duplicated):
- `POST /create` (`ContainerAdminCreateForm` -> `api.create_instance`, project search-select + run-as user + boot fields + restart policy + start_on_boot + env/ports/limits/ingress).
- `GET`/`POST /{uid}/edit` (`ContainerEditForm` -> `api.update_instance_config`, edits `run_as_uid`/`boot_language`/`boot_script`/`boot_command`/`restart_policy`/`start_on_boot`/`cpu_limit`/`mem_limit`).
- Stacked literal lifecycle routes `POST /{uid}/{start,stop,restart,pause,resume}` (the no-wildcard rule below).
- `POST /{uid}/sync` (bidirectional).
- `POST /{uid}/delete` (soft, owner-or-admin via the admin guard).
- Two JSON search endpoints `GET /{projects,users}/search` (back the create/edit search-selects via `db.query LIKE`).

**Route order:** `/{uid}/edit` and the literal verb routes are declared BEFORE the `/{uid}` catch-all.

Frontend: `static/js/ContainerList.js` (poll-render rows with inline action buttons + the create modal with two debounced search-select widgets and a boot-language toggle) and `static/js/ContainerEdit.js` (the edit page). Both route through `Http.send`/`Http.getJson` and surface errors via `app.toast`; the Terminal button reuses `app.containerTerminals.open(slug, uid, name)`. The instance detail page (`containers_instance.html`) has a config summary (boot language, run-as uid, start-on-boot) plus a **Status history** card rendering the server-side `instance_events`.

Devii: `container_create_instance` takes the boot/run-as/start_on_boot args; `container_configure_instance` (`controller._configure_instance` -> `api.update_instance_config`) edits the same fields; both admin-only, audited via `_DEVII_CONTAINER_EVENTS`.

## No wildcard verb route (load-bearing)

`routers/projects/containers/instances.py` registers every instance verb as a **literal** route - `start`/`stop`/`pause`/`resume`/`restart` (stacked `@router.post` decorators on one `instance_action` handler that reads the verb from `request.url.path`), plus `delete`/`exec`/`sync`/`schedules`. There is deliberately NO `POST /instances/{uid}/{action}` catch-all: a wildcard segment matches its literal siblings too (Starlette matches first-declared), so a catch-all would silently swallow `exec`/`sync`/`delete`/`schedules` as `{"error": "unknown action: exec"}` whenever it sat above them. Add any new instance verb as a literal route (and to the `instance_action` decorator stack if it just flips desired state) - never reintroduce a `{action}` wildcard.

## Host port allocation

Host ports are auto-allocated and globally unique. `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port - previously a silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.

## Floating terminals (interactive shell)

An instance's shell is NOT inline - it is a floating `<container-terminal>` (`static/js/components/ContainerTerminal.js`) over the exec WS (`/projects/{slug}/containers/instances/{uid}/exec/ws`, `pty.openpty()` + `docker exec -it`, admin + `service_manager.owns_lock()` gated). xterm.js + the fit addon are vendored under `static/vendor/xterm/` and lazy-loaded. Opened by the instance-page **Open terminal** button (`app.containerTerminals.open(slug, uid, name)`, `ContainerTerminalManager`) or by telling Devii to "attach &lt;container&gt;" - the admin-only `open_terminal` **client** action (`client_actions.py`, `requires_admin=True`) -> `DeviiClient._openTerminal` -> the same manager.

**`FloatingWindow` base.** It extends the generic `FloatingWindow` base (`static/js/components/FloatingWindow.js` + `static/css/floating-window.css`), which owns the Devii-style chrome: drag, native `resize:both`, maximize/fullscreen, geometry persistence. `app.windows` (`WindowManager`, `components/WindowManager.js`) assigns z-index on `pointerdown` so the last-clicked window is on top (below the avatar/toasts).

**The Devii terminal also extends `FloatingWindow`.** `devii/devii-terminal.js` reuses the base drag/geometry/preset/resize/persist plus `_window`/`_registerWindow` (z-order + context-menu) machinery, overriding only the parts that differ - `_setState` (Devii adds a `closed` state + launcher FAB), `_defaultGeometry` (bottom-right, 760x600), `_changeFont` (per-user `--devii-font-size` CSS var), `_contextItems`, and `_persistGeometry` (delegates to its richer `{state,geometry,focused}` blob under `devii-terminal-state`). Devii keeps native `resize:both` (no `.fw-resize` grip); `devii.css` is unchanged - the only base change required was a `get _dragClass()` getter (default `fw-dragging`) that Devii overrides to `devii-dragging`, so the base drag handlers toggle Devii's own class and its existing CSS drives everything.

**Resize protocol.** The exec WS treats a brace-leading JSON frame `{"type":"resize","cols","rows"}` as a control message: it `TIOCSWINSZ`-es the host pty (`fcntl.ioctl`) **and** signals the `docker exec` client (`proc.send_signal(SIGWINCH)`) so the new size forwards through to the container's exec tty (vim/top reflow). The SIGWINCH is required: with `start_new_session=True` the host slave is not the client's controlling terminal, so the kernel does not auto-deliver it; the pair shares its winsize, so the client reads the new size off its slave stdio and calls Docker's exec-resize API. Any non-resize frame is raw stdin (backward compatible). The fit addon drives it client-side, and the window has a dedicated bottom-right resize grip (`.fw-resize`, since xterm covers the native CSS resize corner - the base `FloatingWindow` adds a manual grip instead of relying on `resize:both`). xterm renders ANSI natively, so the old `cleanTerm` stripping is gone for the interactive shell (the one-shot exec box still strips output, since `docker exec` without `-t` is non-TTY).

**Persistence (tmux).** A `?session=<name>` query param (validated `^[A-Za-z0-9_-]{1,64}$`) makes the WS run the shell inside tmux (`tmux new-session -A -s <name>`, falling back to bash if tmux is absent), so the session survives WS disconnect/window close - `proc.kill` on disconnect kills the tmux *client*, the server daemon + session live on. The frontend keeps exactly **one persistent slot** at a time (`ContainerTerminalManager`, the last-opened terminal): it attaches to session `devplace`, **auto-reconnects** with capped backoff on unexpected WS close (not on code `1008`), and is remembered per-user in `localStorage` (`ct-persistent:<scope>`); `app.containerTerminals.restore()` (called once in `Application.js` after `window.app`) reopens it on the next page load. Opening another terminal demotes/calls `setPersistent(false)` on the previous one (its tmux session lingers in the container, but the frontend stops auto-reconnecting/remembering it); explicit window-close (`_onClose`) detaches + forgets (session still survives, reopen re-attaches).

**Right-click context menu.** Every window wires `app.contextMenu.attach(this.win, () => this._contextItems())` (right-click + long-press), opening the shared `dp-context-menu`. The base `FloatingWindow._contextItems` is Minimize/Normalize/Close; `ContainerTerminal` overrides it with Copy (xterm `getSelection`, disabled when `!term.hasSelection()`) / Paste (clipboard -> WS stdin) / Sync files / Restart / Terminate (`dp-dialog` confirm, then delete + close) / Minimize / Normalize / Project (-> `/projects/{slug}`) / Files (-> `/projects/{slug}/files`) / Close - the lifecycle items POST to the existing `instances/{uid}/{sync,restart,delete}` routes. Devii's `_contextItems` override returns Copy (live `window.getSelection()`, or the current input line when empty) / Paste (clipboard inserted into `.devii-input` at the caret, `selectionStart/End` splice, then refocus) / Minimize / Normalize / Close (it is not bound to a container or project). Copy/Paste both use the async Clipboard API with a hidden-`textarea` + `execCommand("copy")` write fallback.

**Minimize/Normalize.** Geometry presets (`_presetGeometry(w,h)`, smallest-usable / comfortable), exposed both as titlebar buttons (`data-win="minimize|normalize"`) and menu items on every window.

## Pravda image (load-bearing, workspace ownership)

The `ppy` image (`ppy.Dockerfile`, built by `make ppy`, context `devplacepy/services/containers/files`) is a `python:3.13-slim-bookworm` base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (`tmux`, `apache2-utils` for `ab`, `procps`/`htop`/`iftop`/`iotop`, `netcat-openbsd` for `nc`, `zip`/`unzip`, `fakeroot`, git/curl/wget/vim/ack).

The security hotpatch that used to run per build is now baked into `ppy.Dockerfile` once. The final stage:
- `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed).
- `COPY`s the **`aptroot` fakeroot wrapper** (`files/aptroot`, symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` so pravda installs system packages without root).
- `COPY`s **`pagent`** (`files/pagent`, the stdlib AI agent; reads `DEVPLACE_OPENAI_URL`+`DEVPLACE_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py`, plus `files/.vimrc` to `/home/pravda/.vimrc` (whose AI helper - `AiEditSelection` - targets the same gateway as pagent via `DEVPLACE_OPENAI_URL`/`DEVPLACE_API_KEY`, with a public fallback, never `api.openai.com`).
- Evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**.
- Hands pravda ownership of the toolchain AND the OS package trees (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`, plus `/usr/lib`, `/usr/bin`, `/usr/sbin`, `/usr/share`, `/usr/include`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv` so `apt`/`dpkg` can write; `~/.local/bin` on `PATH`).
- Ends on `USER pravda`.

**Why uid 1000.** `/app` is bind-mounted from the host, and under DooD a container's UID maps 1:1 to the host. A container running as **root** wrote root-owned files into the workspace; the app process (host `retoor`, uid 1000) then hit `[Errno 13] Permission denied` in `export_to_dir` on the next instance create - a permanent per-project brick. Pinning the container UID to `1000` makes every write land as `retoor`, and pravda owning the global site-packages means runtime `pip install` succeeds as pravda with no elevation.

**The sudo superclone (`files/sudo`, POSIX `sh`)** is sudo-CLI-compatible but **never swaps user**: it parses the full flag interface (`-u/-g/-E/-H/-n/-S/-i/-s/--`, leading `VAR=value` env assignments, `-V/-l/-v/-k/-K` short-circuits), exports `SUDO_USER`/`SUDO_UID`/`SUDO_GID`/`SUDO_COMMAND`, then `exec`s the command **as the current user (pravda)**. So even a blind `sudo apt ...` / `sudo -u root ...` runs as uid 1000 and **cannot create a root-owned file** - the residual risk is gone by construction.

**Rootless apt (`files/aptroot`).** Pravda installs system packages directly (`apt install <pkg>`, no sudo). `aptroot` is symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` (ahead of `/usr/bin` on `PATH`) and execs the real tool under **`fakeroot`** with `APT::Sandbox::User=root`; dpkg's chown-to-root calls are virtualized while every file actually written lands owned by **pravda (uid 1000)**. Combined with pravda owning the system trees (`/usr`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv`, ...), `apt install <pkg>` works with no real euid 0 and still cannot brick the bind mount. Run `apt update` first (the image clears `/var/lib/apt/lists`); a package whose maintainer script needs genuinely privileged syscalls may still fail - bake those into `ppy.Dockerfile` (its `RUN apt-get` runs as root before `USER pravda`), then `make ppy`.

**Trade-off (intentional).** The only genuinely-root operation that still does NOT escalate is binding a port < 1024 - use a high port + `/p/<slug>` ingress instead. Enforcement lives entirely in the Dockerfile (no `--user` on `docker run`). The `export_to_dir` unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).

## `PRAVDA_*` runtime env injection

`api.run_spec_for` merges `api.pravda_env(instance)` over the instance's own `env_json` (PRAVDA keys win), so every running container gets these platform vars:
- `DEVPLACE_BASE_URL` - the `site_url` setting via `seo.public_base_url()`.
- `DEVPLACE_OPENAI_URL` - `{base}/openai/v1`, the OpenAI-compatible gateway base.
- `DEVPLACE_API_KEY` - the **creating** user's (or `run_as_uid`'s) `api_key`, resolved live.
- `DEVPLACE_USER_UID` - the project **owner**'s uid.
- `DEVPLACE_CONTAINER_NAME` - the instance name.
- `DEVPLACE_CONTAINER_UID` - the instance uid.
- `DEVPLACE_INGRESS_URL` - the instance's absolute public ingress URL `{base}/p/{ingress_slug}` when published and `site_url` is set, relative `/p/{slug}` if `site_url` is unset, empty if the instance has no `ingress_slug`.

These let code inside a container call back into the platform and the AI gateway authenticated as the user. **Injected at run** (`docker run -e`), never baked into the image: the image is shared by every instance, but name/uid/api-key are per-instance and base-url/key must stay fresh, so they are resolved each launch and never stored as secrets in the DB.

Resolution needs two instance columns set at `create_instance`: `created_by` (= `actor[1]` when the actor is a user) feeds `DEVPLACE_API_KEY`, and `owner_uid` (= `project["user_uid"]`) feeds `DEVPLACE_USER_UID`. Instances created before this change have neither and degrade to an empty key/uid (base-url and container name/uid still resolve) until recreated. `DEVPLACE_BASE_URL`/`DEVPLACE_OPENAI_URL` are empty when `site_url` is unset, so set the admin `site_url` for container-to-platform calls (and ingress URLs) to work.

## Ingress (`/p/<slug>`)

`routers/proxy.py` (registered at `/p`) implements the ingress reverse proxy. An instance opts in with `ingress_slug` + `ingress_port` (must be one of its mapped container ports; slug unique, validated in `api.validate_ingress`). `_resolve(slug)` -> `store.find_instance_by_ingress` -> `api.proxy_target(instance)` returns `(gateway, host_port)` (the instance's recorded docker bridge gateway + published host port); the route reverse-proxies HTTP (httpx) and WebSocket (`websockets` client) to `http://{host}:{port}/{path}`, stripping the `/p/<slug>` prefix. The reconciler records `container_ip`/`container_gateway` from `docker inspect` each running tick.

**`proxy_target` dials the container's docker bridge gateway + the published host port** (`gateway:host_port`), NOT loopback and NOT the container's own bridge IP. This is deliberate: `127.0.0.1` fails where docker's `nat OUTPUT` excludes `127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port; the container's own bridge IP can be dropped by docker's bridge-isolation rule (`DOCKER ! -i docker0 -o docker0 -j DROP`); the gateway+published-port path survives both. `DEVPLACE_CONTAINER_PROXY_HOST` overrides the host (e.g. `host.docker.internal` for a containerized app) and still uses the published host port; before a gateway is recorded the proxy falls back to `127.0.0.1`.

Ingress is **public** (no auth) but SSRF-safe: host/port are derived from the instance row, never from user input. The Devii container tools return an absolute `ingress_url` built from `seo.public_base_url()` (the admin `site_url` setting), so the agent fetches the public production URL, not localhost (which web tools refuse); unset `site_url` yields a relative `/p/<slug>`. nginx needs a `/p/` location with WS upgrade + long timeouts (present in `nginx/nginx.conf.template`).

## Production wiring

The container manager drives the host docker daemon, which needs heavy wiring - all carried by the opt-in `docker-compose.containers.yml` overlay (docker socket mount, `INSTALL_DOCKER_CLI=true` build arg, `group_add` the docker gid). **`make docker-build`/`make docker-up` always apply this overlay** and self-derive its inputs: `DOCKER_GID` via `stat -c '%g' /var/run/docker.sock` (the socket's owning group) and `DEVPLACE_DATA_DIR` = `$(CURDIR)/data` (the project's own dir at its real host absolute path). This makes the manager work out of the box with no `sudo`, no `/srv`, no manual `.env` edits. A plain `docker compose up -d` drops the overlay (no CLI, no socket) and silently re-breaks it - always update through the make targets.

**The DooD bind-mount gotcha:** `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must be mounted at an identical host+container path (the make targets use `$(CURDIR)/data` on both sides; manual `docker compose` users get a `/srv/devplace-data` default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".

## Bidirectional newer-wins sync (load-bearing direction rule)

Sync is NOT one-directional import. `project_files.sync_dir_bidirectional(project_uid, workspace, user) -> {"exported", "imported"}` is the one helper that reconciles a project's virtual FS against an instance's `workspace_dir`: per file, the side with the newer timestamp wins (project `updated_at` epoch, via `datetime.fromisoformat(...).timestamp()`, vs filesystem `st_mtime`, with a 1s skew tolerance favouring export on ties), and a file present on only one side propagates to the other. It **NEVER deletes a file** - only creates/overwrites the older side. A **read-only** project (`is_readonly`) exports only, never imports (the read-only guard direction).

Both `api.sync_workspace` (HTTP/Devii sync action, returns `{exported, imported}`) and `api.sync_bidirectional_sync` (reconciler, non-blocking `record_event` system actor, logs only when non-zero) call the same helper. The reconciler runs it before every `_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`, gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they never round-trip into the project.

## Run-as user = identity + API key ONLY (load-bearing constraint)

An instance's `run_as_uid` column selects WHICH DevPlace user's identity and `api_key` are injected (`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`), resolved in `api.pravda_env` ahead of the `created_by`/`owner_uid` fallback chain. It does **NOT** change the container OS user, which is ALWAYS `pravda` (uid 1000) - required for the bind-mounted `/app` (DooD uid maps 1:1 to host). Validate it against an existing user via `api.validate_run_as`.

## Boot source precedence (load-bearing)

Columns `boot_language` (`none`|`python`|`bash`) + `boot_script` (multiline source) sit alongside the legacy `boot_command`. Precedence in `api.run_spec_for`: `boot_script` (by language) > `boot_command` > image CMD (`sleep infinity`). When a boot script is set, the reconciler writes it into the workspace (`api.materialize_boot_script`, `.devplace_boot.py`/`.devplace_boot.sh`, excluded from sync) before launch and runs `python|bash /app/.devplace_boot.<ext>`. `api.validate_boot` enforces the language set and a 100k char cap.

## start_on_boot is per-container

The `start_on_boot` integer flag (0/1) forces `desired_state=running` ONLY for flagged instances when `ContainerService` first runs (`_boot_pass`, one-time); all others keep their last `desired_state`.

## Status-change choke point

Every reconciler status mutation flows through `service._set_status(inst, changes, reason=)`, which diffs old vs new `status` and, on change, writes an `instance_events` `status_change` row AND an audit `container.instance.status` `record_system` event (`old_value`/`new_value`). `_reconcile`, `_launch`, and `_handle_exit` (terminal + policy_restart) all flow through it, so previously-silent transitions (`running->stopped`/`->paused`/exit) are now logged and visible on the admin detail page's **Status history** card. New code that changes an instance's status in the reconciler must use `_set_status`, never a raw `store.update_instance(... status ...)`.

## New columns are ensured in init_db and defaulted in store.create_instance

`run_as_uid`/`boot_language`/`boot_script`/`start_on_boot` are added to the `instances` ensure-block in `init_db` (filtered/queried columns must exist on the cached schema) and seeded in the `store.create_instance` base dict so pre-existing rows degrade gracefully.

## Testing without Docker

Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).

## Vibe coding on-ramp (user-facing doc)

The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `PRAVDA_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.

When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.

**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
