Containers
How Borago Board builds and runs a project container. Generated files, base images, architecture, memory, ports, networks, gVisor and the policy proxy.
Every project runs in one Docker container on the user's own machine, built from a Dockerfile and a Compose file the desktop app generates and keeps per account. The agent works inside that container and nowhere else. This page describes what the app writes, what it still applies to a file the user has edited, and what each lifecycle button does. Everything here happens on the desktop app or a headless server with Docker. The cloud board has no Docker and asks the desktop to do these things through the relay.
What the app generates
A project whose container name is Auto-generated (the default on the Containers tab) gets two files. A project marked Custom points at a container the user runs themselves. For a custom container the app builds nothing, runs nothing and only opens a shell inside it by name, so the Ports, Docker Compose and Dockerfiles tabs are hidden.
| File | What it contains | Where it lives |
|---|---|---|
| Dockerfile | The chosen base image, a non-root user with passwordless sudo, git, Node, ripgrep, an SSH client and unzip, git trust for the workspace, and, with container management on, Docker's package repository and the Docker command-line client | One folder per project under the app's Projects directory, in a subfolder per account, so two accounts on one machine can each have a project with the same name |
| Compose file | One service named after the project, the workspace bind mount when a local path is set, a keep-alive command, an automatic restart policy, and the additions described below | The same folder |
The files under that folder are the ones the app runs. They are written on every save of the project and again before every start, so the project dialog is the only place to change them. A Compose file inside the project's own local path is not read. Generate Docker Boilerplate on the Docker Compose tab regenerates both files in the dialog from the current settings and drops any edits. See File locations.
A file edited on the Docker Compose or Dockerfiles tab is stored and used verbatim from then on. Five things are still applied to a stored Compose file every time the container is brought up, because a setting that only reached the generated text would silently stop working for exactly the projects most likely to need it.
| Injected into a stored Compose file | Rule |
|---|---|
| Ports | The Ports tab's rows replace the service's published ports. Everything else in the file is kept |
| Startup script | The Scripts tab's startup script becomes the service's start command, followed by the keep-alive. A blank script leaves the file's own command alone. When the project has a git URL, the script waits for the clone before it runs |
| Sandbox runtime | On a Linux host with gVisor registered, the service runs under the gVisor runtime, with the DNS arrangement described under gVisor below. When gVisor is absent the runtime key is removed, so a project still starts after gVisor is uninstalled |
| Memory cap | The memory limit and the swap limit, set to the same value. A hand-written limit whose swap value differs is left alone |
| CPU architecture | A pinned architecture becomes the service's platform. A platform the user typed that names anything else is left alone |
A Compose file that declares its own DNS or networks keeps them. Environment variables and git credentials are never written into either file. They are injected into each process at the moment it starts, described under Environment below.
Base images
The Base image select on the Containers tab offers a short list. Each entry was built from the generated Dockerfile and checked.
| Label | Notes |
|---|---|
| Ubuntu 26.04 LTS | The default for a new project |
| Ubuntu 24.04 LTS | What a project with no stored choice means |
| Debian 13 (trixie) | Ships no built-in user, which the generated file handles |
Three rules:
- A project created before the setting existed has no stored choice and stays on Ubuntu 24.04. Reading "no choice" as "the newest" would move every older project to another OS release at its next start.
- A hand-edited Dockerfile decides its own base image. When its FROM line names a listed image, changing the select rewrites that line, visibly, on the Dockerfiles tab. When it names anything else, the select is disabled and says the Dockerfile decides.
- A change takes effect the next time the container is started, restarted or rebuilt, and the app offers the rebuild when the container is running.
The choice syncs between the user's devices. A peer running an older build that does not know an image keeps its local value rather than falling back.
CPU architecture and emulation
By default a container follows the Docker daemon. On an Apple silicon Mac that is arm64, on a PC it is x64, and nothing in the generated Dockerfile is architecture-specific. The default is stored as no value, because the right answer differs per machine and a project synced from a Mac to a PC must not carry the Mac's answer with it.
The Containers tab's architecture control can pin x64 or arm64. A pinned value is a statement about the project, that its dependencies exist for that architecture only, so it syncs like any other choice. On a machine of the other architecture the container then runs under emulation, which is slower, and never under gVisor, because the sandbox cannot run emulated binaries. An unknown daemon keeps gVisor as asked.
| Assessment | When | What happens |
|---|---|---|
| Native | No pinned value, or the pinned value matches the daemon | Nothing to say |
| Emulated | Pinned value differs and the daemon can emulate it | The container starts. A notice says once per project that it runs emulated, slower and without gVisor |
| Unsupported | Pinned value differs and the daemon cannot emulate it | The start is refused before any build, with the fix. On a Linux engine the fix is the one-line binfmt install command |
| Unknown | The daemon has not reported its architecture | The start proceeds and the field says it does not know |
The same assessment renders the warning under the control and runs before every start, restart, rebuild and family start, so the field and the Start button cannot disagree. Refusing up front matters because the alternative is a build that runs for minutes and dies on an exec format error.
Two best-effort checks only inform. They never change the setting. Before a build, a Dockerfile line that would fail on the other architecture raises a notice naming the line. After a failed build, an architecture mismatch is recognised and the notice opens the Containers tab rather than the Dockerfile tab, because the fix is the architecture control. Neither sees what a task installs at run time.
Memory
The Automatic checkbox on the Project Info tab, ticked by default, means the cap is derived from whichever machine runs the container. Unticking it enables a slider and a box for a number of the user's own, and the derived figure stays visible while automatic is on so the user can judge the choice. A stored number means "this project needs at least this much" and syncs. Automatic does not sync, for the reason the architecture default does not.
The automatic cap is not a share of the machine. Its job is to keep the host alive, not to ration. The host keeps a reserve for itself, larger on a small machine than it needs to be on a big one, and the container gets the rest. A user may set a value as low as 256 MiB and as high as the host's total. Units are GiB throughout, and the figure is what the kernel can allocate, so a machine sold as 16 GB reads as roughly 14.4 GiB here.
An unknown host produces no limit at all. A guessed low limit fails builds with an out-of-memory kill that names nothing, which is worse than no cap. On the cloud board the host's memory is not known, so the field says so and still accepts a number, since a value typed there is a statement about the project and applies wherever it runs.
The cap is per container, so three containers can still exhaust a host between them. That is contention rather than a runaway, and the answer is a lower value per project.
The container restarts by itself when it dies, so a container that dies at three in the morning comes back without anyone at the desktop. That would make the failure invisible, so the app watches for it. A container that restarted raises a notice, and one that died under its memory limit says so. A container that keeps restarting gets a notice saying it does not recover on its own. A container that comes back up also nudges the queue, so tasks that were waiting on a dead container start again.
Ports
Each row on the Ports tab maps a host port to a container port and has a Check button and an Expose to LAN switch. A published port binds to 127.0.0.1 unless Expose to LAN is on, because a Docker-published port bypasses the host firewall. Every other service in a stored Compose file has its unaddressed ports bound to loopback at start too, so a database beside the app is not published on every interface. Settings, then Docker, has Keep project containers off the local network, which overrides every project on that computer and applies at the next recreate. The panel under it reads the live bindings and names any container still publishing to the network, since a switch is not the state.
A host port has to be free in two senses, and Check confirms both: no other project on this board claims it, even a stopped one, and it can be bound on this machine right now.
A clash names the project that holds the port. The project being edited never conflicts with itself, and while its own container is running, the ports that container publishes are treated as free, since the restart that applies the save replaces it. Leaving the Ports step and saving a project both run the check on every filled row. On the cloud board only the database half is answered, and the desktop's own check has the last word at start.
The first port row is blank. Each row after it proposes the previous row's host port plus one, unchecked. The tab also warns live when the host port is one browsers refuse to open, such as 6000 or 6666, and suggests a browsable one. That warning never blocks a save.
Networks
Each project gets a network of its own, created by the app, with a fixed name that a firewall rule can refer to. Every service in the project's Compose file joins that network, and containers the agent creates are placed on it. The network lives until the project is destroyed. Stopping keeps it. A project created on an older version keeps Docker's shared network until its container is recreated or it is moved from Settings, then Docker.
Two projects reach each other only when the user connects them. Connected projects on the Containers tab is a picker of projects, never of networks. Each connection is one small private network shared by the two containers, on which each reaches the other by the other project's name. Connections are pairs. Connecting A to B and B to C does not connect A to C. The run knows which projects it can reach. See Connected projects.
Moving an existing project onto its own network recreates its container, so it never happens silently. A container that does not exist yet, a Restart container, a Rebuild container, or Move to their own networks under Settings, then Docker, does it, each behind the usual confirmation when work would be lost. A plain start of an existing container keeps it where it is. On Linux the firewall rule for the policy proxy is specific to the project's own network, and applying it removes the old rule in the same prompt.
gVisor
On Linux, when the gVisor runtime is registered with the Docker daemon, every project container runs under it. gVisor is a user-space kernel that intercepts the container's system calls, so the agent and every container it creates are sandboxed from the host kernel. It is opt-in by installation, not by setting. The app reads the runtimes the daemon advertises, so a gVisor binary on the path without the daemon registration counts as absent.
| Host | Runtime | Warning |
|---|---|---|
| Linux with gVisor registered | gVisor | None |
| Linux without gVisor | Docker's default runtime, unsandboxed | An amber notice on the project dialog with an Install gVisor button and a link to the install guide. Saving is never blocked |
| macOS and Windows | Docker's default runtime inside Docker Desktop's virtual machine | None. The virtual machine isolates the host and gVisor is not installable there |
On Linux the app's Docker installer installs gVisor in the same administrator prompt as Docker, and Settings, then Docker, has Set up gVisor for a machine that has Docker without it. A gVisor download that fails is reported and Docker still works. See Installing Docker with the app.
Two consequences of the sandbox are worth knowing. A sandboxed container resolves names through public DNS servers rather than Docker's own. And sudo does not work inside a sandboxed container even though the Dockerfile grants it. In that case the agent installs into its home directory to finish the task and still reports the build-time command, because Dockerfile steps run as root at image build.
A container that existed before gVisor was detected keeps its old runtime until it is restarted.
Container management and the policy proxy
Allow this container to manage Docker containers, on the Containers tab, is off by default and carries a trust warning. When it is on, the agent can run Docker commands inside its container, for a database or a compose stack beside the project. It never receives the Docker socket, because a container with the socket is effectively root on the host. Instead, every call goes through a policy proxy the desktop runs for that project.
| The proxy | Rule |
|---|---|
| What the agent may create | Containers, volumes and networks for this project. Each created container runs under gVisor where the host has it and is marked as the project's, so the board can list and stop it |
| What is refused | Privileged mode, host folder mounts, host networking, added capabilities and the other ways onto the host. Every prune, since each acts on the whole machine. Anything that belongs to another project or that the app did not create, including giving an image a name another project's image already holds |
| Ports | A port a sibling publishes stays on this computer unless Let other devices reach containers the AI creates is on for the project. Keep project containers off the local network on that computer overrides it |
Switching the setting on takes effect at once. The app installs the Docker client into the running container in the background and adds it to the stored Dockerfile so a later rebuild keeps it. The agent works inside the project container and everything it creates runs beside it as a sibling, so a backing service survives a rebuild of the project container. A refused request names the setting. On macOS and Windows created containers are tracked and restricted the same way but not sandboxed by gVisor.
On Linux a host firewall such as ufw drops container-to-host traffic by default, so one rule is needed, allowing one port per project. The Containers tab shows the port, whether the container can reach the proxy right now, the exact command, and buttons to add or remove the rule. Adding it raises one administrator prompt. If the desktop cannot ask for a password, a terminal opens for the user's own sudo, and failing that the command is shown to copy. Whether the rule works is checked from inside the container, so showing the panel needs no password. A rule that does not work follows the run as a manual step and a notification.
Before a run starts, with management on, the app checks that the container can reach the proxy. If it cannot, the queue pauses with the allow rule in the sentence, and Resume checks again. The manual run button is refused on the same verdict and the task is queued instead. See The queue and its holds and Container management.
Lifecycle operations
The board's project controls, the container list dialog and the cloud board all drive the same operations. A relayed press behaves exactly like a local one.
| Control | What it runs | Recreates the container | Confirmation |
|---|---|---|---|
| Start container | Builds the image if needed and brings the service up | Only if it does not exist yet | None |
| Stop container | Stops the container and keeps it, and stops the project's policy proxy | No | None |
| Restart container | Recreates the container and re-applies the Dockerfile, so new ports take effect and tools recorded in the Dockerfile are built back in | Yes | Only when the code lives only in the container |
| Rebuild container | Builds the image from scratch, ignoring the build cache, then recreates. For a moved base image, newer packages or a vendor download that now serves a different version | Yes | Always, and marked dangerous when the code lives only in the container |
| Start all containers and Stop all containers | The whole family, the project container plus everything the agent created and any sibling containers the user named | No | A prompt asking all containers or only the project container, when children exist |
| The container list's per-row start, stop and restart | One container of the family by name. That restart keeps the container | No | None |
Restart's rebuild of the image is deliberate, not an optimisation. The recreate is what discards the container's writable layer, so the recreate is exactly when the image must be current. An unchanged Dockerfile costs a few seconds of cache hits.
The confirmation appears when the project has a git URL or a git credential and no local path. The code then exists only inside the container, and recreating it resets the workspace. Uncommitted changes and unpushed commits are lost. With a git URL a clean tree is cloned again on start. The dialog says to push or copy out anything needed first, and that a plain stop and start keeps the workspace. With a local path the workspace is a bind mount on the host and survives every operation. See Recovering things.
Before any build the app refuses a start whose workspace folder is not on this machine, with a notice that opens the local path field, because Linux Docker would otherwise mount an empty directory over the workspace.
What happens on first start
After the container is up, in order:
- Git maintenance runs. The commit identity is written for processes outside the app's own shells, credential files left by older versions are removed, and a stored key this device cannot decrypt raises a notification. A failure here is a warning, never a failed start.
- When the project has a git URL, the repository is cloned into the container's workspace. Only into an empty workspace. An existing checkout is skipped and any other content refuses the clone rather than clobbering files. A configured branch is checked out, and a checkout that is already on another branch with uncommitted work is left alone with a note. A failed clone fails the request with the git output and a notification routed to Project Info, and the container stays up.
- The startup script from the Scripts tab runs as part of the container's start command, in the workspace, after the clone. Its lines run in sequence. A failed line does not stop the container, so a shell can still be opened to debug it. The named scripts marked to run at startup fire next, not awaited, streaming their output to the board.
- The policy proxy is started when management is on, and the queue is nudged.
Environment and git credentials
Environment variables from the Environment Variables tab and the project's git credential and commit identity are injected into the process environment at the moment a task run, a terminal shell, a script or a flow starts. They are never written into the Dockerfile, the Compose file or the project's files. Both are re-applied on every start of a process, so a container restart or rebuild never loses them, and nothing credential-shaped is left at rest in the container. A process started by some other means inside the container does not see them. That is the deliberate trade.
Neither a key nor a token is left in a file inside the container. A variable of the same name on the Environment Variables tab wins over the injected entry. The full model, including what the cloud can and cannot read, is on Security and Secrets and environment variables.
When a start fails
One notification carries the reason a start failed and opens the tab that holds the fix. The app names the cause when it recognises one: a host port another process or project already holds (the Ports tab), a Dockerfile that failed to build (the Dockerfiles tab), no reachable Docker daemon, a container name already in use, or an image or binary built for another CPU architecture (the Containers tab), and a folder the project mounts that is missing on this machine (Project Info with the Local path field focused, or the Docker Compose tab, depending on how Docker reported it). Otherwise the notice shows the last meaningful lines Docker printed and opens the Containers tab. Every recognised cause is listed with its exact sentence on the States page.
Disk hygiene
The board must not fill the user's disk, and it cleans up as it goes.
- A successful build removes the image it replaced, unless that image is still tagged, shared or used by another container, in which case it stays.
- Deleting a project stops its family and keeps everything, because a restore within 30 days must find the container's writable layer intact. That is where a container-only workspace's uncommitted work lives.
- Destroying a project, by Delete on a recycle bin entry, Empty now, or the 30-day expiry, removes the family's containers, the volumes and networks Compose created for it, and the images the app built for it. Base images the agent pulled are shared by nature and never touched. See Recovery.
- An account switch removes nothing. Switching back brings the board back.
- What the agent creates and no longer needs is removed.
Settings, then Docker, has Docker disk usage, the ledger and the broom for what the automatic cleanup misses. It shows Docker's own usage table and every container family on the daemon, each marked known when some project on this board owns it, recycle bin residents included, or stale. It is fetched on open and on Refresh, never polled. Clean up removes dangling images and the build cache only, across the whole daemon, behind one confirmation. It never runs a full system prune, which would take every stopped container on the machine including one holding uncommitted work, and it never touches volumes. The Reclaimable column is Docker's figure for a full prune and is always larger than what Clean up frees, and the panel says so. A stale family has a per-row Remove behind a danger confirmation. The server refuses a known family, since its project's lifecycle owns it, and a running family, since it may belong to another account on this machine that shares the daemon. There is deliberately no remove-all button. This section is not relayed to the cloud board. A wrong cleanup is unrecoverable, and the desktop's Settings is where its disk is managed.
Dependency persistence
With a local path the workspace is a bind mount, and every other path in the container is a throwaway writable layer. Without one, the workspace is part of that layer too. A tool a run installs with apt, a global package install or a download into a bin directory is present for that run and gone at the next recreate unless it reaches the project's Dockerfile.
The app records the build-time commands for anything a run installed and adds them to a marked section of the Dockerfile. When a run installs something without reporting it, the app notices the install itself. Those are added to the Dockerfile as lines to try, an action comment and a notification name them, and the next start, restart or rebuild is the trial. A successful build keeps them. A failed build removes them and says which lines were removed, because a Dockerfile that fails to build is worse than a missing tool. When the commands cannot be rewritten for build time, nothing is added and the action comment carries them as they ran, for you to add by hand. A notification also says when the Dockerfile has changed and the running container has not, and the next Restart container applies it.
Related: Container management, Ports and LAN access, Connected projects, The relay, Security.