# knotless-serve and its pages, and knotless-run if asked, 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. # # deploy.sh moves serve and pages with rollout.sh, which starts a new # container beside the old under the same names and stops the old once the # new one's healthcheck passes: neither sets a container_name, so two can # run at once. name: knotless # Each container's output, JSON lines for serve and the runner, kept in # the host's journal (deploy/README.md, Logs), which outlives the # container: a rollout removes the old one, and its lines stay. Each line # names its project and service (COM_DOCKER_COMPOSE_PROJECT and _SERVICE) # and its container (CONTAINER_TAG). x-logging: &logging driver: journald options: labels: com.docker.compose.project,com.docker.compose.service tag: "{{.Name}}" services: serve: image: knotless:current restart: always logging: *logging command: - knotless-serve - --listen - 0.0.0.0:2590 - --public-url - https://${KNOTLESS_DOMAIN:?set KNOTLESS_DOMAIN in .env} - --index - /data/index.db # What feeds the index: Jetstream, a relay's firehose, or both. # KNOTLESS_JETSTREAM set empty turns Jetstream off; KNOTLESS_FIREHOSE # (a relay, wss://relay1.us-east.bsky.network) turns the firehose on. # KNOTLESS_BACKFILL (https://relay1.us-east.bsky.network) refills the # index from a relay at each start, for what a feed down missed. - --jetstream - ${KNOTLESS_JETSTREAM-wss://jetstream.us-east.bsky.network} - --firehose - ${KNOTLESS_FIREHOSE:-} - --backfill - ${KNOTLESS_BACKFILL:-} # When an upgrade rebuilds the index, and every ten minutes after, # it refills from the accounts this relay lists as holding knotless # records. - --relay - ${KNOTLESS_RELAY:-https://relay1.us-east.bsky.network} # Which accounts hold knotless records is asked of microcosm's # lightrail before the relay, and each account's repository is read # from its hubble mirror before the account's PDS. Either set empty # is not asked. - --directory - ${KNOTLESS_DIRECTORY-https://lightrail.microcosm.blue} - --mirror - ${KNOTLESS_MIRROR-https://hubble.microcosm.blue} - --web - http://pages:2591 # The client's builds, as the repository's releases of its # `knotless` package, which the release workflow publishes. - --releases - knotless://chadtmiller.com/knotless # Repositories' sites, on a domain of their own (knotless.page), when # KNOTLESS_PAGES_DOMAIN names one; setup.sh's Caddy holds its # certificate. Empty answers none. - --pages-domain - ${KNOTLESS_PAGES_DOMAIN:-} # Apple's push service, for the iOS app's notifications: deploy.sh # sets the key's path once the key is on the host (deploy/README.md). # Empty, none is sent. - --apns-key - ${KNOTLESS_APNS_KEY:-} - --apns-key-id - ${KNOTLESS_APNS_KEY_ID:-} - --apns-team - ${KNOTLESS_APNS_TEAM:-} # /metrics and PUT /log, inside the container alone: read them # with docker exec (deploy/README.md, Logs). - --metrics - 127.0.0.1:9464 # How it is used, for `knotless admin stats`: counts, never who. - --usage - /data/usage.db # The lines it logs, kept to read back across deploys, for # `knotless admin logs` and MCP's logs (deploy/README.md, Logs). - --logs - /data/logs.db # Where the bots' OAuth sessions are kept once they log in at # /oauth/bot: run and triage read them from there. - --bots - /data/bots # Code search: the Zoekt shards of every public repository's default # branch, written from its mirror as it is pushed. - --code-index - /data/code # The runner a new public repository names unless its owner names # others: the runner host's account (runner-host/README.md). Empty, # none. - --default-runner - ${KNOTLESS_DEFAULT_RUNNER_DID:-} # The oldest client version to ask for, and why: set when one harms # repositories, and clients older refuse to run until upgraded. # Empty, none. - --client-minimum - ${KNOTLESS_CLIENT_MINIMUM:-} - --client-minimum-reason - ${KNOTLESS_CLIENT_MINIMUM_REASON:-} # The oldest build of the iOS app to let run, and why: older builds # show only a way to update. Empty, any runs (ios/README.md). - --ios-minimum-build - ${KNOTLESS_IOS_MINIMUM_BUILD:-} - --ios-minimum-reason - ${KNOTLESS_IOS_MINIMUM_REASON:-} 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 # The accounts, by DID, that operate it: takedowns, reports, usage. KNOTLESS_ADMIN_DIDS: ${KNOTLESS_ADMIN_DIDS:-} # The accounts, by DID, that may log in at /oauth/bot: run's and # triage's. KNOTLESS_BOT_DIDS: ${KNOTLESS_BOT_DIDS:-} # What is logged, as RUST_LOG takes it: info, unless .env says. RUST_LOG: ${KNOTLESS_LOG:-info} 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 # Ahead of CI for the host's cores: a run's containers have docker's # default share (1024), so while they keep every core busy the site # still gets four times theirs. cpu_shares: 4096 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 # Each second while it starts, so a rollout hands over as soon as it # listens rather than at the first 30s check. start_period: 120s start_interval: 1s # Stopped, it finishes the requests it has: a push or a clone can take # a while. stop_grace_period: 30s pages: image: knotless:current restart: always logging: *logging 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 # A file the build writes, answered without asking serve anything. healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:2591/_app/version.json').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 60s start_interval: 1s # Stopped, it finishes the requests it has, for up to adapter-node's # SHUTDOWN_TIMEOUT (30s). stop_grace_period: 35s cpu_shares: 4096 deploy: resources: limits: # Two: at one, node's garbage collector and a render share a # core, and the page waits out the rest of the period throttled. cpus: "2" memory: 512m # knotless-run, only with COMPOSE_PROFILES=run, which deploy.sh sets # when KNOTLESS_RUN_REPOS names repositories to watch. run: image: knotless:current profiles: [run] restart: always logging: *logging # Root: what holds the docker socket is root on the host already, and # steps, run as root in their containers, leave files in the checkout # that only root can remove. user: root # Stopped, it drops each run still waiting its turn, for the next # start to run, and finishes only the runs whose steps have started, # a batch's and those at a going deploy's commit: most finish in a few # minutes, test in about 9, a release's build of the client in about # 17. A deploy does not wait for that: it starts the next runner beside # it (handover.sh), which lends the old one the jobs its runs hold, so # waiting costs nothing, and only a stop by hand waits. --stop-wait is # an hour, so that no run is cut off that its own limits let go on: a # step that hangs ends at --step-timeout (30 minutes). Past the hour it # cancels those still going itself, which a stop nothing comes to kill # needs. 65 minutes is that with room to spare; past it, docker kills # the runner, and the next ends what was cut off as an error and # removes its containers. stop_grace_period: 65m command: - sh - -c - >- exec knotless-run --jetstream ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} --executor docker --secrets /etc/knotless-run/run-secrets.json --state ${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}/state.json --work ${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}/work --caches ${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}/caches --stop-wait 3600 ${KNOTLESS_RUN_ARGS:-} ${KNOTLESS_RUN_REPOS:-} environment: # The appview beside it, where --pulls finds pulls. KNOTLESS_APPVIEW: http://serve:2590 # The bots' sessions, which serve keeps (--bots). KNOTLESS_CONFIG_DIR: /bots KNOTLESS_CACHE_DIR: ${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}/cache RUST_LOG: ${KNOTLESS_LOG:-info} env_file: # KNOTLESS_IDENTIFIER, the account the checks are written in, and # KNOTLESS_PASSWORD, an app password for it, used only until it logs # in at /oauth/bot: an app password reads no private repositories. - path: /etc/knotless/run.env required: false volumes: - /var/run/docker.sock:/var/run/docker.sock # At the same path as on the host: a step's container mounts its # checkout by the path the host's daemon knows it by. - ${KNOTLESS_RUN_DATA:-/var/lib/knotless-run}:${KNOTLESS_RUN_DATA:-/var/lib/knotless-run} # Which secrets each repository's workflows may have, by # /, in run-secrets.json, read again at each run. The # directory, not the file: an edit that writes a new file, as most # do, would leave a file's mount on the old one until a restart. The # docker socket above gives the runner the host already, so the # rest of /etc/knotless costs nothing more. - /etc/knotless:/etc/knotless-run:ro - ${KNOTLESS_DATA:-/var/lib/knotless}/bots:/bots deploy: resources: limits: cpus: "1" memory: 512m # Triages issues with a decision model, writing labels as its own # account (knotless-triage). /etc/knotless/triage.env holds that # account's KNOTLESS_IDENTIFIER and KNOTLESS_PASSWORD, an app password # used until it logs in at /oauth/bot, and the model's endpoint and # token, KNOTLESS_DECIDE_URL and KNOTLESS_DECISION_TOKEN. It keeps # nothing but its session: what it has judged is in its records. triage: image: knotless:current profiles: [triage] restart: always logging: *logging command: - sh - -c - >- exec knotless-triage --decide "$$KNOTLESS_DECIDE_URL" --jetstream ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} ${KNOTLESS_TRIAGE_ARGS:-} ${KNOTLESS_TRIAGE_REPOS:-} environment: # The appview beside it, where it finds issues. KNOTLESS_APPVIEW: http://serve:2590 KNOTLESS_CONFIG_DIR: /bots KNOTLESS_CACHE_DIR: /tmp/knotless-triage/cache RUST_LOG: ${KNOTLESS_LOG:-info} env_file: - path: /etc/knotless/triage.env required: false volumes: - ${KNOTLESS_DATA:-/var/lib/knotless}/bots:/bots deploy: resources: limits: cpus: "0.25" memory: 128m # Reviews the pulls that ask its account for a review, with Claude Code # (knotless-agent review, knotless-claude-review), writing each review # as that account's. /etc/knotless/review.env holds the account's # KNOTLESS_IDENTIFIER and KNOTLESS_PASSWORD, an app password, and # Claude's login: CLAUDE_CODE_OAUTH_TOKEN, from `claude setup-token`, # or ANTHROPIC_API_KEY. It keeps nothing but its session: what it has # reviewed is in its records. Private repositories it reads with the # OAuth session it gets at /oauth/bot, which KNOTLESS_BOT_DIDS lets it. review: image: knotless:current profiles: [review] restart: always logging: *logging command: - sh - -c - >- exec knotless-agent review --command knotless-claude-review --jetstream ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} ${KNOTLESS_REVIEW_ARGS:-} ${KNOTLESS_REVIEW_REPOS:-} environment: # The appview beside it, where it finds pulls; Claude reads them # from there too. KNOTLESS_APPVIEW: http://serve:2590 KNOTLESS_CONFIG_DIR: /bots KNOTLESS_CACHE_DIR: /tmp/knotless-review/cache RUST_LOG: ${KNOTLESS_LOG:-info} env_file: - path: /etc/knotless/review.env required: false volumes: - ${KNOTLESS_DATA:-/var/lib/knotless}/bots:/bots deploy: resources: limits: cpus: "1" memory: 1g # Keeps the pulls the merge queue turned away moving (knotless-agent # keep): one that conflicts only where both sides added lines it brings # up to date and queues again. /etc/knotless/keep.env holds its # account's KNOTLESS_IDENTIFIER and KNOTLESS_PASSWORD, an app password; # the account is a collaborator of each repository it keeps. It runs # no model and keeps nothing but its session. keep: image: knotless:current profiles: [keep] restart: always logging: *logging command: - sh - -c - >- exec knotless-agent keep --jetstream ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} ${KNOTLESS_KEEP_ARGS:-} ${KNOTLESS_KEEP_REPOS:-} environment: KNOTLESS_APPVIEW: http://serve:2590 KNOTLESS_CONFIG_DIR: /bots KNOTLESS_CACHE_DIR: /tmp/knotless-keep/cache RUST_LOG: ${KNOTLESS_LOG:-info} env_file: - path: /etc/knotless/keep.env required: false volumes: - ${KNOTLESS_DATA:-/var/lib/knotless}/bots:/bots deploy: resources: limits: cpus: "0.5" memory: 512m networks: proxy: name: ${KNOTLESS_PROXY_NETWORK:-knotless} external: true