chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Deploy: a Dockerfile, a compose file, and DNS in Terraform
One image holds knotless-serve, git and the built pages; deploy/ runs it as two services behind any reverse proxy on a shared docker network, built on the host over SSH by `just deploy`, with the domain's records at Cloudflare in Terraform. A host's own settings stay in gitignored deploy/local.env and terraform.tfvars. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 5026daf commit 58a9176
Showing 14 changed files with 349 additions and 0 deletions
.dockerignorenew+9−0
| @@ -0,0 +1,9 @@ | ||
.git | ||
target | ||
harness | ||
deploy | ||
web/node_modules | ||
web/build | ||
web/.svelte-kit | ||
*.profraw | ||
lcov.info | ||
Dockerfilenew+36−0
| @@ -0,0 +1,36 @@ | ||
# syntax=docker/dockerfile:1 | ||
| ||
# knotless-serve and the web pages it hands on to, in one image: the | ||
# compose file in deploy/ runs it twice, once as each. | ||
# | ||
# docker build -t knotless:current . | ||
| ||
FROM rust:1-bookworm AS serve | ||
WORKDIR /src | ||
COPY . . | ||
# The cache mounts keep the registry and the build between builds on the | ||
# same machine, so a change rebuilds the crates it touches and not the rest. | ||
RUN --mount=type=cache,target=/usr/local/cargo/registry \ | ||
--mount=type=cache,target=/src/target \ | ||
cargo build --release --locked -p knotless-serve --bin knotless-serve \ | ||
&& cp target/release/knotless-serve /knotless-serve | ||
| ||
FROM node:24-bookworm-slim AS pages | ||
WORKDIR /web | ||
COPY web/package.json web/package-lock.json ./ | ||
RUN npm ci --no-audit --no-fund | ||
COPY web/ ./ | ||
RUN npm run build | ||
| ||
# adapter-node bundles what the pages import, so the build needs no | ||
# node_modules beside it. | ||
FROM node:24-bookworm-slim | ||
RUN apt-get update \ | ||
&& apt-get install -y --no-install-recommends git ca-certificates tini \ | ||
&& rm -rf /var/lib/apt/lists/* | ||
COPY --from=serve /knotless-serve /usr/local/bin/knotless-serve | ||
COPY --from=pages /web/build /app/build | ||
COPY --from=pages /web/package.json /app/package.json | ||
USER node | ||
ENTRYPOINT ["tini", "--"] | ||
CMD ["knotless-serve", "--listen", "0.0.0.0:2590"] | ||
Justfile+4−0
| @@ -73,6 +73,10 @@ bench-cards: | ||
serve *args: | ||
{{cargo}} run --release -p knotless-serve -- {{args}} | ||
| ||
# Builds the image on a host and restarts it there (deploy/README.md). | ||
deploy *args: | ||
deploy/deploy.sh {{args}} | ||
| ||
# The web pages, built into web/build: a SvelteKit app that draws what | ||
# knotless-serve answers under /_data. Its types come from `cargo test`. | ||
web: | ||
deploy/.gitignorenew+6−0
| @@ -0,0 +1,6 @@ | ||
.terraform/ | ||
*.tfstate | ||
*.tfstate.* | ||
*.tfvars | ||
!*.tfvars.example | ||
local.env | ||
deploy/.terraform.lock.hclnew+24−0
| @@ -0,0 +1,24 @@ | ||
# This file is maintained automatically by "terraform init". | ||
# Manual edits may be lost in future updates. | ||
| ||
provider "registry.terraform.io/cloudflare/cloudflare" { | ||
version = "4.52.9" | ||
constraints = "~> 4.0" | ||
hashes = [ | ||
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=", | ||
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49", | ||
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b", | ||
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f", | ||
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7", | ||
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3", | ||
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e", | ||
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e", | ||
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb", | ||
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d", | ||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", | ||
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631", | ||
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa", | ||
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df", | ||
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b", | ||
] | ||
} | ||
deploy/README.mdnew+42−0
| @@ -0,0 +1,42 @@ | ||
# Deploying knotless-serve | ||
| ||
One Docker host, a reverse proxy in front of it for TLS, and a domain. | ||
| ||
| | | | ||
| --- | --- | | ||
| `../Dockerfile` | one image, `knotless:current`: knotless-serve, git, and the built pages | | ||
| `docker-compose.yml` | `/etc/knotless/docker-compose.yml`: `serve` (knotless-serve, on :2590) and `pages` (the SvelteKit app, on :2591) | | ||
| `deploy.sh` | builds the image on the host over SSH, writes the compose file and its `.env`, and restarts what changed | | ||
| `*.tf` | the domain's DNS records at Cloudflare, pointing at the host | | ||
| ||
```sh | ||
just deploy root@host git.example.com # build on the host and restart; or put both in deploy/local.env | ||
cd deploy && terraform apply # DNS, once (terraform.tfvars from the .example) | ||
``` | ||
| ||
The proxy joins the `knotless` docker network (`KNOTLESS_PROXY_NETWORK`) | ||
and sends the domain to `knotless-serve:2590`, passing `X-Forwarded-Proto`. | ||
With Caddy: | ||
| ||
```caddy | ||
git.example.com { | ||
reverse_proxy knotless-serve:2590 | ||
} | ||
``` | ||
| ||
knotless-serve is the appview (`dev.knotless.listRepos`, `getForge`, | ||
`getFeed`, indexed from Jetstream), the gateway, as `did:web:<domain>`, and | ||
the moderation service. Its state is in `KNOTLESS_DATA` | ||
(`/var/lib/knotless`): | ||
| ||
- `index.db`, the index: a cache, rebuildable with `--backfill`. | ||
- `cache/`, mirrors and identities: a cache. | ||
- `config/`, the gateway's OAuth client key and the sessions owners | ||
delegated to it. Losing it breaks every delegation, which owners then | ||
have to make again: back it up. | ||
| ||
`/etc/knotless/secrets.env` holds `KNOTLESS_ADMIN_PASSWORD`, made on the | ||
first deploy, for `knotless admin`. | ||
| ||
Keep the records DNS-only: PDSes fetch the gateway's DID document and OAuth | ||
client metadata from the origin, and the proxy holds the certificate. | ||
deploy/deploy.shnew+61−0
| @@ -0,0 +1,61 @@ | ||
#!/usr/bin/env bash | ||
# Builds knotless on a host from this checkout and (re)starts it there. | ||
# | ||
# deploy/deploy.sh <ssh destination> <domain> | ||
# KNOTLESS_HOST=root@host KNOTLESS_DOMAIN=git.example.com deploy/deploy.sh | ||
# | ||
# or with those in deploy/local.env (see local.env.example). | ||
# | ||
# The image is built by the host's own docker, over SSH, so nothing is | ||
# cross-compiled here and no registry is involved. The build caches stay on | ||
# the host, so a second deploy rebuilds only what changed. | ||
# | ||
# Optional: KNOTLESS_PROXY_NETWORK (knotless), the docker network the | ||
# reverse proxy reaches knotless-serve on; KNOTLESS_DATA (/var/lib/knotless), | ||
# its state on the host; KNOTLESS_JETSTREAM, the Jetstream it follows. | ||
| ||
set -euo pipefail | ||
cd "$(dirname "$0")/.." | ||
# Settings for one's own host, kept out of the repository. | ||
[ -f deploy/local.env ] && { set -a; . deploy/local.env; set +a; } | ||
| ||
DEST="${1:-${KNOTLESS_HOST:-}}" | ||
DOMAIN="${2:-${KNOTLESS_DOMAIN:-}}" | ||
[ -n "$DEST" ] && [ -n "$DOMAIN" ] || { sed -n '4,5p' "$0" >&2; exit 2; } | ||
NETWORK="${KNOTLESS_PROXY_NETWORK:-knotless}" | ||
DATA="${KNOTLESS_DATA:-/var/lib/knotless}" | ||
SSH="ssh -o StrictHostKeyChecking=accept-new $DEST" | ||
| ||
echo "→ building knotless:current on $DEST" | ||
DOCKER_HOST="ssh://$DEST" docker build -t knotless:current . | ||
| ||
echo "→ preparing $DEST" | ||
$SSH " | ||
set -e | ||
mkdir -p /etc/knotless '$DATA' | ||
chown 1000:1000 '$DATA' | ||
# Made once and kept: the operator password for the moderation service. | ||
if [ ! -f /etc/knotless/secrets.env ]; then | ||
umask 077 | ||
echo \"KNOTLESS_ADMIN_PASSWORD=\$(openssl rand -hex 24)\" > /etc/knotless/secrets.env | ||
fi | ||
docker network inspect '$NETWORK' >/dev/null 2>&1 || docker network create '$NETWORK' | ||
" | ||
{ | ||
echo "KNOTLESS_DOMAIN=$DOMAIN" | ||
echo "KNOTLESS_PROXY_NETWORK=$NETWORK" | ||
echo "KNOTLESS_DATA=$DATA" | ||
[ -z "${KNOTLESS_JETSTREAM:-}" ] || echo "KNOTLESS_JETSTREAM=$KNOTLESS_JETSTREAM" | ||
} | $SSH 'cat > /etc/knotless/.env' | ||
scp -q -o StrictHostKeyChecking=accept-new deploy/docker-compose.yml "$DEST:/etc/knotless/docker-compose.yml" | ||
| ||
echo "→ starting" | ||
$SSH ' | ||
set -e | ||
cd /etc/knotless | ||
docker compose config --quiet | ||
docker compose up -d --remove-orphans | ||
docker compose ps | ||
' | ||
| ||
echo "→ done: https://$DOMAIN, once the proxy on $NETWORK serves it" | ||
deploy/docker-compose.ymlnew+74−0
| @@ -0,0 +1,74 @@ | ||
# knotless-serve and its pages, as /etc/knotless/docker-compose.yml. | ||
# deploy.sh copies it there, beside the .env it reads; edit it here. | ||
# | ||
# TLS is left to a reverse proxy in front: it joins the network named by | ||
# KNOTLESS_PROXY_NETWORK and proxies the domain to knotless-serve:2590. | ||
| ||
name: knotless | ||
| ||
services: | ||
serve: | ||
image: knotless:current | ||
restart: always | ||
command: | ||
- knotless-serve | ||
- --listen | ||
- 0.0.0.0:2590 | ||
- --public-url | ||
- https://${KNOTLESS_DOMAIN:?set KNOTLESS_DOMAIN in .env} | ||
- --index | ||
- /data/index.db | ||
- --jetstream | ||
- ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} | ||
- --web | ||
- http://pages:2591 | ||
environment: | ||
# The gateway's OAuth client key and the sessions owners delegate | ||
# are in the config directory; mirrors and identities in the cache. | ||
KNOTLESS_CONFIG_DIR: /data/config | ||
KNOTLESS_CACHE_DIR: /data/cache | ||
env_file: | ||
# KNOTLESS_ADMIN_PASSWORD, made on the host by deploy.sh. | ||
- /etc/knotless/secrets.env | ||
volumes: | ||
# Owned by uid 1000, the node user the image runs as. | ||
- ${KNOTLESS_DATA:-/var/lib/knotless}:/data | ||
networks: | ||
default: {} | ||
# The name the reverse proxy reaches it by. | ||
proxy: | ||
aliases: [knotless-serve] | ||
depends_on: | ||
- pages | ||
deploy: | ||
resources: | ||
limits: | ||
cpus: "2" | ||
memory: 2g | ||
healthcheck: | ||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:2590/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] | ||
interval: 30s | ||
timeout: 5s | ||
retries: 3 | ||
| ||
pages: | ||
image: knotless:current | ||
restart: always | ||
command: ["node", "/app/build"] | ||
environment: | ||
HOST: 0.0.0.0 | ||
PORT: "2591" | ||
KNOTLESS_SERVE: http://serve:2590 | ||
# knotless-serve hands on the host and scheme it was asked for. | ||
HOST_HEADER: x-forwarded-host | ||
PROTOCOL_HEADER: x-forwarded-proto | ||
deploy: | ||
resources: | ||
limits: | ||
cpus: "1" | ||
memory: 512m | ||
| ||
networks: | ||
proxy: | ||
name: ${KNOTLESS_PROXY_NETWORK:-knotless} | ||
external: true | ||
deploy/local.env.examplenew+6−0
| @@ -0,0 +1,6 @@ | ||
# deploy.sh reads deploy/local.env, if there is one, for what it is not | ||
# given on the command line. | ||
KNOTLESS_HOST=root@203.0.113.10 | ||
KNOTLESS_DOMAIN=example.com | ||
# KNOTLESS_PROXY_NETWORK=knotless | ||
# KNOTLESS_DATA=/var/lib/knotless | ||
deploy/main.tfnew+41−0
| @@ -0,0 +1,41 @@ | ||
# The names knotless-serve answers at: the apex, and www, which the proxy | ||
# redirects to it. | ||
| ||
data "cloudflare_zone" "main" { | ||
name = var.zone | ||
} | ||
| ||
# DNS-only, not proxied: the gateway is the zone's did:web, whose document | ||
# and OAuth client metadata PDSes fetch from the origin, and git clones want | ||
# bodies Cloudflare's proxy would cap. The reverse proxy on the host holds | ||
# the certificate. | ||
resource "cloudflare_record" "apex_a" { | ||
zone_id = data.cloudflare_zone.main.id | ||
name = "@" | ||
type = "A" | ||
content = var.host_ipv4 | ||
ttl = 1 | ||
proxied = false | ||
comment = "knotless-serve" | ||
} | ||
| ||
resource "cloudflare_record" "apex_aaaa" { | ||
count = var.host_ipv6 == "" ? 0 : 1 | ||
zone_id = data.cloudflare_zone.main.id | ||
name = "@" | ||
type = "AAAA" | ||
content = var.host_ipv6 | ||
ttl = 1 | ||
proxied = false | ||
comment = "knotless-serve" | ||
} | ||
| ||
resource "cloudflare_record" "www" { | ||
zone_id = data.cloudflare_zone.main.id | ||
name = "www" | ||
type = "CNAME" | ||
content = var.zone | ||
ttl = 1 | ||
proxied = false | ||
comment = "redirects to the apex" | ||
} | ||
deploy/outputs.tfnew+7−0
| @@ -0,0 +1,7 @@ | ||
output "url" { | ||
value = "https://${var.zone}" | ||
} | ||
| ||
output "gateway_did" { | ||
value = "did:web:${var.zone}" | ||
} | ||
deploy/terraform.tfvars.examplenew+4−0
| @@ -0,0 +1,4 @@ | ||
cloudflare_api_token = "…" # Zone:DNS:Edit on the zone | ||
zone = "example.com" | ||
host_ipv4 = "203.0.113.10" | ||
host_ipv6 = "2001:db8::10" | ||
deploy/variables.tfnew+21−0
| @@ -0,0 +1,21 @@ | ||
variable "cloudflare_api_token" { | ||
description = "A token with Zone:DNS:Edit on the zone." | ||
type = string | ||
sensitive = true | ||
} | ||
| ||
variable "zone" { | ||
description = "The Cloudflare zone knotless-serve answers at the apex of." | ||
type = string | ||
} | ||
| ||
variable "host_ipv4" { | ||
description = "The host's IPv4 address, where the reverse proxy listens." | ||
type = string | ||
} | ||
| ||
variable "host_ipv6" { | ||
description = "The host's IPv6 address, or empty for none." | ||
type = string | ||
default = "" | ||
} | ||
deploy/versions.tfnew+14−0
| @@ -0,0 +1,14 @@ | ||
terraform { | ||
required_version = ">= 1.5" | ||
| ||
required_providers { | ||
cloudflare = { | ||
source = "cloudflare/cloudflare" | ||
version = "~> 4.0" | ||
} | ||
} | ||
} | ||
| ||
provider "cloudflare" { | ||
api_token = var.cloudflare_api_token | ||
} | ||