···11# CLAUDE.md
2233-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
33+This file provides guidance to Claude Code when working with code in this repository.
4455## What this repo is
6677-A Docker Compose deployment stack for [Spindle](https://tangled.org) (a CI/CD pipeline tool) backed by [OpenBao](https://openbao.org) (an open-source HashiCorp Vault fork) for secrets management. Spindle is not developed here — it is cloned from `tangled.org/core` and built inside `Dockerfile.spindle`.
77+A Docker Compose deployment stack for [Spindle](https://tangled.org) (a CI/CD pipeline tool) backed by [OpenBao](https://openbao.org) (an open-source HashiCorp Vault fork) for secrets management. Spindle is not developed here — it is cloned from `tangled.org/tangled.org/core` at tag `v1.13.0-alpha` and built inside `Dockerfile`.
8899-## First-time setup
99+## Current state
10101111-```bash
1212-cp .env.sample .env
1313-# Set SPINDLE_SERVER_HOSTNAME and SPINDLE_SERVER_OWNER in .env
1111+This stack is actively being tested for first-time deployment on Ubuntu Linux. The init script and Docker config have had several fixes applied and may need further iteration.
14121515-docker compose up -d openbao
1616-# Wait ~5s for health check, then:
1313+Known working:
1414+- OpenBao starts and reads config correctly
1515+- `init-openbao.sh` fixes data volume permissions, restarts OpenBao, and runs the full vault bootstrap
17161818-chmod +x scripts/init-openbao.sh
1919-./scripts/init-openbao.sh # ONE-TIME ONLY — save the unseal key and root token it prints
2020-2121-docker compose up -d
2222-```
2323-2424-> `init-openbao.sh` writes AppRole credentials into the `openbao-approle` Docker volume using a temporary Alpine container. The volume name it targets is hardcoded as `tangled-spindle_openbao-approle` — if the Compose project name changes, this line must be updated.
2525-2626-## After every restart
2727-2828-OpenBao seals itself on restart. Unseal before the proxy or Spindle can start:
2929-3030-```bash
3131-docker compose exec openbao bao operator unseal http://localhost:8200 <unseal_key>
3232-```
3333-3434-## Verify the stack
3535-3636-```bash
3737-curl http://localhost:8201/v1/sys/health # OpenBao proxy
3838-curl http://localhost:6555/ # Spindle
3939-```
1717+Known issues / things being tested:
1818+- Full end-to-end stack (openbao → openbao-proxy → spindle) not yet verified
1919+- Spindle healthcheck is intentionally omitted pending manual testing of its HTTP endpoints
40204121## Architecture
4222···5131 Host Docker daemon (pipeline containers run here)
5232```
53335454-- **openbao** — vault backend with file storage; sealed on every start
5555-- **openbao-proxy** — AppRole sidecar that auto-authenticates and caches a token at `/tmp/openbao-token`; Spindle reads secrets through this proxy
5656-- **spindle** — starts only after the proxy is healthy; mounts the Docker socket to spawn CI pipeline containers on the host daemon
3434+- **openbao** — vault backend, file storage, sealed on every start
3535+- **openbao-proxy** — AppRole sidecar, auto-authenticates, token cached at `/tmp/openbao-token`
3636+- **spindle** — CI runner, starts only after proxy is healthy, mounts Docker socket
57375838## Key config files
59396040| File | Purpose |
6141|------|---------|
6262-| `docker-compose.yml` | Service definitions, volumes, healthchecks, dependency order |
6363-| `Dockerfile.spindle` | Clones `tangled.org/core`, builds with Go 1.23, produces minimal Alpine image |
6464-| `config/openbao/server.hcl` | OpenBao server (file storage, TCP listener on 8200, TLS off) |
6565-| `config/openbao/proxy.hcl` | Proxy AppRole auto-auth, token sink at `/tmp/openbao-token`, listener on 8201 |
4242+| `docker-compose.yml` | Service definitions, volumes, port bindings, dependency order |
4343+| `Dockerfile` | Clones `tangled.org/core` at `v1.13.0-alpha`, builds with Go, produces Alpine image |
4444+| `config/openbao/server/server.hcl` | OpenBao server (file storage, TCP listener on 8200, TLS off) |
4545+| `config/openbao/proxy/proxy.hcl` | Proxy AppRole auto-auth, token sink at `/tmp/openbao-token`, listener on 8201 |
6646| `config/openbao/spindle-policy.hcl` | Grants Spindle KV v2 CRUD on `spindle/data/*` and `spindle/metadata/*` |
6767-| `scripts/init-openbao.sh` | One-time bootstrap: init vault, enable KV v2, create AppRole, write credentials to shared volume |
4747+| `init-openbao.sh` | One-time bootstrap: fixes volume perms, inits vault, enables KV v2, creates AppRole |
4848+| `.env.sample` | All configurable env vars with defaults — copy to `.env` before starting |
68496969-## Notes
5050+## Important implementation details
70517171-- TLS is disabled on both OpenBao listeners. Put nginx or Caddy in front for production.
7272-- The `openbao-approle` volume is mounted **read-only** by the proxy and **read-write** by the init script (via a temporary container).
7373-- Spindle's Docker socket mount means pipeline containers run on the **host** daemon — ensure the container user has socket access.
5252+- Config directories are split: `config/openbao/server/` and `config/openbao/proxy/` are mounted separately so each service only sees its own HCL files. Mixing them caused OpenBao server to fail parsing proxy-specific stanzas.
5353+- The `openbao-data` Docker volume is created root-owned by default. `init-openbao.sh` fixes this with `docker compose exec --user root openbao chown -R openbao:openbao /openbao/data` before running init.
5454+- All `docker compose exec` calls in the init script use `-T` with `</dev/null` to prevent stdin hanging.
5555+- OpenBao port 8200 is bound to `127.0.0.1` only (not exposed to the network).
5656+- Both OpenBao and its proxy have `IPC_LOCK` capability to prevent secrets swapping to disk.
5757+- All images are pinned to SHA256 digests. Spindle source is pinned to commit `3572988b89fa093269ae78e02d7283ee206b6888`.
5858+5959+## First-time setup
6060+6161+```bash
6262+cp .env.sample .env
6363+# Set SPINDLE_SERVER_HOSTNAME and SPINDLE_SERVER_OWNER in .env
6464+6565+docker compose up -d openbao
6666+# Wait for "seal configuration missing, not initialized" in logs, then:
6767+6868+./init-openbao.sh # ONE-TIME ONLY — save the unseal key and root token it prints
6969+7070+docker compose up -d
7171+```
7272+7373+## After every restart
7474+7575+OpenBao seals itself on restart. Unseal before the proxy or Spindle can start:
7676+7777+```bash
7878+docker compose exec openbao bao operator unseal -address=http://localhost:8200 <unseal_key>
7979+```
8080+8181+## Verify the stack
8282+8383+```bash
8484+curl http://localhost:8200/v1/sys/health # OpenBao server (127.0.0.1 only)
8585+curl http://localhost:6555/ # Spindle
8686+```
+6-5
Dockerfile
···11# ── Build stage ───────────────────────────────────────────────────────────────
22-FROM golang:1.23.12-alpine3.22@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS builder
22+FROM golang:1.25.8-alpine3.23@sha256:8e02eb337d9e0ea459e041f1ee5eece41cbb61f1d83e7d883a3e2fb4862063fa AS builder
3344-RUN apk add --no-cache git
44+RUN apk add --no-cache git gcc musl-dev
5566WORKDIR /src
7788# Pin to v1.13.0-alpha; update the SHA here when upgrading
99+# Note: 3572988b is the annotated tag object SHA; c3f60dc1 is the actual commit SHA
910RUN git clone --depth 1 --branch v1.13.0-alpha https://tangled.org/tangled.org/core . \
1010- && [ "$(git rev-parse HEAD)" = "3572988b89fa093269ae78e02d7283ee206b6888" ] \
1111+ && [ "$(git rev-parse HEAD)" = "c3f60dc17fd6cc709159974f2815a6d14044a106" ] \
1112 || { echo "ERROR: commit SHA mismatch — possible supply chain tampering"; exit 1; }
12131314RUN go mod download
1415RUN go mod verify
1515-RUN go build -o /spindle ./cmd/spindle/main.go
1616+RUN CGO_ENABLED=1 go build -o /spindle ./cmd/spindle/main.go
16171718# ── Runtime stage ─────────────────────────────────────────────────────────────
1819FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
19202020-RUN apk add --no-cache ca-certificates docker-cli
2121+RUN apk add --no-cache ca-certificates docker-cli sqlite-libs
21222223COPY --from=builder /spindle /usr/local/bin/spindle
2324
+37-4
README.md
···1010├── Dockerfile
1111├── init-openbao.sh # one-time vault bootstrap
1212└── config/openbao/
1313- ├── server.hcl # OpenBao server config
1414- ├── proxy.hcl # AppRole auto-auth proxy config
1313+ ├── server/
1414+ │ └── server.hcl # OpenBao server config
1515+ ├── proxy/
1616+ │ └── proxy.hcl # AppRole auto-auth proxy config
1517 └── spindle-policy.hcl # KV access policy for spindle
1618```
1719···5557docker compose up -d openbao
5658```
57595858-Wait ~5 seconds for it to be healthy.
5959-6060**3. Initialize the vault** (once only)
61616262```bash
6363chmod +x init-openbao.sh
6464./init-openbao.sh
6565```
6666+6767+The script fixes permissions, initialises the vault, and configures AppRole automatically. When prompted, choose a Secret ID TTL or press enter for no expiry.
66686769Save the **unseal key** and **root token** printed to stdout — they are not stored anywhere.
6870···116118- Port 8200 is exposed for local CLI access only (`127.0.0.1`). Remove that port mapping entirely if you don't need it.
117119- TLS is disabled on both listeners. Put nginx or Caddy in front for production traffic.
118120- Spindle mounts the Docker socket, so pipeline containers run on the **host** daemon.
121121+122122+## AppRole credential handling
123123+124124+By default, the `openbao-approle` volume is mounted read-only (`:ro`) in the proxy container. This means the proxy can read the `role-id` and `secret-id` written by `init-openbao.sh` on every startup, but cannot delete them. The credentials persist on the volume indefinitely, so the proxy can re-authenticate automatically after any restart with no user intervention beyond unsealing OpenBao.
125125+126126+The tradeoff: the `secret-id` is never rotated or deleted. For a self-hosted server where Docker volumes are only accessible to the server owner, this is a reasonable default.
127127+128128+**If you want the secret-id deleted after first use** (higher security, more operational overhead):
129129+130130+1. In `docker-compose.yml`, remove `:ro` from the approle volume mount:
131131+ ```yaml
132132+ - openbao-approle:/openbao/approle
133133+ ```
134134+135135+2. After any restart or proxy container recreation, generate and write a new secret-id before starting the proxy:
136136+ ```bash
137137+ # Unseal first, then:
138138+ SECRET_ID=$(docker compose exec -T openbao bao write \
139139+ -address=http://localhost:8200 -f -field=secret_id \
140140+ auth/approle/role/spindle/secret-id)
141141+142142+ docker run --rm \
143143+ -v "openbao-approle:/openbao/approle" \
144144+ --entrypoint="" \
145145+ alpine:3.23.3 \
146146+ sh -c "printf '%s' '$SECRET_ID' > /openbao/approle/secret-id \
147147+ && chown 100:1000 /openbao/approle/secret-id \
148148+ && chmod 640 /openbao/approle/secret-id"
149149+150150+ docker compose restart openbao-proxy
151151+ ```