zuka
Git hosting your agent can actually drive.
One small Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP, and CI. No database, no Docker, no drama.
Named for the sound of it rather than the meaning: two syllables, a rare opening consonant, one plausible spelling after hearing it once. It sits near Swahili kuzuka — to surface, to emerge — without claiming to be it.
The idea
Agents already know git. What they can't do is the GitHub part.
Claude, Cursor and the rest run git clone, commit and push perfectly well.
What stops a non-developer shipping with them is everything bolted on top:
creating the repository, orgs and teams, deploy keys, permission matrices, pull
requests, review, Actions YAML.
So the split is strict:
git does the git
Code moves over the wire protocol, HTTP or SSH. Nothing proprietary.
the API does the rest
Create a repo, register a key, read a file, check CI — over REST and MCP.
Nothing in the API writes to a repository. No commit endpoint, no file
endpoint, no ref-moving endpoint. A second write path would mean two homes for
the fast-forward rule, two concurrency models and a merge story — git already
has all three. The write path is git push.
What's deliberately absent is the point: no organisations, no teams, no pull requests, no review, no issues. The whole model is accounts, repositories, refs, keys and runs.
Your README is the website
Open a repository in a browser and you don't get a wall of filenames — you get the README, rendered as a page, the way Markdoc turns Stripe's doc files into stripe.com/docs. The file listing is one tab away, not the thing a visitor has to squint at first. This page you're reading is that website.
The viewer is built for people who have never used a code host: the tabs say Overview · Files · History · Branches instead of tree, log and refs; every tab remembers which branch you were on; history reads like a chat feed with a coloured initial for each author; statuses say Added, Changed, Removed. A wrong link gets a sentence and a way home, not machine JSON.
Plain markdown already works. For readmes that want to be front pages, there's a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own line; everything between tags is ordinary markdown:
{% hero %}
# Big friendly title
One-line pitch under it.
[Primary button](docs/start.md) [Second button](#below)
{% /hero %}
{% callout type="tip" title="Good to know" %}
Callouts come as note, tip, warn and danger.
{% /callout %}
{% cards %}
{% card title="Left" %}
Feature one.
{% /card %}
{% card title="Right" %}
Feature two.
{% /card %}
{% /cards %}
{% details summary="The long table nobody reads twice" %}
…hidden until tapped, no JavaScript involved…
{% /details %}
The rules, all of them:
- A tag line starts at column zero. Indented or fenced tags are shown, not run — which is how this README quotes them.
- Unknown tags vanish and their content stays, so a document written for a richer engine degrades to its words, never to tag soup.
- An unclosed tag is closed for you at the end. A typo can't eat the page.
- Relative links work:
[guide](docs/guide.md)opens that file rendered as a page,serves the image,[src](src/)opens the listing. A folder of markdown becomes a small site with zero configuration. - Headings get anchor ids, so
[jump](#the-idea)works — this page uses them. - Any
.mdfile renders as a document; add?plain=1to see the raw text.
And because a README is written by anyone who can push — on a public repository, a stranger — the whole surface stays paranoid: raw HTML is dropped, link schemes pass an allow-list, every interpolated value is escaped, and the page ships a CSP with no script source at all. The dialect adds wrappers, not execution.
Install onto a host
sudo zuka setup --standalone --dry-run # prints every file it would write
sudo zuka setup --standalone
It writes /etc/zuka.env and two systemd units — the service and maintenance
separately, because a long repack must not stall requests — then prints the
remaining commands rather than running them. Creating users and enabling units
are steps someone may reasonably want to do differently.
--domain is optional, and does one thing: it adds a Caddyfile so Caddy
obtains and renews TLS for that name, and sets the public URLs advertised in
clone URLs. Without it you get a service on 127.0.0.1:8790 with no TLS, which
is right behind an existing proxy or for a local trial.
sudo zuka setup --standalone --domain git.example.com
--isolated installs the multi-tenant shape: a control plane that holds no
repositories and gives every account its own Incus container. Code, CI and
objects for one account never touch another's filesystem. --domain is
optional here too and means exactly the same thing — the control plane is the
only public listener, so it is the only thing a certificate is for.
sudo zuka setup --isolated
curl -X POST localhost:8790/v1/accounts/alice # returns immediately
Set a public URL if you have one: a tenant is only reachable through the control plane, so left to itself it advertises its own container address and hands callers a clone URL they cannot use.
Provisioning never blocks a request. The account records intent and a
reconciler drives it to ready; a request arriving early gets 429 with a
Retry-After. Tenants are stopped when idle and started again on demand.
Quickstart
Needs git ≥ 2.41 on the host.
cargo build --release
export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"
TOKEN=$(./target/release/zuka token alice --scopes admin) # shown once
./target/release/zuka
# [boot] listening on http://127.0.0.1:8790
# [ssh] listening on ssh://127.0.0.1:2222
Create and push
curl -X POST localhost:8790/v1/repos \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"site","visibility":"public"}'
git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
cd site && echo hello > README.md
git add . && git commit -m "first" && git push origin HEAD:refs/heads/main
The git username is ignored; the password is your token. visibility defaults
to private; a public repository is clonable and browsable by anyone, writable
by no one anonymous. Open http://127.0.0.1:8790/alice/site and there's your
website.
Use SSH instead
./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
git clone ssh://git@127.0.0.1:2222/alice/site.git
--read-only makes a key clone-only; --repos a,b confines it.
Read without cloning
A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
curl -H "$A" $R/refs
curl -H "$A" "$R/tree/src?ref=refs/heads/main"
curl -H "$A" "$R/raw/README.md"
curl -H "$A" "$R/search?q=TODO"
curl -H "$A" "$R/blame/src/main.rs"
curl -H "$A" "$R/compare?base=<sha>&head=<sha>"
On /v1 the ref is always a query parameter, never a path segment —
feature/login and src/main.rs both contain slashes, so /raw/{ref}/{path}
has no unique parse. The browser URLs accept the ambiguity for the sake of
readable links; the API does not.
Turn on CI
ZUKA_CI_ENABLED=1 ./target/release/zuka
# .zuka.toml, in the repository
[run]
steps = ["cargo fmt --check", "cargo test"]
timeout_secs = 600
branches = ["refs/heads/main"] # optional; omit to run everywhere
Push, then curl -H "$A" $R/runs and $R/runs/{id}/logs.
For agents — MCP
POST /mcp JSON-RPC 2.0: initialize · ping · tools/list · tools/call
The intended session: repo_create → key_add → plain
git clone/commit/push → run_list. There is no tool that writes to a
repository, and initialize says so, so a model doesn't go hunting for one.
A tool failure that's the caller's fault comes back as a tool result with
isError: true, not a JSON-RPC error — the model should see it and adapt, not
have the call look broken.
The 18 tools
Administration — the part git can't do
| Tool | |
|---|---|
repo_create | Create a repository. Returns both clone URLs. |
repo_list repo_get repo_delete | |
key_add key_list key_remove | Register an SSH key so the agent can then use git. |
Discovery — read without cloning
| Tool | |
|---|---|
ref_list | Branches, tags, notes with tip commits. |
tree_list | List a directory. |
file_read | Read a text file. |
search | Fixed-string search across the tree. |
commit_log commit_get | History; optionally path-filtered. |
compare | Diff from the merge base. |
blame | Who last changed each line. |
CI
| Tool | |
|---|---|
run_list run_get run_logs | Did the build pass, and why not. |
API
Full contract: openapi.json, also served at /openapi.json.
GET /healthz | Mode, version, disk headroom, CI state. 503 below the reserve. |
GET /openapi.json | This service's OpenAPI 3.1 document. |
GET /v1/account | Identity, scopes, storage usage and limits. |
GET POST /v1/repos | List, create (optionally import_url, visibility). |
GET PATCH DELETE /v1/repos/{account}/{repo} | Read, update settings, soft-delete. |
GET .../refs?type= | Branches, tags, notes. |
GET .../tree/{path}?ref= | Directory listing. |
GET .../raw/{path}?ref= | File bytes. |
GET .../commits?ref=&path=&limit= | Log. |
GET .../commits/{sha} | One commit and its changed paths. |
GET .../compare?base=&head= | Diff from the merge base. |
GET .../search?q=&path=&ref= | Content search. |
GET .../blame/{path}?ref= | Line attribution. |
GET POST .../runs · GET PATCH .../runs/{id} · GET .../runs/{id}/logs | CI. |
GET POST /v1/keys · DELETE /v1/keys/{id} | SSH keys. |
POST /mcp | MCP JSON-RPC. |
/{account}/{repo}.git/* | Git smart HTTP. Protocol v2. |
ssh://git@host/{account}/{repo}.git | Git over SSH, plus git archive --remote. |
The browser lives on the same origin, read-only, no credential required for
public repositories: /{account}/{repo} (the clone URL minus .git), plus
/tree/{ref}/{path}, /blob/{ref}/{path} (?plain=1 for raw text),
/raw/{ref}/{path} (download), /commits/{ref} (?from={sha} pages older),
/commit/{sha} and /refs. / redirects to the repository named by
ZUKA_HOME_REPO.
Errors on /v1 and /mcp are RFC 9457 application/problem+json with a
stable type URI — discriminate on type, never on detail. The git paths
answer in text/plain, because git prints the body straight at the user. The
browser pages answer a missing page in HTML, because a person tapped that link.
Lists return {items, truncated}. There is no cursor, and none is claimed.
CLI
zuka serve
zuka setup --standalone|--isolated install onto this host [--domain d] [--dry-run]
zuka jobs maintenance daemon (own service unit)
zuka gc | sweep | fsck [--repair] one-shot maintenance
zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
zuka key <account> <path.pub> [--title t] [--read-only] [--repos r]
zuka version
Scopes: repo:read, repo:write, admin, ci. Tokens expire in 90 days
unless you pass --days or opt out with --no-expiry, which warns.
Credentials added while the server runs take effect without a restart.
fsck exits non-zero on an orphan so a timer notices. It reports a repository
with no metadata but never deletes one — repair only removes records that
describe nothing.
Configuration
Everything has a working default; nothing is required.
All settings
| Variable | Default | |
|---|---|---|
ZUKA_BIND | 127.0.0.1:8790 | HTTP listener. |
ZUKA_SSH_BIND | 127.0.0.1:2222 | SSH listener; off disables. |
ZUKA_DATA_DIR | /var/lib/zuka | Everything on disk. |
ZUKA_PUBLIC_URL / _SSH | derived | Advertised clone URLs. |
ZUKA_HOME_REPO | — | account/repo featured at / in the browser. |
ZUKA_PROBLEM_BASE | https://zuka.dev/problems | Point error type URIs at your own docs. |
| Limits | ||
ZUKA_MAX_REPOS_PER_ACCOUNT | 100 | 0 disables. |
ZUKA_MAX_REPO_MB / _ACCOUNT_MB | 2048 / 10240 | 0 disables. |
ZUKA_RATE_PER_MINUTE | 600 | Per credential, on /v1 and /mcp. 0 disables. |
ZUKA_MAX_PACK_MB | 512 | Largest push. Also sets receive.maxInputSize. |
ZUKA_MAX_BLOB_MB | 32 | Largest blob served inline. |
ZUKA_MAX_BODY_MB | 10 | Largest JSON body. |
ZUKA_PAGE_LIMIT / _MAX_PAGE_LIMIT | 50 / 200 | |
ZUKA_MAX_CONCURRENT_GIT | 8 | Concurrent git subprocesses. |
ZUKA_DISK_RESERVE_MB | 2048 | Below this, writes return 507. |
| CI | ||
ZUKA_CI_ENABLED | 0 | Off by default; see below. |
ZUKA_CI_TIMEOUT_SECS | 600 | Ceiling. A spec may lower it, never raise it. |
ZUKA_CI_MAX_CONCURRENT | 2 | |
ZUKA_CI_LOG_MB | 8 | Output cap per run. |
ZUKA_CI_MEMORY_MB | 2048 | RLIMIT_AS. 0 disables. |
ZUKA_CI_FILE_MB | 2048 | RLIMIT_FSIZE. Independent of the memory limit. |
ZUKA_CI_MAX_PROCESSES | 0 | RLIMIT_NPROC — per-UID, so only meaningful with a runner uid. |
ZUKA_CI_KEEP_RUNS | 50 | Runs retained per repository. |
| Maintenance | ||
ZUKA_GC_INTERVAL_SECS | 21600 | |
ZUKA_GC_PRUNE_GRACE | 2.weeks.ago | Objects younger than this are never pruned. |
ZUKA_DELETED_RETENTION_DAYS | 7 | |
| Import | ||
ZUKA_IMPORT_ENABLED | 1 | |
ZUKA_IMPORT_TIMEOUT_SECS | 300 | |
ZUKA_IMPORT_ALLOW_PRIVATE | 0 | Permit private/loopback sources. Only on a trusted network. |
| Multi-tenant | ||
ZUKA_MODE | standalone | standalone · control · tenant |
ZUKA_OPERATOR | — | Account allowed to manage accounts. Unset ⇒ refused for all. |
ZUKA_INCUS_IMAGE | images:debian/12 | Image for a new tenant. |
ZUKA_INCUS_NETWORK | incusbr0 | Network a tenant joins. |
ZUKA_TENANT_BINARY | this binary | Pushed into each container; must be static. |
ZUKA_TENANT_PORT | 8790 | Port a tenant listens on inside its container. |
ZUKA_CONTROL_PUBLIC_KEYS | — | Tenant only. Comma-separated; more than one allows key rotation. |
ZUKA_PROXY_TIMEOUT_SECS | 300 | Ceiling on one proxied request. |
ZUKA_ME_URL is parsed and then refuses to boot — hosted auth isn't
implemented, and accepting a security-relevant setting while ignoring it is
worse than not accepting it.
Maintenance
Run zuka jobs as a second service unit — a long repack must not stall request
handling.
[Unit]
Description=zuka maintenance
After=zuka.service
[Service]
ExecStart=/usr/local/bin/zuka jobs
EnvironmentFile=/etc/zuka.env
User=zuka
Restart=always
[Install]
WantedBy=multi-user.target
It garbage-collects (git's own auto-gc is disabled on every repo, so a repack never fires inside a push), sweeps deleted repositories and old runs, and fscks.
What makes GC safe against a live clone is the grace window, not a lock — a
lock couldn't span the two processes. upload-pack can reference an object
between resolving and streaming it, so nothing younger than GC_PRUNE_GRACE is
pruned. A test clones a 12 MB repo while GC runs and checks the result byte for
byte.
Security
Each of these has a test that fails if it regresses. Several are here because an earlier version got them wrong.
Names can't escape the data directory. Ref names go through gix-validate,
never a hand-rolled check — ../../config is a command-execution primitive via
core.pager and core.sshCommand. Tree paths reject any .git component
after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping
and trailing dot/space removal.
Names are case-folded. Alice/Site and alice/site are one resource.
Comparing the raw path segment once let a case-variant account reach another
account's repository on a case-insensitive filesystem while passing the
ownership check.
An unreadable repository is 404, not 403. 403 enumerates private
repos. Confinement applies to destruction too: a token scoped to one repo
can't delete another.
Anonymous means read, and only through one door. Exactly one function
grants anonymous access, it can only read, and a presented-but-rejected
credential stays rejected — an expired token never quietly degrades into an
anonymous visitor. The browser answers a private repository with the same page
as an absent one; the git wire answers both with a 401 challenge, so neither
surface can be used to enumerate names.
The viewer renders hostile input inert. Every value is escaped, markdown drops raw HTML and rewrites link schemes against an allow-list, and pages carry a CSP with no script source — so anything that survived the escaping would still have nowhere to execute. Raw file bytes are served as an attachment in a CSP sandbox, never rendered on the origin.
Rewriting is allowed; protection is opt-in. rebase and --amend are
ordinary git and blocking them protects nobody when there's one agent and no
reviewer — git's reflog is the recovery path. Set protected_refs on the
branch something deploys from; it's then enforced server-side over HTTP and
SSH, which matters because --force bypasses the client check. One Rust
function; the hook is a one-line exec into the binary.
SSH runs git and nothing else. No shell, no pty, no subsystem. Three
services: upload-pack, receive-pack, upload-archive. The exec string is
parsed, never shelled, so git-upload-pack '/a/b.git'; id is a parse error.
The account comes from which registered key matched, never the SSH username.
Only GIT_PROTOCOL crosses into the child.
Only three endpoints route under .git. The dumb protocol isn't served. A
duplicated service parameter is refused — this server would take the first
and the CGI the last, so a duplicate would let the authorization decision and
the running service disagree.
Import can't reach the local network. The one place a caller-supplied URL
is fetched. Checked in two stages: shape (scheme, credentials) then every
address the host resolves to — checking the hostname alone is defeated by a
name that resolves to 169.254.169.254.
Credentials are hashed, scoped, expiring and 0600. Token comparison has
no early return. Public keys match on raw bytes; authorized_keys option
prefixes like command= are refused.
Deletes are recoverable. Soft-deleted to tmp/deleted/, swept on
retention.
Nothing buffers a pack.
before after
200 MB push, server RSS ~360 MB 5 MB
4 concurrent clones 1508 MB 5 MB
The child is killed by process group on disconnect — kill_on_drop reaches
only the direct child, and pack-objects is a grandchild that otherwise kept
running for seconds after the client left.
Multi-tenant isolation
In --isolated, each account gets its own Incus container, unprivileged and
with nesting off. The control plane holds no repositories: a push through it
lands in the account's container and nowhere on the host.
A tenant is told who is calling by an Ed25519 assertion the control plane signs, not a shared secret. That matters because a tenant runs the account's own CI, which is arbitrary code — anything symmetric in there would forge every other tenant. The private key never leaves the control plane; tenants hold only the public half, which forges nothing.
The assertion authenticates one request, not an identity: single-use nonce, 30-second window, bound to the method and path. It is deliberately not bound to the body, because a push is a multi-gigabyte pack the proxy streams — the nonce is what stops replay.
Verified on a real host rather than asserted, in
infra/README.md: CI inside a tenant reports
uname -n = zuka-alice, and a second account gets 404 on the first's
repository and an empty repo list.
CI is not a security boundary in standalone
Steps run as the service user with rlimits, a scrubbed environment, a process-group kill on timeout and a bounded log — but no container and no network namespace. Anyone who can push can run code as that user. CI is off by default and the boot log says so.
A step allowlist was considered and rejected: sh -c defeats it in one
character. Enforcement is at the OS or it isn't enforcement. Multi-tenant
isolation is what --isolated is for.
Renaming it
The name lives in exactly one file. src/brand.rs derives the env prefix, data
directory, problem-type URIs, auth realm, git config namespace, CI filename and
hook shim from CARGO_PKG_NAME.
Renaming is one line in Cargo.toml plus the openapi.json title — verified
by actually doing it. A unit test walks src/ and fails if the name appears as
a literal anywhere outside brand.rs; it has already caught a hardcoded auth
realm, and it is the reason even the browser viewer's stylesheet URL is derived.
Development
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test # 301 unit tests
deno task test:live # 69 live tests: spawns the binary, drives real git and ssh
The live suite is the real proof — unit tests cover units, but only a real clone and push exercise the wire protocol. It runs in CI rather than self-skipping there.
src/
├── brand.rs the one place the product name appears
├── config.rs Config::from_env(); fails boot on a bad value
├── error.rs Error -> RFC 9457 problem+json
├── core/ domain operations shared by every facade
├── git/
│ ├── exec.rs the only place this service invokes git
│ ├── validate.rs security controls: names, refs, paths
│ ├── store.rs repositories, hooks, ref rules
│ ├── discover.rs the read core, shared by REST, MCP and the browser
│ ├── transport.rs streaming CGI bridge
│ └── import.rs SSRF-guarded cloning
├── http/ routing, limits, responses, auth, rate limiting
├── api/ REST facade
├── mcp/ MCP facade
├── web/ the browser viewer: escaping, the tag dialect, the pages
├── ssh/ SSH server and exec-command parser
├── ci/ spec, run store, runner, executor
├── control/ accounts, provisioning, reconciler, streaming proxy
├── setup.rs systemd units, Caddyfile, environment file
├── jobs/ gc, sweep, fsck
├── account/ identity, tokens, SSH keys
└── store/ metadata, quotas, change-aware file cache
Two rules worth knowing before changing anything:
- git is the only writer. No endpoint or tool modifies repository contents.
- Facades translate; they don't decide. Authorization lives on
Identity, git rules ingit/, shared operations incore/.api/andmcp/reshape payloads and nothing else — they had drifted copies of repo create and delete once, and those copies had already diverged in four ways.
More: SPEC.md · ROADMAP.md · AGENTS.md