krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

c0d45bd13028b5cad90bac2dd1efad6390908e0d

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T01:45:52Z

docs: user guide, admin guide, API/webhook reference
 README.org     |   6 ++
 docs/admin.org | 140 ++++++++++++++++++++++++++++++++++++++++++
 docs/api.org   | 109 +++++++++++++++++++++++++++++++++
 docs/users.org | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 445 insertions(+)

diff --git a/README.org b/README.org
index 39a61e1..6b612a5 100644
--- a/README.org
+++ b/README.org
@@ -100,6 +100,12 @@ Every read command takes =--json=; stdout is data, stderr is messages;
 exit codes are stable (0 ok, 2 usage, 3 not found, 4 denied). Man pages
 via =gitbay man=, completions via =gitbay completion <shell>=.
 
+* Documentation
+
+- [[file:docs/users.org][user guide]] — accounts, keys, verified commits, repos, issues, MRs, scripting
+- [[file:docs/admin.org][admin guide]] — install, full configuration reference, backup/restore, upgrades
+- [[file:docs/api.org][API and webhooks]] — the JSON API contract, tokens, webhook payloads and HMAC
+
 * Development
 
 #+begin_src sh
diff --git a/docs/admin.org b/docs/admin.org
new file mode 100644
index 0000000..56873f2
--- /dev/null
+++ b/docs/admin.org
@@ -0,0 +1,140 @@
+#+title: gitbay admin guide
+
+One static binary (=gitbayd=), one SQLite file, bare repositories on
+disk, and the system =git=. Schema migrations run automatically on
+startup and on every admin command.
+
+* Install
+
+#+begin_src sh
+install -m 755 gitbayd /usr/local/bin/
+adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
+install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay
+gitbayd --config /etc/gitbay/config.toml check-config
+#+end_src
+
+=deploy/= in the source tree has a cloud-init file, a hardened systemd
+unit, and a nightly backup timer. Run as the unprivileged =gitbay= user;
+the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports
+22/80/443 without root.
+
+** The SSH port decision
+
+- =ssh.mode = "embedded"= (default): gitbayd itself listens, normally on
+  22 — move the host's admin sshd to another port. Remotes read
+  =git@host:owner/repo= with no port gymnastics.
+- =ssh.mode = "system"=: the host sshd owns 22 and invokes gitbayd via
+  =AuthorizedKeysCommand=:
+  #+begin_example
+  AuthorizedKeysCommand /usr/local/bin/gitbayd --config /etc/gitbay/config.toml authorized-keys %t %k
+  AuthorizedKeysCommandUser gitbay
+  #+end_example
+  sshd requires that binary to be root-owned and not group/world
+  writable. Unknown keys fail authentication inside sshd, so system mode
+  requires =registration.mode = "closed"= (check-config enforces this).
+
+* Configuration reference
+
+=/etc/gitbay/config.toml=. =check-config= validates and names every
+contradiction; =--no-host-checks= skips port/path probes.
+
+** [server]
+- =root= (default =/var/lib/gitbay=) — repositories, database, host
+  keys, ACME cache all live here.
+- =site_url= (required) — canonical =https://host=; drives ACME, clone
+  URLs, mail links.
+
+** [ssh]
+- =mode= — =embedded= | =system= (above).
+- =port= (22) — embedded listener port.
+- =host_keys= — list of private key paths; empty generates an ed25519
+  key at =<root>/ssh/host_ed25519=.
+
+** [http]
+- =addr= (=:443=), =tls= — =acme= | =files= | =off=.
+- =acme=: certificates via TLS-ALPN-01 on the HTTPS port, cached at
+  =<root>/acme=; =acme_email= for the CA account; =acme_http_addr=
+  (=:80=, ="off"= to disable) adds HTTP-01 and an https redirect —
+  failing to bind it is a warning, not fatal. Requires an =https://=
+  site_url with a public DNS name.
+- =files=: =cert_file= + =key_file=.
+- =off=: plain HTTP — development, or behind a TLS-terminating proxy.
+
+** [web]
+- =mode= — =view_only= (default) | =accounts=. In view_only the mutating
+  web routes are never registered; in accounts, browser sessions are
+  minted over SSH (=web login=), and users with write access can create
+  repos, comment, and make simple file edits (which commit unsigned,
+  honestly). =password_auth= is reserved and currently rejected.
+
+** [registration]
+- =mode= — =closed= (default) | =invite= | =open=. invite/open require
+  [mail]. See the user guide for the flows.
+
+** [mail]
+- =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= /
+  =smtp_pass=. STARTTLS when offered. Required for invite/open
+  registration and self-service =email add=; in closed mode you may omit
+  it entirely and assert addresses by hand (below).
+
+** [api]
+- =enabled= (false) — the JSON API surface; see =docs/api.org=. Off
+  means no credential-bearing HTTP endpoint exists at all.
+
+** [webhooks]
+- =allow_local= (false) — permit webhook targets on loopback/private
+  addresses. Leave off unless you know why you need it (SSRF).
+
+** [limits]
+- =clone_timeout= (3600s) — cap on =repo import= fetches.
+- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
+- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
+
+** [git_daemon]
+- =enabled= (false), =port= (9418) — the anonymous =git://= listener.
+  Serves only public repositories that additionally ran
+  =repo settings git-daemon <repo> on=.
+
+* Users, email, invites
+
+#+begin_src sh
+gitbayd admin user create alice --key alice.pub --email a@example.org --verified [--admin]
+gitbayd admin email verify alice a@example.org   # admin assertion, no SMTP needed
+gitbayd admin invite --email b@example.org       # mails a code; prints it if no SMTP
+#+end_src
+
+"Verified" means SMTP-confirmed or host-admin-asserted; the database
+records which. Verified emails are what make commit signatures
+meaningful — an unverified address never produces a =verified= badge.
+
+* Backup and restore
+
+#+begin_src sh
+gitbayd admin backup --out /var/backups/gitbay/backup.tar.gz
+#+end_src
+
+One archive: a consistent SQLite snapshot (taken *before* the
+repositories are read, so the database never references objects the
+archive missed), every repository, and the SSH host keys. Excluded:
+hook socket, regenerated hook scripts, WAL files. Safe to run against a
+live daemon.
+
+Restore: extract into an empty directory, point =server.root= at it,
+start gitbayd. Host keys are preserved, so clients keep their
+known_hosts entries; hooks regenerate at startup.
+
+* Upgrades
+
+Replace the binary, restart the unit. Migrations apply automatically and
+are transactional; hook scripts under =<root>/hooks= are rewritten at
+startup to point at the current binary path.
+
+* Odds and ends
+
+- deleting a fork marks MRs sourced from it =source_gone=; their diffs
+  remain viewable and mergeable because the target repo owns the
+  objects.
+- =refs/merge-requests/*= is server-owned and unpushable by clients.
+- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in
+  the =events= table, which also feeds webhooks.
+- the daemon idles under 10MB RSS; the smallest VPS tier is adequate.
diff --git a/docs/api.org b/docs/api.org
new file mode 100644
index 0000000..a546ab4
--- /dev/null
+++ b/docs/api.org
@@ -0,0 +1,109 @@
+#+title: gitbay API and webhooks
+
+* The JSON API
+
+One endpoint fronts the entire command registry — everything the SSH
+control plane can do, current and future, with identical semantics.
+Disabled by default; the instance must set:
+
+#+begin_src toml
+[api]
+enabled = true
+#+end_src
+
+** Tokens
+
+Tokens are minted over SSH and only over SSH — an API token can never
+create further credentials.
+
+#+begin_src sh
+gitbay auth token create --name ci [--scope full|read] [--ttl 30d]
+gitbay auth token list
+gitbay auth token revoke ci
+#+end_src
+
+The token (prefix =gb_=, shown exactly once) is presented as
+=Authorization: Bearer gb_...=. Only a hash is stored server-side.
+Scope =read= permits list/show/log/diff-style commands and refuses
+anything that modifies state. =--ttl= takes Go durations or a day
+suffix (=30d=); expired, revoked, and unknown tokens all answer the
+same 401.
+
+** POST /api/v1/cmd
+
+Request body:
+#+begin_src json
+{"argv": ["issue", "create", "you/project", "--title", "from CI"],
+ "stdin": "optional body for --file - style input"}
+#+end_src
+
+=argv= is real argv — no shell, no quoting rules. The response is the
+command's own JSON envelope with =exit_code= added, plus =stderr= when
+the command wrote diagnostics:
+
+#+begin_src json
+{"protocol_version": 1, "exit_code": 0, "data": {"number": 7}}
+#+end_src
+
+HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500.
+Commands that emit raw text rather than an envelope (=help=, =mr diff=)
+come wrapped as ={"output": "..."}=. Git transport commands and the
+token commands are refused by name.
+
+#+begin_src sh
+curl -s -H "Authorization: Bearer $TOKEN" \
+     -d '{"argv":["whoami"]}' https://gitbay.org/api/v1/cmd
+
+printf '{"argv":["issue","create","you/project","--title","t","--file","-"],"stdin":"body\n"}' |
+curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd
+#+end_src
+
+* Webhooks
+
+Per-repository outbound POSTs for repository events. Managed by repo
+admins:
+
+#+begin_src sh
+gitbay webhook add <url> --secret s3cret [--events push,issue.created]  # default *
+gitbay webhook list
+gitbay webhook deliveries [--limit 50]     # status, attempts, last error
+gitbay webhook redeliver <delivery-id>     # requeue, including dead letters
+gitbay webhook remove <id>
+#+end_src
+
+** Events
+
+=push= (data: ref, old, new, forced, deleted), =issue.created=,
+=issue.commented=, =issue.closed=, =issue.open=, =mr.created=,
+=mr.merged= (data: number, sha), =repo.imported= (data: from).
+
+** Delivery
+
+Each event POSTs one JSON body:
+
+#+begin_src json
+{"event": "push",
+ "repo": "you/project",
+ "actor": "alice",
+ "created_at": "2026-08-24T01:00:00.000Z",
+ "data": {"ref": "refs/heads/main", "old": "...", "new": "...",
+          "forced": false, "deleted": false}}
+#+end_src
+
+Headers: =X-Gitbay-Event=, =X-Gitbay-Delivery= (id), and — when the hook
+has a secret — =X-Gitbay-Signature-256: sha256=<hex hmac>= over the raw
+body. Verify before trusting:
+
+#+begin_src python
+import hmac, hashlib
+expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
+ok = hmac.compare_digest(expected, request.headers["X-Gitbay-Signature-256"])
+#+end_src
+
+A 2xx within 10 seconds is success. Anything else retries with
+exponential backoff (30s base, doubling) and dead-letters after five
+attempts; =webhook deliveries= shows the trail and =redeliver= revives a
+dead letter. Redirects are never followed, and targets resolving to
+loopback/private/link-local addresses are refused both at registration
+and again at connect time, unless the instance sets
+=[webhooks] allow_local=.
diff --git a/docs/users.org b/docs/users.org
new file mode 100644
index 0000000..ed5bd71
--- /dev/null
+++ b/docs/users.org
@@ -0,0 +1,190 @@
+#+title: gitbay user guide
+
+Everything here works from stock OpenSSH — replace =gitbay= with
+=ssh git@<host>= in any command and it behaves identically. The CLI adds
+convenience (instance profiles, repo inference, =$EDITOR=), nothing more.
+=ssh git@<host> help= lists every command the server knows.
+
+* Getting an account
+
+How you join depends on the instance's registration mode:
+
+- closed :: an admin creates your account on the host and registers your
+  first SSH key. Nothing for you to do but hand over your public key.
+- invite :: you receive a single-use code by email. With the SSH key you
+  want to use:
+  #+begin_src sh
+  ssh git@<host> register --username you --invite <code>
+  #+end_src
+  Your account is active immediately; the invited address is your
+  verified email.
+- open ::
+  #+begin_src sh
+  ssh git@<host> register --username you --email you@example.org
+  #+end_src
+  A verification code arrives by mail. Until you run
+  =ssh git@<host> email verify <code>=, the account is pending: you can
+  run =whoami= and the email commands, and nothing else — no git, no
+  repos.
+
+* SSH keys
+
+Your key is your identity; there are no passwords anywhere. The SSH
+username is always =git= — the key alone determines who you are.
+
+#+begin_src sh
+gitbay auth keys list
+gitbay auth keys add --scope git < ~/.ssh/ci_key.pub   # key on stdin
+gitbay auth keys remove SHA256:...
+#+end_src
+
+Scopes: =full= (default; git plus every control command) or =git= (git
+transport only — right for CI and automation keys, which then cannot
+touch issues, settings, or your account).
+
+A key belongs to exactly one account instance-wide. Registering a key
+someone else already holds is refused without telling you whose it is.
+
+* Verified commits
+
+The commit badge is driven by the *author* email and the signing key:
+=verified= means the signature is valid, the key is registered to an
+account, and the author email is a verified address on that account.
+
+For OpenPGP signing (git's default):
+#+begin_src sh
+gpg --armor --export you@example.org | gitbay auth pgp add
+#+end_src
+
+For SSH signing (=git config gpg.format ssh=): sign with any key
+registered on your account; your verified addresses act as the principal
+set. No separate registration step.
+
+Add and verify additional addresses with =email add <address>= /
+=email verify <code>= (requires the instance to have SMTP; otherwise an
+admin can assert an address for you).
+
+The states you will see, in decreasing order of trust: =verified=,
+=signed_unknown_key= (valid signature, key not registered here — register
+it and history upgrades retroactively), =signed_email_mismatch= (real
+key, author line claims someone else), =signed_key_expired= /
+=signed_key_revoked=, =bad_signature=, =unsigned=. Server-created commits
+(web edits, merge commits) are always =unsigned= — the server holds no
+signing key on principle.
+
+* Repositories
+
+#+begin_src sh
+gitbay repo create you/project [--private]
+gitbay repo clone you/project
+gitbay repo list
+gitbay repo show you/project
+gitbay repo log you/project --limit 20      # commits with signature states
+gitbay repo fork other/project [--name mine]
+gitbay repo delete you/project --yes
+#+end_src
+
+Pushing is SSH-only. Public repositories are anonymously readable over
+HTTPS (and =git://= where enabled); private repositories exist only over
+SSH and answer "not found" to everyone without access.
+
+Access and settings (owner or =admin= grant):
+#+begin_src sh
+gitbay repo access grant you/project alice write    # read | write | admin
+gitbay repo access revoke you/project alice
+gitbay repo settings protect you/project main       # no force-push, no delete
+gitbay repo settings require-signed you/project on  # every commit must verify
+gitbay repo settings git-daemon you/project on      # expose over git://
+#+end_src
+
+Import from another forge (git data only — issues and PRs do not
+transfer):
+#+begin_src sh
+gitbay repo import you/mirror --from https://github.com/you/repo.git \
+    [--private] [--token-stdin]        # token on stdin, never in the URL
+#+end_src
+
+* Organizations
+
+Orgs share the owner namespace with users and own repositories at
+=org/repo=. Members get write on all org repos; org admins get repo
+admin, create repos under the org, and manage membership.
+
+#+begin_src sh
+gitbay org create krz
+gitbay org members add krz alice [--role admin]
+gitbay org show krz
+gitbay org rename krz newname     # clone URLs change
+gitbay org delete krz --yes       # only when it owns no repositories
+#+end_src
+
+* Issues
+
+Anyone who can read a repository can file and comment. Closing/reopening
+is for the author or anyone with write; labels and assignees need write.
+
+#+begin_src sh
+gitbay issue create --title "it breaks" [--body "..." | --file -]
+gitbay issue list [--state open|closed|all]
+gitbay issue show 4
+gitbay issue comment 4 --message "same here"
+gitbay issue close 4 / reopen 4
+gitbay issue label 4 --add bug --remove wontfix
+gitbay issue assign 4 --add alice
+#+end_src
+
+Inside a clone, the repository is inferred from the =origin= remote —
+that is why no =owner/name= appears above. Anywhere else, pass it as the
+first argument. Long text: =--body= inline, =--file -= from stdin, or
+neither on a terminal and =$EDITOR= opens.
+
+* Merge requests
+
+#+begin_src sh
+gitbay mr create --source feature --target main --title "add thing"
+gitbay mr create other/upstream --source you/fork:feature --target main --title "..."
+gitbay mr list / show 4 / diff 4
+gitbay mr checkout 4              # local branch mr/4 from the MR head
+gitbay mr review 4 --approve      # or --request-changes / --comment
+gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
+gitbay mr close 4
+#+end_src
+
+Semantics worth knowing:
+
+- the MR head lives in the *target* repository as
+  =refs/merge-requests/N/head= (fetchable by any reader), so an MR
+  survives deletion of its source branch or fork.
+- force-pushing the source updates the MR and marks existing reviews
+  stale.
+- default strategy: fast-forward when possible, else a merge commit.
+  Squash makes one commit authored by the MR author, committed by the
+  merger. Rebase replays a linear range preserving authors; it refuses
+  ranges containing merge commits, and when fast-forward is possible it
+  *is* one (original commits and signatures land untouched).
+- on =require_signed_commits= branches only fast-forwards of fully
+  verified commits merge; everything server-created is refused with
+  instructions to rebase locally.
+
+* Scripting
+
+Every read command takes =--json= and emits one envelope:
+={"protocol_version": 1, "data": ...}=. stdout is data, stderr is
+messages. Exit codes are stable: 0 ok, 1 failure, 2 usage, 3 not found,
+4 denied, 5 server/protocol error. Nothing ever prompts; destructive
+commands take =--yes=.
+
+For HTTP automation see =docs/api.org=.
+
+* CLI setup
+
+#+begin_src sh
+gitbay remote add myforge forge.example.org [--port n] [--user u] --default
+gitbay remote list
+gitbay init [name] [--private]   # git init + repo create + origin, in one step
+#+end_src
+
+Configuration lives at =~/.config/gitbay/config.toml=. The CLI shells
+out to your real =ssh=, so =~/.ssh/config=, the agent, and hardware keys
+all apply. Man pages: =gitbay man --dir <dir>=; completions:
+=gitbay completion bash|zsh|fish=.