A headless Git server for platforms and internal tools.
Provides Git hosting primitives (git over SSH/HTTP, authentication, permissions, storage) and pluggable storage.
Basically, this is a Git layer of infrastructure you'd put underneath a project. This service is not responsible for billing and the UI, etc. It just handles the actual git transport, enforces access, and stores the bare repositories.
- Basic Git over SSH and HTTP for clone / fetch / push.
- Git LFS for large files, with object storage on local disk or any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO).
- A small control API, RESTful api to manage repositories, users, SSH keys, tokens, and permissions.
- A repo content API — list trees, read files, download zip/tar.gz archives, and create commits over REST, so your backend never needs a local clone.
- Simple permission model (
read/write/admin) enforced before every Git operation. - Path policies — block chosen paths from ever being committed, enforced identically on API commits and
git push(via a pre-receive hook). - Push webhooks — signed deliveries on every ref change, pushed or committed via the API.
- Bare-repository storage on a filesystem, with SQLite for metadata.
Start the server using headlessgit image (it already bundles git):
docker run --rm \
-p 4000:4000 -p 4001:4001 -p 2222:2222 \
-v "$PWD/data:/data" \
-e DATABASE_URL=/data/headlessgit.db \
-e ADMIN_TOKEN="$(openssl rand -hex 32)" \
ghcr.io/axenos-dev/headlessgit:latestThat brings up three listeners - Git over HTTP (4000) and SSH (2222) for clients, and the control API (4001) for your backend. ADMIN_TOKEN seeds an admin service account on boot — your backend uses it as a bearer token to provision accounts, repositories, and permissions through the control API.
Once a user has a repository and credentials, they can use it like any other Git remote — the path is always <owner-username>/<repo-name>.git:
# over SSH, authenticated by a registered public key
git clone ssh://localhost:2222/username/api.git
# over HTTP, authenticated by a token
git clone http://x:<token>@localhost:4000/username/api.gitFor local development, see ./dev.sh up.
- Backend using the admin token calls the control API to create accounts, register credentials, create repositories, and grant permissions - translating its own users into explicit repo grants here.
- Users use the data plane directly with their own credentials (SSH key or token). Service authenticates them and authorizes each operation against the permissions they have.
An account is either a user (a human with a Git client) or a service (a machine — backend). They authenticate identically and are authorized by the same per-repo permissions. The seeded ADMIN_TOKEN account is an admin service account — typically your application's backend, which uses it to provision accounts, repositories, and permissions.
- Keep the control API on an internal interface — it's the privileged plane. The data-plane ports are the ones you expose to clients.
- Treat
ADMIN_TOKENas a secret. Rotate it by changing the env value and restarting the service (to reseed the admin account). - Persist
/data(bare repos, the SQLite file, and the SSH host key all live there).
Today, bare repositories are stored on the local filesystem under REPO_ROOT. Support for keeping repositories on dedicated storage nodes over RPC is planned.
All configuration is via environment variables.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(required) | SQLite file path, e.g. data/headlessgit.db. |
AUTO_MIGRATE |
true |
Run migrations on startup. |
ENVIRONMENT |
DEVELOPMENT |
DEVELOPMENT or PRODUCTION. |
CONTROL_PORT |
4001 |
Control API listener. |
GIT_HTTP_PORT |
4000 |
Git-over-HTTP listener. |
GIT_SSH_PORT |
2222 |
Git-over-SSH listener. |
REPO_ROOT |
data/repos |
Where bare repositories are stored. |
SSH_HOST_KEY_PATH |
data/ssh/host_ed25519 |
SSH host key file (generated on first boot if absent). |
ADMIN_TOKEN |
(empty) | Raw token for the seeded admin account. Only its hash is stored. Empty = no admin seeded. |
TOKEN_GC_INTERVAL |
1h |
How often expired tokens are deleted. 0 disables the loop. |
REPO_GC_INTERVAL |
5h |
How often git gc sweeps the repositories (repack + prune). 0 disables the loop. |
WEBHOOK_WORKERS |
3 |
Goroutines delivering webhook events. |
See .env.example.
Git LFS is enabled if LFS_ENABLED=true set in environment. Clients then use it transparently over both HTTP and SSH, and nothing beyond the usual git lfs track.
Storage sits behind an interface, separate from the bare repos. It can be one of those:
disk(default) — objects stored locally on disk underLFS_ROOT.s3— any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO). Transfers use presigned URLs, so object bytes flow directly between the client and the bucket instead of streaming through the server.
| Variable | Default | Description |
|---|---|---|
LFS_ENABLED |
false |
Enable Git LFS. |
LFS_STORAGE_TYPE |
disk |
disk or s3. |
LFS_PUBLIC_URL |
(required if enabled) | Externally-reachable base URL of the Git HTTP server, e.g. https://git.example.com. |
LFS_ROOT |
data/lfs |
Object directory when LFS_STORAGE_TYPE=disk. |
LFS_S3_BUCKET |
(required for s3) | Bucket name. |
LFS_S3_ENDPOINT |
(required for s3) | Host without scheme, e.g. <account>.r2.cloudflarestorage.com. |
LFS_S3_ACCESS_KEY_ID |
(required for s3) | Access key ID. |
LFS_S3_SECRET_ACCESS_KEY |
(required for s3) | Secret access key. |
LFS_S3_REGION |
(empty) | Region; use auto for Cloudflare R2. |
LFS_S3_USE_SSL |
true |
Reach the endpoint over HTTPS. |
LFS_S3_USE_PATH_STYLE |
false |
Force path-style addressing (needed by some S3-compatible providers). |
LFS_S3_KEY_PREFIX |
(empty) | Optional prefix prepended to every object key. |
Every request requires Authorization: Bearer <ADMIN_TOKEN>. Responses are enveloped: {"data": ...} on success, {"error": {"code", "message"}} on failure.
Accounts & credentials
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/users |
{username, kind} |
Create a user/service account (kind: user | service); 409 user_exists if the username is taken. |
GET |
/users/{id} |
— | Get an account. |
GET |
/users/by-username/{username} |
— | Look up an account by username (name -> id resolution). |
GET |
/users/{id}/repositories |
— | List repositories owned by the account. |
POST |
/users/{id}/ssh-keys |
{title, publicKey} |
Register an SSH public key. |
GET |
/users/{id}/ssh-keys |
— | List the account's SSH keys. |
DELETE |
/users/{id}/ssh-keys/{keyId} |
— | Revoke an SSH key. |
POST |
/users/{id}/tokens |
{title} |
Mint a token; the raw value is returned once. |
GET |
/users/{id}/tokens |
— | List the account's tokens (never the secret). |
DELETE |
/users/{id}/tokens/{tokenId} |
— | Revoke a single token. |
DELETE |
/users/{id}/tokens |
— | Revoke all of the account's tokens. |
Repositories & permissions
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/repositories |
{ownerId, name, visibility} |
Create a repository (visibility: public | private); 409 repository_exists if the owner already has one with that name. |
GET |
/repositories/{id} |
— | Get repository metadata. |
GET |
/repositories/by-path/{namespace}/{name} |
— | Look up a repository by owner username + name (name -> id resolution). |
PUT |
/repositories/{id}/visibility |
{visibility} |
Change visibility (public | private). |
DELETE |
/repositories/{id} |
— | Delete a repository (row + bare repo). |
GET |
/repositories/{id}/permissions |
— | List collaborators. |
PUT |
/repositories/{id}/permissions |
{userId, role} |
Grant/update a collaborator role (read | write | admin). |
DELETE |
/repositories/{id}/permissions/{userId} |
— | Revoke a collaborator. |
POST |
/repositories/{id}/webhooks |
{url} |
Register a push webhook; the signing secret is returned once. 409 webhook_exists if the URL is already registered on the repo. |
GET |
/repositories/{id}/webhooks |
— | List the repository's webhooks (never the secret). |
DELETE |
/repositories/{id}/webhooks/{hookId} |
— | Delete a webhook. |
GET |
/repositories/{id}/path-policies |
— | List the repository's path policies. |
POST |
/repositories/{id}/path-policies |
{pattern, reason?} |
Block a path; see Path policies. |
DELETE |
/repositories/{id}/path-policies/{policyId} |
— | Remove a policy. |
Repository contents & commits
| Method | Path | Body | Description |
|---|---|---|---|
GET |
/repositories/{id}/contents?ref=&path=&include=lastCommit |
— | List one directory level, optionally with each entry's last commit. |
GET |
/repositories/{id}/commits/{sha} |
— | Get metadata for one commit by its full SHA. |
GET |
/repositories/{id}/diff?base=&head= |
— | Compare two refs with per-file metadata and unified patches. |
GET |
/repositories/{id}/blob?ref=&path=&lfs= |
— | Stream one file's raw content. |
GET |
/repositories/{id}/archive?ref=&format=&lfs=&prefix= |
— | Stream a zip (default) or tar.gz archive of the tree. |
POST |
/repositories/{id}/blobs |
raw bytes | Upload content into the repo's object database; returns {sha, size}. |
POST |
/repositories/{id}/commits |
JSON | Create a commit from Git blobs or verified LFS objects. |
ref accepts anything git can resolve to a commit — a branch, tag, sha, or expression like main~2 — and defaults to HEAD. Every response is pinned to the exact commit it was answered from, so consumers can page through a repository without seeing a torn view mid-push.
GET /contents returns the entries of one directory level:
{
"data": {
"ref": "main",
"sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
"path": "src",
"entries": [
{
"name": "main.go",
"path": "src/main.go",
"type": "file",
"mode": "100644",
"size": 1234,
"sha": "...",
"lastCommit": {
"sha": "7786adb...",
"message": "Change server difficulty",
"committedAt": "2026-07-30T18:42:00Z"
}
},
{
"name": "vendor",
"path": "src/vendor",
"type": "dir",
"mode": "040000",
"sha": "..."
}
]
}
}type is file | dir | symlink | submodule. Add include=lastCommit to populate the optional lastCommit object for every entry.
GET /commits/{sha} requires commit SHA and returns the complete commit message and metadata:
{
"data": {
"sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
"parents": ["7786adb11411791e94b04f5f672e50df2a472a65"],
"message": "Update server configuration",
"author": {
"name": "Alex Developer",
"email": "alex@example.com"
},
"authoredAt": "2026-07-30T18:40:00Z",
"committedAt": "2026-07-30T18:42:00Z"
}
}parents is an empty array for a root commit and contains multiple SHAs for a merge commit. A missing commit returns 404 commit_not_found.
GET /diff requires base and head, each accepting the same ref syntax as the other read endpoints. The all-zero SHA is also accepted on either side as the empty tree, so a ref creation can be diffed as base=0000...&head=<commit> and a ref deletion as base=<commit>&head=0000....
{
"data": {
"baseSha": "...",
"headSha": "...",
"files": [
{
"status": "renamed",
"oldPath": "config/default.properties",
"newPath": "config/server.properties",
"oldBlobSha": "...",
"newBlobSha": "...",
"oldMode": "100644",
"newMode": "100644",
"additions": 2,
"deletions": 1,
"binary": false,
"patch": "diff --git a/config/default.properties b/config/server.properties\n...\n@@ -1,3 +1,4 @@\n ...\n"
}
],
"truncated": false
}
}patch and binary are always present. Binary files return null for patch, additions, and deletions, with "patchOmittedReason": "binary". A patch larger than 1 MiB, or one that would take the response over its 10 MiB patch budget, is omitted completely with "patchOmittedReason": "too_large"—the API never returns a partially cut patch. Non-UTF-8 patches use "unsupported_encoding". Diffs over 10k files set "truncated": true and omit patches as "too_large".
The patch is intended for normal unified-diff renderers. Consumers that need complete old and new file bodies can fetch them through /blob using base + oldPath and head + newPath.
GET /blob streams the file bytes with Content-Length, a strong ETag (the blob sha — content-addressed, so If-None-Match caching works perfectly), and X-HeadlessGit-Commit carrying the resolved commit. With lfs=true, an LFS pointer file is replaced by the real object; a missing object is a 404 rather than silently serving the pointer.
GET /archive streams the whole tree as an artifact, named <repo>-<shortsha>.zip. By default its entries are under the matching <repo>-<shortsha>/ directory. Set prefix=release/source to choose another directory, or explicitly set prefix= to place entries at the archive root. Prefixes are relative directory paths and a trailing / is optional.
With lfs=true, pointer files are swapped for the real objects in-flight — the archive is re-encoded entry by entry, nothing is buffered or written to disk:
A pointer whose object is missing stays a pointer.
Commits follow two-step model: upload content first, then commit metadata referencing it.
# 1. upload each new/changed file's bytes (raw body, streamed)
curl -H "Authorization: Bearer $TOKEN" \
--data-binary @config.yaml \
http://localhost:4001/repositories/7/blobs
# -> {"data": {"sha": "44b4fc6d...", "size": 812}}
# 2. create the commit (atomic, any number of operations)
curl -H "Authorization: Bearer $TOKEN" -X POST \
http://localhost:4001/repositories/7/commits -d '{
"branch": "main",
"message": "update config",
"author": { "name": "deploy-bot", "email": "bot@example.com" },
"expectedHeadSha": "9fb03799...",
"operations": [
{ "op": "put", "path": "config.yaml", "blobSha": "44b4fc6d..." },
{ "op": "delete", "path": "config.old.yaml" }
]
}'
# -> 201 {"data": {"branch": "main", "commitSha": "...", "before": "9fb03799..."}}A put operation takes exactly one content source:
{ "op": "put", "path": "README.md", "blobSha": "<sha from POST /blobs>" }or a repository-scoped LFS object already uploaded and verified through the LFS Batch API:
{
"op": "put",
"path": "models/model.bin",
"lfs": {
"oid": "...",
"size": 734003200
}
}blobSha and lfs are mutually exclusive. executable is optional for puts. A delete operation takes only op and path.
A move relocates a file or a whole directory tree:
{
"op": "move",
"fromPath": "plugins",
"path": "server/plugins"
}Operations are applied in array order. After the move, later operations in the same request use the destination path:
{
"operations": [
{ "op": "move", "fromPath": "plugins", "path": "server/plugins" },
{ "op": "put", "path": "server/plugins/config.yml", "blobSha": "<sha>" },
{ "op": "delete", "path": "server/plugins/something.yaml" }
]
}expectedHeadSha controls concurrency:
| Value | Meaning |
|---|---|
| (omitted) | Last write wins. |
| a commit sha | Compare-and-swap: 409 head_mismatch if the branch moved. |
| the all-zero sha | The branch must not exist yet — creates it (or the first commit on an empty repository). |
Content is deduplicated by sha, so retrying an upload is free and a lost 409 race can be retried without re-uploading anything. Blobs that never get committed are garbage-collected after a grace period (see REPO_GC_INTERVAL).
API commits dispatch the same signed webhooks as a git push — consumers can't tell them apart.
The control port also serves an unauthenticated GET /healthz readiness probe. It returns 200 {"status":"ok"} when the database is reachable and 503 {"status":"unavailable"} otherwise, and backs the container HEALTHCHECK.
A path policy blocks a path, and everything under it from being added or modified in a repository.
curl -H "Authorization: Bearer $TOKEN" -X POST \
http://localhost:4001/repositories/7/path-policies \
-d '{"pattern": "runtime/", "reason": "....."}'Semantics:
- A pattern matches the exact path and its whole subtree:
runtimeblocksruntimeandruntime/state.json, but notruntime.mdorsrc/runtime/x— patterns anchor at the repo root and match whole path segments. - Deletes are always allowed, so blocked content already in history can be cleaned up.
- Policies apply to new commits only.
- Enforcement is identical on both write paths:
POST /commitsreturns422 path_blocked, and agit pushis rejected by a pre-receive hook before any ref moves — every commit in the push is checked, so a blocked path added and removed within the same push is still refused. The client sees thereasonverbatim:
remote: push rejected: "runtime/state.json" is blocked by policy (.....)
! [remote rejected] main -> main (pre-receive hook declined)Register a webhook on a repository and headlessgit will POST to it after every ref change — a git push or a commit created through the content API produce identical events. A repository can have multiple webhooks, but each URL only once (409 webhook_exists on duplicates); to rotate a secret, delete the webhook and re-register it.
One delivery is sent per changed ref (a branch/tag create, update, or delete — not per file or commit). The JSON body:
{
"event": "push",
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "344018f5c8bce597cfb1b13058edc688f3a13230",
"created": true,
"deleted": false,
"repository": {
"id": 6,
"name": "HeadlessGit",
"full_name": "Axenos-dev/HeadlessGit"
},
"pusher": { "id": 7, "username": "Axenos-dev" },
"timestamp": "2026-06-29T19:06:48Z"
}before/after are the ref's SHAs around the push; a create has before all-zero (created: true), a delete has after all-zero (deleted: true). repository.full_name is namespace/name.
Each request carries these headers:
| Header | Value |
|---|---|
X-HeadlessGit-Event |
push |
X-HeadlessGit-Delivery |
Unique id for this delivery attempt. |
X-HeadlessGit-Signature |
sha256=<hex> — HMAC-SHA256 of the raw body keyed by the webhook secret. |
Verify a delivery by recomputing the HMAC over the exact request body with the secret returned at registration, e.g.:
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-HeadlessGit-Signature")))The secret is generated server-side and shown once in the registration response.
Git 2.52 or newer is required for include=lastCommit. The container image bundles a compatible Git version.
./dev.sh up # build and run the stack (docker compose)
./dev.sh gen # regenerate sqlc code
./dev.sh test # build + vet + test (what CI runs)

