Heavily inspired by frontpage dev environment. Tangled's setup is slightly more involved because services inside the network need to reach the PDS over its public hostname with valid TLS — federation paths (DID resolution, OAuth, etc.) round-trip through the same URLs an external client would use.
For example, resolving alice.pds.tngl.boltless.dev yields an #atproto_pds service pointing at https://pds.tngl.boltless.dev. Knot and spindle running inside docker must hit that exact URL and trust its cert.
To make that work:
- Caddy's dev root CA is mounted into every container that talks to another service over HTTPS.
- The Docker network uses an unrouted "public" subnet so the SSRF dialer doesn't reject container IPs as private.
What's inside:#
- did-method-plc (https://plc.tngl.boltless.dev)
- atproto_pds (https://pds.tngl.boltless.dev)
- jetstream (https://jetstream.tngl.boltless.dev)
- knot (https://knot.tngl.boltless.dev)
- spindle (https://spindle.tngl.boltless.dev)
- knotmirror (https://knotmirror.tngl.boltless.dev)
- appview (https://tngl.boltless.dev) (live reloading)
- ncps nix binary cache (internal,
http://ncps:8501) - pdsls (https://pdsls.tngl.boltless.dev)
- caddy reverse proxy
Setup#
- Generate the dev CA from the repo root:
mkdir -p localinfra/certs && openssl req -x509 -newkey rsa:2048 \ -keyout localinfra/certs/root.key \ -out localinfra/certs/root.crt \ -days 3650 -nodes \ -subj "/CN=Tangled Dev CA" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -addext "nameConstraints=critical,permitted;DNS:tngl.boltless.dev" - Trust generated
localinfra/certs/root.crtin your system's trust store.
- For example in MacOS, run
sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain \ ./localinfra/certs/root.crt - Depending on your browser you may have to import the certificate into your browser profiles too as some have their own certs do not use your system ones
- run
./localinfra/scripts/appview-static-files.sh - Prepare the spindle microVM images:
This writes the image directory under./localinfra/scripts/prepare-spindle-images.shout/localinfra-spindle-images. docker compose up- AppView will be running on
127.0.0.1:3000with two test users:alice.pds.tngl.boltless.devandbob.pds.tngl.boltless.dev. Both with passwordpassword.TANGLED_APPVIEW_HOSTmust be a loopback IP with the mapped port (127.0.0.1:3000), notlocalhost: atproto's dev OAuth client requires a loopback IP for the redirect URI. If you remap the published appview port, updateTANGLED_APPVIEW_HOSTindocker-compose.ymlto match.
Mill mode#
The default stack runs one standalone spindle. docker-compose.mill.yml is an overlay that splits that into the distributed arch: the primary spindle becomes the mill host (role=mill, it only places jobs) and a three-executor fleet (role=executor) runs the engines.
docker compose -f docker-compose.yml -f docker-compose.mill.yml --profile linux up
The executors differ on the two axes placement cares about, so you can watch candidates get filtered and ranked:
| executor | labels | images | runs |
|---|---|---|---|
| executor-a | linux, fast |
full | image/alpine, image/nixos |
| executor-b | linux, slow |
full | image/alpine, image/nixos |
| executor-c | linux, gpu |
alpine only | image/alpine |
So image: nixos has two candidates, image: alpine has three, and runs_on: [gpu] pins to executor-c. The alpine-only image set is staged by prepare-spindle-images.sh (step 4) alongside the full one, no extra step.
Each executor needs its own identity (one live session per token), so the mill-tokens service registers a token per executor in the mill db and drops it into the shared volume for the executor to read.