Getting Started
Quick start
Runs on Linux and macOS: it reads uname and skips whatever does not
exist on the host. On macOS there is no systemd, apt, usermod or NVIDIA
stack, so step 2 only checks that Docker Desktop is running and step 3 is a
no-op; sudo is unnecessary there (compose runs as you).
cp .env.example .env # secrets (+ DOMAIN when tunneling)
sudo ./setup.sh --dry-run # print the plan, write nothing
sudo ./setup.sh # asks which services to run, then builds + up
# localhost only. Expose it, and/or add a disk and GPU:
sudo ./setup.sh --tunnel
sudo ./setup.sh --tunnel --disk /nvme0n1-disk --nvidia # linux-only steps
# skip the prompt and name the services outright:
sudo ./setup.sh --with code-server --with opencode --with openchamber
setup.sh is idempotent — re-running is safe. It is also the only thing
you run outside docker: the host installs Docker and (optionally) a disk
layout, the NVIDIA stack, and a tunnel. Everything else lives in
docker-compose.yml.
Cookbook — first run on your host
Six checkpoints, each with a known-good result: stop and fix before the next.
0. Check the ground
sudo ss -ltnp | grep -E ':(8080|4096|3000)' # occupied = the old stack
docker compose version # compose plugin must exist
Leave the old stack running for now — it keeps remote access alive until step 5.
1. Configure
Set the UI passwords always; DOMAIN and TUNNEL_TOKEN only if you will
run with --tunnel. Lost the token?
2. Dry run
Expect every command prefixed [dry ], tunnel off — cloudflared stays down
(or ingress rows rendered for *.<your domain> when you passed --tunnel), and
tunnel credentials found. Nothing is written.
3. Real run
Four steps; the first image build takes minutes. The last one is two
commands — docker compose build then up -d — which setup.sh runs for you.
4. Verify inside out
docker compose ps # +1 when tunneling
docker compose logs cloudflared | grep -i registered # only with --tunnel
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ # 302 -> login
curl -s -o /dev/null -w '%{http_code}\n' -L http://127.0.0.1:8080/login # 200
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4096/ # 200
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/ # 200
docker compose exec opencode opencode --version
docker compose exec openchamber openchamber --version
docker compose exec -T openchamber node -e \
"fetch('http://opencode:4096/').then(r=>console.log(r.status)) \
.catch(e=>console.log('ERR',e.message))"
The last one proves both DNS-dependent pieces: service-name resolution on the
dev network and the openchamber → opencode wiring.
5. Verify outside in, then cut over
Skip this if you started without --tunnel — there are no routes to check.
Load https://code.<domain>, https://opencode.<domain> and
https://chamber.<domain>. Once they serve the new stack:
Rollback: docker compose down and re-enable those two units.
6. When it breaks
| symptom | cause |
|---|---|
cloudflared restarting forever |
empty/wrong TUNNEL_TOKEN — read its logs |
address already in use |
old code-server holds 8080: stop it or re-port |
cloudflared logs lookup code-server |
it is not on the dev network |
| 502 on a subdomain | target down, or hostname missing from ingress/dashboard |
| code-server cannot write | dirs made root-owned: sudo chown -R $USER ~/workspaces |
images gone after --disk |
they stayed in /var/lib/docker — see Storage |
Develop without deploying
To poke at the repo without provisioning a host at all:
docker run -it --rm \
-v ~/.ssh:/root/.ssh:ro \
-v ~/.local/share/opencode:/root/.local/share/opencode \
-v ~/workspaces:/workspaces \
-w /workspaces \
ghcr.io/anomalyco/opencode
Next: Services — what can run and how to pick it.