Running a node
One binary serves two protocols on one port: git smart-HTTP for clone,
fetch and push, and the platform API for signed operations. This page is
how to start it and what each policy file means.
Using a node: docs/using/cli.md. The policy behind each flag:
docs/operating/authorization.md, docs/operating/limits.md,
docs/operating/webhooks.md, docs/operating/observability.md,
docs/operating/transports.md.
Default port 8417. A repository is sequenced only when it carries a
pre-receive hook: create with choir repo create or --create; a bare
repository that arrives any other way is adopted and hooked at the next
start. With no arguments the binary uses ./repos and port 8417;
configured invocations must supply both <repo-root> and <port> before
any flags.
Getting the binaries
One command, no Rust toolchain and no OpenSSL headers. macOS and Linux, x86-64 and arm64:
curl -fsSL https://github.com/deadcaf3/choir/releases/latest/download/choir-node-installer.sh | sh
curl -fsSL https://github.com/deadcaf3/choir/releases/latest/download/choir-cli-installer.sh | sh
Take both: every command on this page is choir, and choir node serve
execs the daemon. The four binaries land in $CARGO_HOME/bin
(~/.cargo/bin by default) as choir-node, choir-ssh, choir and
choir-mcp; each release publishes a SHA256 per archive and a
sha256.sum.
cargo binstall choir-node choir-cli fetches the same archives. From a git
checkout, needing only a Rust toolchain:
cargo install --git https://github.com/deadcaf3/choir choir-cli choir-node
Neither package pulls in choir-actor, the one crate that needs this
workspace’s LIBSQLITE3_FLAGS.
From source: the toolchain and headers the README lists, then:
cargo build --release -p choir-node -p choir-cli
export PATH="$PWD/target/release:$PATH"
choir node status and choir --version report the built commit from a
stamp the release workflow sets.
One command
choir host
| you have | run | you get |
|---|---|---|
| a laptop, or a box nobody else reaches | choir host | http://127.0.0.1:8417, in seconds, no certificate |
| a name pointing at this box | choir host --domain node.example | https://node.example:8417 |
| a VPS and no name | choir host --public | https://<this-ip>.sslip.io:8417 |
It prints the URL people use and, with --invite, one invite link:
choir host --repo me/thing.git --invite Ada
The two public modes take two commands. Obtaining a certificate is
privileged and choir host does not run sudo: it prints the line to
paste and exits 3. Paste it, run the same choir host again, and it
continues.
$ choir host --domain node.example
ok state /home/you/.choir — credential, key, trusted keys
next: a certificate for node.example has to be issued as root.
certbot writes /etc/letsencrypt, and the renewal hook that keeps
this working for the next two years lives there too. Paste this:
sudo choir node tls node.example --user you --port 8417
sudo ufw allow 8417/tcp && sudo ufw allow 80/tcp
then: choir host --domain node.example
Dry-run the certificate step without spending a Let’s Encrypt rate-limit slot:
sudo choir node tls node.example --user "$(id -un)" --dry-run
Why a public bind needs a certificate at all
choir-node refuses a non-loopback bind without TLS
(invariant 9). There is no flag to soften it.
--public, and why the address is ugly
choir host --public uses an sslip.io name built from
the box’s address, 203-0-113-7.sslip.io for 203.0.113.7, so a
certificate can be issued with no DNS. Replace it any time:
choir host --domain the-name-you-bought.example
sslip.io and nip.io share one Let’s Encrypt rate-limit pool. If issuance
fails for that reason, --public-name <name> takes any name pointing at
this box.
What it did
Every step is a command you could run yourself:
| step | what | undo |
|---|---|---|
| state | choir init: ~/.choir with the credential, actor key, trusted keys, and .choir/config | delete ~/.choir |
| acl + accounts | ~/.choir/acl granting the operator everything, and an empty ~/.choir/accounts.jsonl | delete either file |
| certificate | choir node tls: certbot, a renewal deploy hook, the pair projected where the node can read it, and ~/.choir/tls.enabled | sudo rm /etc/letsencrypt/renewal-hooks/deploy/choir-tls, sudo certbot delete |
| address | ~/.choir/public-url, and .choir/config pointed here | edit either |
| supervised | choir node install: a launchd agent or a systemd --user unit | choir node uninstall |
| healthy | polls /healthz | |
| repository | choir repo create, with --repo | |
| invited | choir invite, with --invite | choir revoke |
Two things it prints but never runs:
- the firewall.
ufworfirewalld, opening the serving port, and port 80 when HTTP-01 is the challenge (renewals rebind it every ~60 days). - linger. Without
loginctl enable-linger, systemd stops a--userunit at logout.choir hoststops with the line to paste;--yesaccepts a node that dies at logout.
TLS, and what happens every 60 days
The daemon terminates TLS itself with --tls-cert and --tls-key. An
nginx or Caddy front is a valid topology; it is not required.
The daemon reads its certificate once, at bind. The certbot deploy hook
choir node tls installs re-projects the pair into ~/.choir/tls/ and
restarts the unit. Renewal runs on certbot’s timer.
Root for certbot, an unprivileged account for the node; the node’s copy of the pair is a copy for that reason.
The ACME account is registered without an email. sudo certbot renew --dry-run is the manual check; choir doctor reports the expiry date.
Checking it
choir doctor
On a hosting machine it adds six rows: bind address, TLS on, certificate expiry, linger, unit loaded and running, and whether the public URL answers (asked from the box, so it proves name and certificate, not the firewall).
Uninstalling
choir node uninstall
Removes the unit and stops the node. Keeps ~/.choir: keys,
repositories and the op log. If a renewal hook is installed it prints the
two sudo lines that remove it and the certificate.
In a container
Dockerfile at the repository root builds from source and runs the daemon
as an unprivileged user with /var/lib/choir as a volume. Loopback mode and
bring-your-own-certificate mode; no ACME client in the daemon. No compose
file: the volume and the port are one flag each.
By hand
choir init # the same layout, without a service manager
choir node serve # runs here, in this terminal
choir node serve -- --reviewers-file ~/.choir/reviewers # any daemon flag, after `--`
choir node serve derives root, port, credential and trusted-key file from
what choir init wrote, then execs the daemon. Three files switch
behaviour by existing:
| file | effect |
|---|---|
~/.choir/tls.enabled | two lines, cert path then key path: binds 0.0.0.0 with that pair |
~/.choir/acl | --acl-file |
~/.choir/accounts.jsonl | --accounts-file (refused without an ACL) |
Supervised, step by step:
choir init # mint ~/.choir: credential, key, trusted keys, config
choir node install # hand it to launchd (macOS) or systemd (Linux)
choir node status # health, the commit serving, sequencer position
choir repo create me/thing.git # a repository on the running node
choir repo url me/thing.git # the clone URL, and the git config to go with it
choir node logs # the tail of the daemon log
# choir node restart | stop | uninstall -- uninstall keeps ~/.choir
choir node install writes a unit that runs choir node serve. Daemon
flags go after -- and are recorded in the unit:
choir node install -- --acl-file ~/.choir/acl --rate-limit-api 60
The daemon can be run directly:
choir-node /tmp/choir-repos 8417 \
--create owner/demo.git \
--auth-file ~/.choir/auth \
--keys-file ~/.choir/keys
Useful flags: --bind, --tls-cert / --tls-key, --acl-file <file> (required before a second credential), --request-log <file> and --rate-limit-api / --rate-limit-git (also required before a second credential), --quota-push-bytes / --quota-workspaces, --api-body-limit, --batch-limit, --ready-min-free-bytes, --read-only-browser, --journal <file>, --require-assignment, --protected-refs <file>, --require-review, --reviewer-conflict-graph <file> with --reviewer-conflict-distance <hops>, --review-retention <count>, and --review-lapse-after-secs <seconds>. Authenticated operations endpoints: /healthz, /readyz, /metrics. Flag reference: crates/choir-node/src/main.rs module docs, or AGENTS.md.
File formats (all mode 0600)
| File | Format |
|---|---|
--auth-file | user:token per line; authentication only, pair with --acl-file |
--acl-file | <user> <repo|*|@node> <level> per line; levels read < propose < write < own, and auditor on @node |
--keys-file | <64-hex> or <channel> <64-hex> (bound key) |
--reviewers-file | channel name per line; re-read on each draw |
--protected-refs | owner/repo.git:refs/heads/main (trailing * ok) |
--reviewer-conflict-graph | undirected operator operator edges; pair with an explicit maximum hop distance |
Hot-reload: trusted keys, channel bindings, push-certificate signers, reviewers, and ACL grants take effect on the next request.
Warning
Without
--acl-file, every credential reaches every repository. A seconduser:tokenline can clone every repo, push to any unprotected ref and provision workspaces anywhere. Do not issue a second credential without an ACL.choir hostwrites one.
Behind a TLS proxy
For the private beta, bind 127.0.0.1 and terminate TLS at a hardened
reverse proxy. Do not use the legacy direct-TLS installer. The beta service
renderer requires an ACL, protected-ref review policy, scoped operations,
operator-issued auth, and the read-only browser.
docs/private-beta-runbook.md covers the
network hold, renderers, backups, CI packaging, staging promotion,
monitoring, rollback and go-live receipts.
scripts/flip/RUNBOOK.md is the operator’s own dogfood procedure; not the
page to start from.