Small unix daemon to serve git files through nginx.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-11 20:51:53 +00:00
deploy Drops a client gone without closing after about a minute, freeing its database connection 2026-11-11 21:49:30 +01:00
src first commit 2026-11-11 14:58:25 +01:00
.gitignore first commit 2026-11-11 14:58:25 +01:00
Cargo.lock first commit 2026-11-11 14:58:25 +01:00
Cargo.toml first commit 2026-11-11 14:58:25 +01:00
README.md first commit 2026-11-11 14:58:25 +01:00

Pages

Serves files straight from Forgejo's bare repositories, at any branch, tag or commit, with no checkouts and no build step. A push is live on the next page load.

URLs

  • /<repo>/<path>: the default branch
  • /<repo>/@<ref>/<path>: a branch, then a tag, by its exact name, or a full 40-character commit hash
  • A path ending in / gets index.html; /<repo> and a folder without its / redirect to the same with it

How it works

sequenceDiagram
  participant B as Browser
  participant N as nginx
  participant P as pages (Rust, libgit2)
  participant G as Bare repository

  B->>N: GET /myday/@main/main.js<br/>If-None-Match: "a1b2…"
  N->>P: SCGI over /run/pages/pages.sock<br/>REQUEST_METHOD, DOCUMENT_URI, HTTP_IF_NONE_MATCH
  P->>P: Split into repo, ref and path
  P->>G: refs/heads/main, then main.js in its tree
  alt There is no such file
    G-->>P: Not found
    P-->>N: 404
  else The blob id matches If-None-Match
    G-->>P: Blob id
    P-->>N: 304 Not Modified
  else It differs
    G-->>P: Blob id and bytes
    P-->>N: 200, Content-Type, Content-Length,<br/>ETag: blob id, the bytes
  end
  N-->>B: The same reply, over TLS
  • nginx handles TLS, HTTP/2, gzip and rate limiting. Its event loop holds the browsers' connections, and it passes the daemon only GET and HEAD requests, with the method, the decoded path and If-None-Match.
  • pages is a Rust daemon using libgit2. Five OS threads accept from the same Unix socket, each with its own open repository handles, and do the blocking git work. It holds no other state.

Caching

  • The ETag is the file's git blob id, so it changes only when the file's bytes do.
  • Branches and tags get Cache-Control: no-cache: the browser asks every time, and an unchanged file costs a 304.
  • Commit hashes get Cache-Control: public, max-age=31536000, immutable, so the browser never asks again.
  • There's no other cache, so no TTL to tune and nothing to clear after a push.

Building

cargo build --release
cargo test
pages <socket> <repositories folder>

It serves <folder>/<repo>.git for each /<repo>/, for example from /var/lib/forgejo/repositories/joexo.

Deploying

deploy/ has the files for one server running Forgejo, nginx and pages:

The daemon reads the repositories through a shared group:

  • A pages group holds both the pages user and Forgejo's git user.
  • The repositories folder is git:pages with mode 2750. The setgid bit makes everything Forgejo creates inside it inherit the group.
  • Forgejo runs with UMask=0027, so the group can read what it writes.
  • /var/lib/forgejo is git:pages with mode 0710, so pages can pass through it but not list it.

Safety

The design removes attack surface rather than guarding it:

  • nginx filters first. Only GET and HEAD pass, with no request body and no headers but If-None-Match. nginx caps the URI's length, decodes it, resolves its dot segments and merges its slashes.
  • No revision syntax. A ref is looked up as exactly refs/heads/<ref>, then refs/tags/<ref>, or as a full hash. Short hashes, HEAD~1, @{1}, FETCH_HEAD and the like are all 404s, and libgit2 refuses any name that isn't a valid ref.
  • One name becomes a path: the repository's, which must be lowercase ASCII ([a-z0-9._-], not starting with . or -), as Forgejo stores them.
  • Nothing unchecked is echoed into a header. The only redirects are to a checked repository name and to a folder found in git, percent-encoded.
  • Symlinks are never followed. A symlink in a repository is a blob holding its target path, and that text is what's served.
  • The daemon is sandboxed by systemd: its own user, a read-only filesystem, no network, Unix sockets only, no capabilities. Its socket is reachable by nginx's group alone.
  • Pages get an origin of their own, on a different site from Forgejo, so no cookie passes between them.

Limits

  • A file is held in memory once while it's sent. libgit2 can stream only loose objects, and inflates a packed one whole. The daemon writes libgit2's buffer straight to the socket, without a copy of its own.
  • A file too big for memory fails gracefully. The unit caps the daemon's address space (LimitAS=), so an allocation past it fails instead of the kernel killing the daemon, and that request alone gets a 500. A big file only occupies its own thread.
  • Every repository in the folder is served, private ones included: Forgejo keeps that flag in its database.