One container per workspace, each its own tailnet machine with a custom libghostty-powered agent interface, optional web VS Code, and the AI coding CLIs ready to run:
https://submind.<tailnet>.ts.net→ VS Code for submindhttps://submind.<tailnet>.ts.net:8444→ Forjara agent workspace
Valid HTTPS certs, tailnet-only, nothing published on the LAN.
The Forjara interface discovers projects, creates persistent tmux sessions,
optionally creates Git worktrees, launches agents, and keeps multiple terminals
open in tabs or a split. Terminal parsing, screen state, and keyboard encoding
come from an official pinned libghostty-vt.wasm build; the server, renderer,
and workspace UI are Forjara code.
The sidebar lists workspaces — each project checkout plus one entry per
worktree — with the agents running inside and an attention dot. The tab bar
holds the terminals of the selected workspace: + (or ⌘K) opens a new agent
there, ✕ stops one, and Split shows two side by side. Closing a workspace
stops its tabs and offers to remove its worktree; Git refuses to remove dirty
worktrees, and branches are always kept.
Above: the main workspace with two terminal tabs split side by side, and a
forjara/fix-api-tests worktree workspace ready in the sidebar. Every session
keeps running in tmux whether or not a browser is attached.
ghcr.io/lludlow/forjara contains the Forjara web service, the official
code-server release, tmux,
ripgrep, mise for per-project language runtimes, and:
| CLI | command |
|---|---|
| Claude Code | claude |
| OpenAI Codex | codex |
| Google Antigravity | agy (build with GOOGLE_AGENT=gemini for enterprise Gemini CLI) |
| opencode | opencode |
CLI logins persist in each project's /config volume — log in once per
project, survives container recreation.
Prereqs (one-time): MagicDNS + HTTPS certs
enabled on your tailnet; a reusable auth key tagged tag:forjara.
git clone git@github.com:lludlow/Forjara.git && cd Forjara
cp config/tsdproxy.yaml.example config/tsdproxy.yaml # paste your auth key
docker compose up -dOpen https://submind.<tailnet>.ts.net:8444, press + (or ⌘K), then pick
the project, agent, and whether it should get an isolated Git worktree.
VS Code remains available at https://submind.<tailnet>.ts.net.
Both interfaces are enabled by default. Set one environment variable before starting Compose to run only one:
FORJARA_SERVICES=vscode docker compose up -d
FORJARA_SERVICES=web docker compose up -dThe disabled interface's port is unavailable. The supported values are
vscode, web, or vscode,web.
Copy a workspace block in docker-compose.yml, change the service name, the
tsdproxy.name label, and the two volume lines, then docker compose up -d.
tsdproxy picks it up from the
labels and it appears on your tailnet.
The default Compose example mounts one project at /workspace. To use a
container as a projects hub instead, mount the directory containing them:
volumes:
- workspace-config:/config
- ${HOME}/projects:/workspaceWhen /workspace is a Git repository, Forjara treats it as one project. When
it is a directory of projects, immediate child directories appear separately;
plain folders work too.
Worktrees live under <project>/.forjara/worktrees/ and are excluded through
the repository's local .git/info/exclude. Closing a tab never deletes a
worktree; closing a workspace asks first, runs git worktree remove without
--force so uncommitted work survives, and never deletes the branch.
The base image stays small on purpose — projects bring their own toolchains. The repository owns its environment; Forjara owns the development experience.
Most projects need nothing but the pulled image and a mise.toml. In order
of how often you'll need them:
| Your project needs | Use | Build required? |
|---|---|---|
| Node (any version) | already in the image, corepack included | no |
| Go, Python, Rust, other runtimes | mise.toml in the repo |
no |
| OS packages (native libs, browsers) | small project Dockerfile | seconds, on the host |
| PostgreSQL, Redis, etc. | Compose sidecar service | no |
mise is preinstalled in the image. Drop a mise.toml
in the repo declaring what the project needs:
# a Go project
[tools]
go = "1.22"# a Python project using uv
[tools]
python = "3.12"
uv = "latest"Then, once, in any terminal tab of that project:
mise trust && mise installThat's it — go, python, uv now resolve in every terminal and agent
session, pinned to the project's versions. Runtimes install under /config,
so they survive container recreation; you never rebuild or restart anything.
Node projects usually need no mise.toml at all: the image ships Node 22
with corepack enabled, so a "packageManager": "pnpm@10.x" pin in
package.json resolves by itself on first pnpm run.
mise installs language runtimes, not apt packages. If the project needs
native libraries, database client tools, or Playwright's browser
dependencies, give it a small Dockerfile — .forjara/Dockerfile in the repo:
FROM ghcr.io/lludlow/forjara:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client libvips-dev \
&& rm -rf /var/lib/apt/lists/*
USER nodeand its workspace block uses build: with its own image tag — the complete
service is in docker-compose.derived.example.yml.
This is not "building Forjara" — it's an apt layer on top of the pulled base
image, built in seconds by the same docker compose up -d. Entrypoint,
agents, code-server, mise, and /config persistence are all inherited.
A project that needs PostgreSQL, Redis, or similar gets them as extra Compose services next to its workspace block — same network, reachable by service name:
atlas-db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD: dev
volumes:
- atlas-db-data:/var/lib/postgresql/dataInside the atlas workspace, the database is simply atlas-db:5432. The host
manages sidecars; the workspace never gets the Docker socket.
It also works the other way around: if your project already has a
docker-compose.yml with its app and services, add one service to it instead
of adopting Forjara's:
# your existing docker-compose.yml
services:
db:
image: postgres:17
# ...
forjara:
image: ghcr.io/lludlow/forjara:latest
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080" # Forjara UI -> http://localhost:8080
- "127.0.0.1:8443:8443" # VS Code -> http://localhost:8443
volumes:
- forjara-config:/config
- .:/workspace
volumes:
forjara-config:docker compose up -d and the workspace is live, on the same network as the
rest of your stack — your existing db service is already its sidecar,
reachable as db:5432. For tailnet access instead of localhost ports, drop
the ports: block and add the tsdproxy labels from the main example. The
same security notes apply: this container runs coding agents with your
credentials, so don't publish its ports beyond localhost or your tailnet.
Start the dev server in any terminal tab — tmux keeps it running when the
browser disconnects. With the vscode service enabled, code-server proxies
any local port over the existing tailnet hostname:
https://atlas.<tailnet>.ts.net/proxy/5173/
Apps that can't tolerate the path prefix can use /absproxy/<port>/ instead —
see the code-server proxy docs.
Tests are the project's own commands — go test ./..., pnpm test, pytest
— run in a tab like anything else. Forjara deliberately has no test-harness
abstraction or language detection.
Agent sessions receive FORJARA_SESSION_ID and FORJARA_EVENT_SOCKET.
Integrations can update the sidebar without parsing terminal output:
forjara-web signal busy
forjara-web signal awaiting_input
forjara-web signal idle
forjara-web signal notificationForjara reports agent process start and exit automatically. Agent-specific hooks may invoke the commands above; they are delivered over a private Unix socket and streamed to open browsers.
- Keep Tailscale Funnel off — these containers hold live Anthropic/OpenAI/ Google credentials.
- Never mount
/var/run/docker.sock,~/.ssh,~/.aws, or host/homeinto a workspace. (tsdproxy holds the socket; the workspaces never do.) - Scope the auth key with a
tag:forjaraACL. - Running agents unsupervised? Add an egress firewall — see Anthropic's devcontainer reference.

