configuration for self hosting a spindle in docker
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: get full stack working on first-time Ubuntu deployment

+135 -76
+55 -42
CLAUDE.md
··· 1 1 # CLAUDE.md 2 2 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 3 + This file provides guidance to Claude Code when working with code in this repository. 4 4 5 5 ## What this repo is 6 6 7 - 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`. 7 + 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`. 8 8 9 - ## First-time setup 9 + ## Current state 10 10 11 - ```bash 12 - cp .env.sample .env 13 - # Set SPINDLE_SERVER_HOSTNAME and SPINDLE_SERVER_OWNER in .env 11 + 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. 14 12 15 - docker compose up -d openbao 16 - # Wait ~5s for health check, then: 13 + Known working: 14 + - OpenBao starts and reads config correctly 15 + - `init-openbao.sh` fixes data volume permissions, restarts OpenBao, and runs the full vault bootstrap 17 16 18 - chmod +x scripts/init-openbao.sh 19 - ./scripts/init-openbao.sh # ONE-TIME ONLY — save the unseal key and root token it prints 20 - 21 - docker compose up -d 22 - ``` 23 - 24 - > `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. 25 - 26 - ## After every restart 27 - 28 - OpenBao seals itself on restart. Unseal before the proxy or Spindle can start: 29 - 30 - ```bash 31 - docker compose exec openbao bao operator unseal http://localhost:8200 <unseal_key> 32 - ``` 33 - 34 - ## Verify the stack 35 - 36 - ```bash 37 - curl http://localhost:8201/v1/sys/health # OpenBao proxy 38 - curl http://localhost:6555/ # Spindle 39 - ``` 17 + Known issues / things being tested: 18 + - Full end-to-end stack (openbao → openbao-proxy → spindle) not yet verified 19 + - Spindle healthcheck is intentionally omitted pending manual testing of its HTTP endpoints 40 20 41 21 ## Architecture 42 22 ··· 51 31 Host Docker daemon (pipeline containers run here) 52 32 ``` 53 33 54 - - **openbao** — vault backend with file storage; sealed on every start 55 - - **openbao-proxy** — AppRole sidecar that auto-authenticates and caches a token at `/tmp/openbao-token`; Spindle reads secrets through this proxy 56 - - **spindle** — starts only after the proxy is healthy; mounts the Docker socket to spawn CI pipeline containers on the host daemon 34 + - **openbao** — vault backend, file storage, sealed on every start 35 + - **openbao-proxy** — AppRole sidecar, auto-authenticates, token cached at `/tmp/openbao-token` 36 + - **spindle** — CI runner, starts only after proxy is healthy, mounts Docker socket 57 37 58 38 ## Key config files 59 39 60 40 | File | Purpose | 61 41 |------|---------| 62 - | `docker-compose.yml` | Service definitions, volumes, healthchecks, dependency order | 63 - | `Dockerfile.spindle` | Clones `tangled.org/core`, builds with Go 1.23, produces minimal Alpine image | 64 - | `config/openbao/server.hcl` | OpenBao server (file storage, TCP listener on 8200, TLS off) | 65 - | `config/openbao/proxy.hcl` | Proxy AppRole auto-auth, token sink at `/tmp/openbao-token`, listener on 8201 | 42 + | `docker-compose.yml` | Service definitions, volumes, port bindings, dependency order | 43 + | `Dockerfile` | Clones `tangled.org/core` at `v1.13.0-alpha`, builds with Go, produces Alpine image | 44 + | `config/openbao/server/server.hcl` | OpenBao server (file storage, TCP listener on 8200, TLS off) | 45 + | `config/openbao/proxy/proxy.hcl` | Proxy AppRole auto-auth, token sink at `/tmp/openbao-token`, listener on 8201 | 66 46 | `config/openbao/spindle-policy.hcl` | Grants Spindle KV v2 CRUD on `spindle/data/*` and `spindle/metadata/*` | 67 - | `scripts/init-openbao.sh` | One-time bootstrap: init vault, enable KV v2, create AppRole, write credentials to shared volume | 47 + | `init-openbao.sh` | One-time bootstrap: fixes volume perms, inits vault, enables KV v2, creates AppRole | 48 + | `.env.sample` | All configurable env vars with defaults — copy to `.env` before starting | 68 49 69 - ## Notes 50 + ## Important implementation details 70 51 71 - - TLS is disabled on both OpenBao listeners. Put nginx or Caddy in front for production. 72 - - The `openbao-approle` volume is mounted **read-only** by the proxy and **read-write** by the init script (via a temporary container). 73 - - Spindle's Docker socket mount means pipeline containers run on the **host** daemon — ensure the container user has socket access. 52 + - 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. 53 + - 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. 54 + - All `docker compose exec` calls in the init script use `-T` with `</dev/null` to prevent stdin hanging. 55 + - OpenBao port 8200 is bound to `127.0.0.1` only (not exposed to the network). 56 + - Both OpenBao and its proxy have `IPC_LOCK` capability to prevent secrets swapping to disk. 57 + - All images are pinned to SHA256 digests. Spindle source is pinned to commit `3572988b89fa093269ae78e02d7283ee206b6888`. 58 + 59 + ## First-time setup 60 + 61 + ```bash 62 + cp .env.sample .env 63 + # Set SPINDLE_SERVER_HOSTNAME and SPINDLE_SERVER_OWNER in .env 64 + 65 + docker compose up -d openbao 66 + # Wait for "seal configuration missing, not initialized" in logs, then: 67 + 68 + ./init-openbao.sh # ONE-TIME ONLY — save the unseal key and root token it prints 69 + 70 + docker compose up -d 71 + ``` 72 + 73 + ## After every restart 74 + 75 + OpenBao seals itself on restart. Unseal before the proxy or Spindle can start: 76 + 77 + ```bash 78 + docker compose exec openbao bao operator unseal -address=http://localhost:8200 <unseal_key> 79 + ``` 80 + 81 + ## Verify the stack 82 + 83 + ```bash 84 + curl http://localhost:8200/v1/sys/health # OpenBao server (127.0.0.1 only) 85 + curl http://localhost:6555/ # Spindle 86 + ```
+6 -5
Dockerfile
··· 1 1 # ── Build stage ─────────────────────────────────────────────────────────────── 2 - FROM golang:1.23.12-alpine3.22@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS builder 2 + FROM golang:1.25.8-alpine3.23@sha256:8e02eb337d9e0ea459e041f1ee5eece41cbb61f1d83e7d883a3e2fb4862063fa AS builder 3 3 4 - RUN apk add --no-cache git 4 + RUN apk add --no-cache git gcc musl-dev 5 5 6 6 WORKDIR /src 7 7 8 8 # Pin to v1.13.0-alpha; update the SHA here when upgrading 9 + # Note: 3572988b is the annotated tag object SHA; c3f60dc1 is the actual commit SHA 9 10 RUN git clone --depth 1 --branch v1.13.0-alpha https://tangled.org/tangled.org/core . \ 10 - && [ "$(git rev-parse HEAD)" = "3572988b89fa093269ae78e02d7283ee206b6888" ] \ 11 + && [ "$(git rev-parse HEAD)" = "c3f60dc17fd6cc709159974f2815a6d14044a106" ] \ 11 12 || { echo "ERROR: commit SHA mismatch — possible supply chain tampering"; exit 1; } 12 13 13 14 RUN go mod download 14 15 RUN go mod verify 15 - RUN go build -o /spindle ./cmd/spindle/main.go 16 + RUN CGO_ENABLED=1 go build -o /spindle ./cmd/spindle/main.go 16 17 17 18 # ── Runtime stage ───────────────────────────────────────────────────────────── 18 19 FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 19 20 20 - RUN apk add --no-cache ca-certificates docker-cli 21 + RUN apk add --no-cache ca-certificates docker-cli sqlite-libs 21 22 22 23 COPY --from=builder /spindle /usr/local/bin/spindle 23 24
+37 -4
README.md
··· 10 10 ├── Dockerfile 11 11 ├── init-openbao.sh # one-time vault bootstrap 12 12 └── config/openbao/ 13 - ├── server.hcl # OpenBao server config 14 - ├── proxy.hcl # AppRole auto-auth proxy config 13 + ├── server/ 14 + │ └── server.hcl # OpenBao server config 15 + ├── proxy/ 16 + │ └── proxy.hcl # AppRole auto-auth proxy config 15 17 └── spindle-policy.hcl # KV access policy for spindle 16 18 ``` 17 19 ··· 55 57 docker compose up -d openbao 56 58 ``` 57 59 58 - Wait ~5 seconds for it to be healthy. 59 - 60 60 **3. Initialize the vault** (once only) 61 61 62 62 ```bash 63 63 chmod +x init-openbao.sh 64 64 ./init-openbao.sh 65 65 ``` 66 + 67 + The script fixes permissions, initialises the vault, and configures AppRole automatically. When prompted, choose a Secret ID TTL or press enter for no expiry. 66 68 67 69 Save the **unseal key** and **root token** printed to stdout — they are not stored anywhere. 68 70 ··· 116 118 - Port 8200 is exposed for local CLI access only (`127.0.0.1`). Remove that port mapping entirely if you don't need it. 117 119 - TLS is disabled on both listeners. Put nginx or Caddy in front for production traffic. 118 120 - Spindle mounts the Docker socket, so pipeline containers run on the **host** daemon. 121 + 122 + ## AppRole credential handling 123 + 124 + 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. 125 + 126 + 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. 127 + 128 + **If you want the secret-id deleted after first use** (higher security, more operational overhead): 129 + 130 + 1. In `docker-compose.yml`, remove `:ro` from the approle volume mount: 131 + ```yaml 132 + - openbao-approle:/openbao/approle 133 + ``` 134 + 135 + 2. After any restart or proxy container recreation, generate and write a new secret-id before starting the proxy: 136 + ```bash 137 + # Unseal first, then: 138 + SECRET_ID=$(docker compose exec -T openbao bao write \ 139 + -address=http://localhost:8200 -f -field=secret_id \ 140 + auth/approle/role/spindle/secret-id) 141 + 142 + docker run --rm \ 143 + -v "openbao-approle:/openbao/approle" \ 144 + --entrypoint="" \ 145 + alpine:3.23.3 \ 146 + sh -c "printf '%s' '$SECRET_ID' > /openbao/approle/secret-id \ 147 + && chown 100:1000 /openbao/approle/secret-id \ 148 + && chmod 640 /openbao/approle/secret-id" 149 + 150 + docker compose restart openbao-proxy 151 + ```
config/openbao/proxy.hcl config/openbao/proxy/proxy.hcl
config/openbao/server.hcl config/openbao/server/server.hcl
+2 -2
docker-compose.yml
··· 11 11 environment: 12 12 BAO_ADDR: "http://0.0.0.0:8200" 13 13 volumes: 14 - - ./config/openbao/server.hcl:/openbao/config/server.hcl:ro 14 + - ./config/openbao/server:/openbao/config 15 15 - openbao-data:/openbao/data 16 16 ports: 17 17 - "127.0.0.1:${OPENBAO_PORT:-8200}:8200" # localhost-only; remove entirely if you don't need local CLI access ··· 36 36 openbao: 37 37 condition: service_healthy 38 38 volumes: 39 - - ./config/openbao/proxy.hcl:/openbao/config/proxy.hcl:ro 39 + - ./config/openbao/proxy:/openbao/config 40 40 - openbao-approle:/openbao/approle:ro # role-id + secret-id written by init-openbao.sh 41 41 networks: 42 42 - spindle-net
+35 -23
init-openbao.sh
··· 1 1 #!/usr/bin/env bash 2 2 # Run this ONCE after first `docker compose up -d openbao`. 3 - # It initialises the vault, unseals it, and wires up AppRole for spindle. 3 + # Fixes data permissions, initialises the vault, and wires up AppRole for spindle. 4 4 set -euo pipefail 5 5 6 - BAO="docker compose exec openbao bao" 6 + BAO_EXEC() { docker compose exec -T openbao bao "$@"; } 7 7 BAO_ADDR="http://localhost:8200" 8 8 9 + echo "==> Verifying openbao container is reachable via compose..." 10 + docker compose exec -T openbao echo "container OK" || { echo "ERROR: openbao container not running"; exit 1; } 11 + 12 + echo "==> Fixing data volume ownership..." 13 + docker compose exec --user root openbao chown -R openbao:openbao /openbao/data 14 + 15 + echo "==> Restarting OpenBao with correct permissions..." 16 + docker compose restart openbao 17 + 18 + echo "==> Waiting for OpenBao to be ready..." 19 + until docker compose exec -T openbao wget -qO /dev/null http://127.0.0.1:8200/v1/sys/seal-status 2>/dev/null; do 20 + printf "." 21 + sleep 2 22 + done 23 + echo "" 24 + 9 25 echo "==> Initialising OpenBao (1 key share, threshold 1)..." 10 - INIT_OUTPUT=$($BAO operator init \ 26 + INIT_OUTPUT=$(BAO_EXEC operator init \ 11 27 -address="$BAO_ADDR" \ 12 28 -key-shares=1 \ 13 29 -key-threshold=1 \ 14 30 -format=json) 15 31 16 - UNSEAL_KEY=$(echo "$INIT_OUTPUT" | grep -o '"unseal_keys_b64":\["[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') 17 - ROOT_TOKEN=$(echo "$INIT_OUTPUT" | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4) 32 + UNSEAL_KEY=$(echo "$INIT_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['unseal_keys_b64'][0])") 33 + ROOT_TOKEN=$(echo "$INIT_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['root_token'])") 18 34 19 35 echo "" 20 36 echo "┌─────────────────────────────────────────────────────────┐" ··· 26 42 echo "" 27 43 28 44 echo "==> Unsealing..." 29 - $BAO operator unseal -address="$BAO_ADDR" "$UNSEAL_KEY" 45 + BAO_EXEC operator unseal -address="$BAO_ADDR" "$UNSEAL_KEY" 30 46 31 47 echo "==> Logging in with root token..." 32 - $BAO login -address="$BAO_ADDR" "$ROOT_TOKEN" 48 + BAO_EXEC login -address="$BAO_ADDR" "$ROOT_TOKEN" 33 49 34 50 echo "==> Enabling KV v2 at path 'spindle'..." 35 - $BAO secrets enable -address="$BAO_ADDR" -path=spindle -version=2 kv 51 + BAO_EXEC secrets enable -address="$BAO_ADDR" -path=spindle -version=2 kv 36 52 37 53 echo "==> Writing spindle policy..." 38 54 docker compose cp config/openbao/spindle-policy.hcl openbao:/tmp/spindle-policy.hcl 39 - $BAO policy write -address="$BAO_ADDR" spindle-policy /tmp/spindle-policy.hcl 55 + BAO_EXEC policy write -address="$BAO_ADDR" spindle-policy /tmp/spindle-policy.hcl 40 56 41 57 echo "==> Enabling AppRole auth..." 42 - $BAO auth enable -address="$BAO_ADDR" approle 58 + BAO_EXEC auth enable -address="$BAO_ADDR" approle 43 59 44 60 echo "" 45 61 echo " AppRole Secret ID TTL" ··· 50 66 SECRET_ID_TTL=${SECRET_ID_TTL_INPUT:-0} 51 67 echo "" 52 68 53 - $BAO write -address="$BAO_ADDR" auth/approle/role/spindle \ 69 + BAO_EXEC write -address="$BAO_ADDR" auth/approle/role/spindle \ 54 70 token_policies="spindle-policy" \ 55 71 token_ttl=1h \ 56 72 token_max_ttl=4h \ ··· 59 75 secret_id_num_uses=0 60 76 61 77 echo "==> Fetching AppRole credentials..." 62 - ROLE_ID=$($BAO read -address="$BAO_ADDR" -field=role_id auth/approle/role/spindle/role-id) 63 - SECRET_ID=$($BAO write -address="$BAO_ADDR" -f -field=secret_id auth/approle/role/spindle/secret-id) 78 + ROLE_ID=$(BAO_EXEC read -address="$BAO_ADDR" -field=role_id auth/approle/role/spindle/role-id) 79 + SECRET_ID=$(BAO_EXEC write -address="$BAO_ADDR" -f -field=secret_id auth/approle/role/spindle/secret-id) 64 80 65 81 echo "==> Writing credentials into the openbao-approle volume..." 66 - # Resolve the volume name regardless of compose project name 67 - APPROLE_VOL=$(docker volume ls --format '{{.Name}}' | grep '_openbao-approle$' | head -1) 68 - if [ -z "$APPROLE_VOL" ]; then 69 - echo "ERROR: openbao-approle volume not found. Did you run 'docker compose up -d openbao' first?" 70 - exit 1 71 - fi 72 82 docker run --rm \ 73 - -v "${APPROLE_VOL}:/openbao/approle" \ 74 - alpine sh -c " 75 - printf '%s' '$ROLE_ID' > /openbao/approle/role-id && chmod 600 /openbao/approle/role-id 76 - printf '%s' '$SECRET_ID' > /openbao/approle/secret-id && chmod 600 /openbao/approle/secret-id 83 + -v "openbao-approle:/openbao/approle" \ 84 + --entrypoint="" \ 85 + "alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659" \ 86 + sh -c " 87 + printf '%s' '$ROLE_ID' > /openbao/approle/role-id && chown 100:1000 /openbao/approle/role-id && chmod 640 /openbao/approle/role-id 88 + printf '%s' '$SECRET_ID' > /openbao/approle/secret-id && chown 100:1000 /openbao/approle/secret-id && chmod 640 /openbao/approle/secret-id 77 89 " 78 90 79 91 echo ""