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_SERVICESin.envmerely seeds the checkboxes. It is not what runs, so a baredocker compose upoutsidesetup.shstarts nothing — pass the profiles yourself or setCOMPOSE_PROFILES.openchamberpulls inopencodeautomatically. It has no function without it (it querieshttp://opencode:4096).setup.shadds 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:3001withnoTLSVerify: true. - There is no authentication by default. Set
OBSIDIAN_USERandOBSIDIAN_PASSWORDin.envbefore 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: 1gbis 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:
- Auth is pairing, not a password.
serveprints 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, ort3.${DOMAIN}through the tunnel). Need a fresh link?docker compose exec t3code t3 pair. - Sessions and settings live in the
t3code-datavolume (~/.t3/userdata); the runtime above it stays image-owned, so image rebuilds update T3 Code —t3 updateinside 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.shrenders it toconfig.yml(gitignored) substitutingDOMAINfrom.env, so nothing in the repo is hardcoded toexample.com. opencode serveruns 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.openchamberpoints at that shared server viaOPENCODE_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 —
--tunneldeliberately leaves no host-level fallback behind.
Tunnel authentication
Pick one:
- Token-based (remote-managed): set
TUNNEL_TOKENin.envand mirror the rows in the Cloudflare dashboard, which owns the live config. - Locally-managed:
cloudflared tunnel create, then uncommenttunnel+credentials-filein the template; it becomes authoritative. Keep the credentials json inconfig/cloudflared/— it is gitignored.
Next: Configuration — the .env reference.