How the container is configured
Each environment is a Docker container that Forge Fleet assembles from your
ffleet.toml and flags. This page explains the pieces so you can tune them.
Image
image (or --image) selects the Docker image the agent runs in. Default:
ghcr.io/grzegorz-aniol/forge-fleet-python:latest (pulled from GHCR on first
use — no local build required). A Go-dev variant
(ghcr.io/grzegorz-aniol/forge-fleet-go:latest, also published to GHCR) overlays
a Go toolchain for developing the Go port. Pick or build an image that carries the
language runtimes and tools your project needs — the agent can only use what's in
the image (plus what it installs at runtime).
Because image is a config-derived setting, you can change it and pick it up on
the next revive: ffleet stop SLUG then ffleet up SLUG (see
what can change mid-life).
How the published images are built
The two default images are built and pushed to GHCR from the public
ffleet-dist repo (its
publish-images workflow), where the Dockerfiles live:
ghcr.io/grzegorz-aniol/forge-fleet-python:latest— the base image (Debian bookworm-slim). It carries the agent toolbox: the Claude Code and Codex CLIs, Python 3 + uv, Node 22 (with corepack for pnpm/yarn), plusgit,gh(GitHub CLI), thedockerCLI (client only, for DooD),tmux,jq,make, and a C build toolchain.ghcr.io/grzegorz-aniol/forge-fleet-go:latest— a superset overlay of the base that adds the Go toolchain (for developing the Go port); the rest of the toolbox is identical.
Each is published multi-arch (linux/amd64 + linux/arm64), so Apple
Silicon Macs pull and run a native image without emulation.
Both run as a non-root user buddy (uid/gid 1000) with home
/home/buddy, and the workspace directory is owned by buddy. That is why credential and
cache mounts land under /home/buddy/... (e.g. ~/.claude →
/home/buddy/.claude), why extra_mounts into the container home use that
path, and why host Docker access adds buddy to the host docker group so it
can reach the socket (see DooD). If you build a custom image,
matching the buddy uid/gid 1000 keeps mounted-file ownership aligned with
your host user.
Volumes & mounts
Several things get mounted into the container:
- The workspace — the git worktree (or, in
--here/offmode, your current directory) is mounted into the container at its host-identical absolute path (e.g./srv/worktrees/creator-ai/creator-ai-feat1), not a fixed/workspace. This is where the agent does its work; it also gives every environment a unique container cwd, which matters for Codex session resume (see below). uv_cache_dir— a shared uv cache (default~/.cache/uv) for faster Python installs across environments.extra_mounts— your own bind mounts.
extra_mounts — your own bind mounts
Each entry is source:target[:ro|:rw]. The target is an absolute container
path; the source is a host path that must already exist — ffleet resolves it
before starting the container and fails with the resolved path if it doesn't.
extra_mounts = [
# Read-only reference material the agent should read but never edit.
"~/notes/architecture:/home/buddy/architecture:ro",
# Read-write shared cache, so every environment reuses one Go module cache.
"~/.cache/go-build:/home/buddy/.cache/go-build:rw",
# `rw` is the default — this line means the same as the one above.
"~/.cache/go-build:/home/buddy/.cache/go-build",
# A relative source resolves against the shell's cwd when `ffleet up` runs.
"fixtures/large-dataset:/home/buddy/dataset:ro",
]
Things worth knowing:
roandrware the only accepted modes, and the default is read-write. Anything else (z,delegated,cached) is rejected. Reach for:rowhenever the agent has no business writing to the host path — it is the cheapest way to stop an agent editing your real files.~expands to your host home, which keeps a source portable across machines. A relative source, by contrast, is resolved against the shell's current directory at the moment you runffleet up— not against the repo root — so the same entry breaks when you launch from a different subdirectory. Prefer absolute or~-relative sources; keep relative ones only for a directory you always launch from.- The container runs as
buddy(uid/gid1000), so mounts meant for the agent's home belong under/home/buddy/.... A host directory owned by a different uid will be read-only in practice regardless of the mode you ask for. - Shadowing works, exact duplicates don't. A target nested inside an
existing mount is layered on top of it, leaving the rest of that mount intact —
so
"~/shared-agents:/home/buddy/.claude/agents:ro"shares one set of agent definitions across projects without disturbing the rest of the mounted~/.claude. Targeting the exact path of a built-in mount (/home/buddy/.claudeitself, the workspace root, the uv cache) instead makes Docker refuse to start the container with a duplicate-mount-point error; change those through their own settings ([claude].dir,uv_cache_dir). ffleet upprints every resolved mount — extras and built-ins — while starting, which is the quickest way to check what actually landed where.
See secrets for when to mount vs. copy vs. env-file; a mount is the right answer when the container should see a host path live and as-is.
Credential directories — authenticating the agent inside
The agent must be logged in inside the container. Forge Fleet handles this by mounting the agent's host credential/config directory:
- Claude Code —
[claude].dir(default~/.claude), plus a credentialauthsource (auto/keychain/token/api-key/credentials-file/external) that decides which credential ffleet injects. See the[claude].authtable. - Codex —
[codex].dir(default~/.codex).
Override the mounts per run with --claude-dir / --codex-dir, and the Claude
source with --claude-auth. The upshot: because your host login is mounted in,
you don't re-authenticate per environment.
Codex's SQLite databases have separate, persistent storage for each environment.
That includes logs_2.sqlite and its -wal / -shm files, so simultaneous
environments do not write to the host's shared log database. Credentials,
configuration and session transcripts still use your mounted Codex directory.
ffleet stop preserves the environment's database files; ffleet up reuses them,
and ffleet remove deletes them with the environment's metadata.
Forge Fleet mounts a read-only administrator policy at
/etc/codex/requirements.toml that pins sqlite_home to the environment's
storage. This covers managed launches and manual codex commands, including
conflicting user config, CODEX_SQLITE_HOME and command-line arguments. No host
Codex files are edited or migrated. Codex chooses database filenames and handles
migrations; Forge Fleet keeps the whole directory, including sidecars.
Before starting a container, Forge Fleet checks the image's real Codex offline,
without credentials. An unsupported CLI produces an actionable error: update or
rebuild the image and retry. Images containing their own nonempty
/etc/codex/requirements.toml are rejected because Forge Fleet cannot safely
combine arbitrary administrator policies. Extra mounts cannot replace the
Codex policy, SQLite storage, or files within that storage. Custom [codex].cmd wrappers must run a
compatible CLI that respects this policy; enterprise/cloud policy overrides
are outside this local-image guarantee.
Running containers need both the SQLite mount and policy. After upgrading
Forge Fleet, run ffleet stop <slug> followed by ffleet up <slug> from that
project's directory, using the environment's short name. If a managed Codex
relaunch finds either missing or incorrect, it reports these commands. Existing
live sessions continue until you stop them.
Codex resumes the most recently used session within its cwd filter, not by ID. Unlike Claude, the Codex CLI has no way to resume a specific session by an externally chosen ID —
ffleet upon a stopped Codex environment runscodex resume --last --include-non-interactive. This includes older conversations launched withcodex execwhile still filtering sessions to the same working directory. Because each environment's container workspace is always mounted at its own host-identical path (see "Volumes & mounts" above), concurrent Codex environments get distinct cwds even though they share[codex].dirby default — so--lastresolves to the right environment's session in the common case. This isn't an absolute guarantee for every possible setup, so if you ever see a Codex environment resume the wrong session, giving that environment its own--codex-dirremoves the ambiguity entirely.
extra_hosts
extra_hosts maps hostnames to addresses via docker run --add-host, letting
processes inside resolve names to the host or to arbitrary addresses. Each entry
is host:address; the special value host-gateway resolves to the host itself —
handy for reaching a service running on your machine:
extra_hosts = ["host.docker.internal:host-gateway"]
Reaching a service on your host (e.g. an MCP server)
A common case: the agent needs to reach something running on your host — an MCP server, a local API, a dev database. Two pieces must line up:
- Give the container a name for the host. Map
host.docker.internalto the host gateway:
extra_hosts = ["host.docker.internal:host-gateway"]
Then point the agent at host.docker.internal:<port> (e.g. in your Claude MCP
config under the mounted ~/.claude).
2. Bind the host service so the container can reach it. host-gateway
reaches your host on its bridge interface, not its loopback — a service
bound only to 127.0.0.1 on the host is unreachable from the container. Bind
it to 0.0.0.0 (or the docker bridge address) instead.
Editing your host's /etc/hosts doesn't help here: it only changes name
resolution on the host, not inside the container (which has its own
/etc/hosts). The container-side mapping is exactly what extra_hosts provides.
Pointing a hostname at a fixed address
An address other than host-gateway is taken literally, which lets you pin a
name the container would otherwise fail to resolve — an internal service on your
VPN, or a staging host you want the agent's tests to hit:
extra_hosts = [
"host.docker.internal:host-gateway",
"api.internal.example.com:10.0.3.14",
"staging.example.com:203.0.113.7",
]
Entries are independent, so mix host-gateway and literal addresses freely. This only maps names to addresses — the container still needs a network route to reach them, so a VPN-only address works from a container only if the host routes that range.
Host Docker access
docker_host_bind = true (or --docker-host-bind) binds the host Docker socket
so the agent can drive the host daemon — this is the Docker-outside-Docker setup;
see DooD.
Git inside the container
The container gets no SSH key and no host git config. Its init script instead
rewrites git remotes to HTTPS in the container's own gitconfig
(url."https://<host>/".insteadOf "git@<host>:", host taken from your origin),
so the agent only needs a token — see
git push credentials. The repository's own git
config is never modified. It also stamps in a commit identity, without which the
container refuses to start.
Both of those, along with git_mode, --from and denying the agent remote
writes, are covered in git options.
Putting it together
image = "ghcr.io/grzegorz-aniol/forge-fleet-python:latest"
uv_cache_dir = "~/.cache/uv"
extra_mounts = ["~/.agents:/home/buddy/.agents"]
extra_hosts = ["host.docker.internal:host-gateway"]
[claude]
dir = "~/.claude"
# auth = "auto"
[git]
user_name = "Jane Developer"
user_email = "jane@example.com"