Skip to content

Services

Services and ingress

subdomain compose service in-container host (debug)
code.${DOMAIN} code-server :8080 127.0.0.1:8080
opencode.${DOMAIN} opencode :4096 127.0.0.1:4096
chamber.${DOMAIN} openchamber :3000 127.0.0.1:3000
term.${DOMAIN} terminal (ttyd) :7681 127.0.0.1:7681
notes.${DOMAIN} obsidian :3001 (https) 127.0.0.1:3001
t3.${DOMAIN} t3code :3773 127.0.0.1:3773
— cloudflared upstream image, sole ingress —
— ollama :11434 not published — network only

ollama has no host port on purpose: nothing outside the compose network needs it, and the other services reach it as http://ollama:11434. Add a 127.0.0.1:11434:11434 port line locally if you want curl from the host.

The ingress rows come from config/cloudflared/config.template.yml, rendered to config.yml (gitignored) by setup.sh.

Choosing services

Every service except cloudflared sits behind a compose profile named after itself, so nothing auto-starts: if you select no services, the project is empty. setup.sh asks which to run every time — nothing is remembered between runs — and turns the answer into --profile flags:

Services to run (space-separated numbers, blank = keep current):
  1) [x] code-server
  2) [ ] opencode
  3) [ ] openchamber
  4) [ ] terminal
  5) [ ] obsidian
  6) [ ] t3code
  7) [ ] ollama
  8) [ ] ollama-gpu
> 1 2 3
  • Default when nothing is set: code-server only.
  • --with <service> (repeatable) skips the prompt; useful for scripts. There is no --unset: re-run, deselect, done.
  • AINV_SERVICES in .env merely seeds the checkboxes. It is not what runs, so a bare docker compose up outside setup.sh starts nothing — pass the profiles yourself or set COMPOSE_PROFILES.
  • openchamber pulls in opencode automatically. It has no function without it (it queries http://opencode:4096). setup.sh adds it for you, because compose refuses the project outright otherwise:
$ docker compose --profile openchamber config
service "openchamber" depends on undefined service "opencode": invalid compose project

So when calling compose yourself, always pass both profiles (--profile openchamber --profile opencode). The reverse is not true — opencode runs perfectly well alone.

terminal (ttyd) — auth is opt-in, and loud about it

ttyd is a root shell in a browser, so it is always loopback-only. Whether it also asks for a password depends on you:

TTYD_CREDENTIALS in .env behaviour
TTYD_CREDENTIALS=dev:s3cr3t basic auth required
unset / empty starts anyway, anonymous shell on 127.0.0.1:7681

It starts anonymously rather than refusing to boot, but not silently — look for terminal: WARNING anonymous (set TTYD_CREDENTIALS in .env) in its logs. Set one before putting it behind --tunnel; until then assume anything that can reach the port owns the machine. (Note ttyd rejects an empty --credential string, so the flag is omitted entirely rather than passed blank.)

obsidian — notes in the browser

The linuxserver/obsidian image streams the Obsidian desktop app into the browser (Selkies). Vaults live in the obsidian-config named volume, so they survive rebuilds.

  • The browser stack requires a secure context, so the loopback publish is on 3001 (HTTPS, self-signed); port 3000 is the HTTP entry meant for reverse proxies. Cloudflare terminates TLS at the edge, so the tunnel points at https://obsidian:3001 with noTLSVerify: true.
  • There is no authentication by default. Set OBSIDIAN_USER and OBSIDIAN_PASSWORD in .env before putting it behind --tunnel — the session includes a terminal with passwordless sudo, so anyone who can reach the GUI is effectively root in that container.
  • shm_size: 1gb is set: Electron needs it. CPU rendering is fine; a GPU is optional and not wired up here.

t3code — agent control surface (not yet supported)

T3 Code drives the agent CLIs installed in the image from its own web app — but it has not migrated to opencode v2 yet, the version this image ships, so there is no working provider inside the container today. The service still runs it headless:

t3 serve --host 0.0.0.0 --port 3773 /home/dev/workspaces
  • Auth is pairing, not a password. serve prints one-time pairing links at startup (docker compose logs t3code); open one to authorize that browser — swap the container IP in the printed URL for the host you use (127.0.0.1, or t3.${DOMAIN} through the tunnel). Need a fresh link? docker compose exec t3code t3 pair.
  • Sessions and settings live in the t3code-data volume (~/.t3/userdata); the runtime above it stays image-owned, so image rebuilds update T3 Code — t3 update inside the container is lost on recreate.
  • Loopback publish is 127.0.0.1:3773, and the pairing link is the only way in — there is no basic-auth password to set.

ollama: CPU and GPU variants

GPU passthrough is a hard requirement at runtime, not a speed knob — with devices: reserved, the container refuses to start when the host lacks the NVIDIA Container Toolkit. So it ships as two variants of one anchor:

profile starts without NVIDIA? notes
ollama yes — CPU inference default; safe everywhere including macOS
ollama-gpu no needs sudo ./setup.sh --nvidia on the host first

Both share the same image, networks, restart policy and the ollama-models volume, so models pulled in one are visible in the other. Neither publishes a host port — other services reach it as http://ollama:11434.

Neither is auto-detected: setup.sh cannot see the host's driver through the mounted socket (nvidia-smi is not a container concern), so pick the profile yourself.

Tunnel

cloudflared lives on the compose tunnel profile: without --tunnel it never starts. Later compose calls need the flag too — docker compose ps, logs, stop — or set COMPOSE_PROFILES=tunnel in .env once to make it sticky.

  • Routing comes from config/cloudflared/config.template.yml; with --tunnel, setup.sh renders it to config.yml (gitignored) substituting DOMAIN from .env, so nothing in the repo is hardcoded to example.com.
  • opencode serve runs the v2 API and the web UI on one port; the UI connects back to that same server, so there is no separate UI process.
  • openchamber points at that shared server via OPENCODE_HOST=http://opencode:4096 + OPENCODE_SKIP_START=true, so both web UIs see the same sessions and auth. Drop those two vars and it spawns its own private opencode instead.
  • With a tunnel up, remember the routes die with the stack: compose down means no remote access. Ssh in directly — --tunnel deliberately leaves no host-level fallback behind.

Tunnel authentication

Pick one:

  • Token-based (remote-managed): set TUNNEL_TOKEN in .env and mirror the rows in the Cloudflare dashboard, which owns the live config.
  • Locally-managed: cloudflared tunnel create, then uncomment tunnel + credentials-file in the template; it becomes authoritative. Keep the credentials json in config/cloudflared/ — it is gitignored.

Next: Configuration — the .env reference.