chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs 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
}