Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Operations
A forge you own should be easy to back up and recover. Vole keeps the important state small: an account database, a handful of files, and ordinary Git repositories. These are the tasks that keep an instance healthy.
Audit log
data/audit.log records every privileged action — roster and key changes, access
rules, forced lock releases, forced pushes, release rewrites, admin commands,
sessions, and ordinary pushes — as one tab-separated line:
timestamp action actor subject detail
- One shape. Every writer goes through the same code, so a parser can split
on tabs.
actionis a lower-case dotted name (acl.update,push.force,session.create,admin);actoris the account or OS user,-when there is none;subjectis the repository, ref, or account the action was about. - Fields are escaped and truncated. A key comment, an LFS lock path, a repository name, or an admin argument cannot add a column or a line, and a field is capped at 4 KiB.
- Mode 0600, owner only. The file is created that way and tightened on every write, so an instance upgraded from an older version stops being world-readable the first time it writes. Startup refuses a log that is a symlink, a directory, a non-regular file, or owned by another account.
- Privileged changes fail closed. A roster, key, access-rule, session, forced
lock release, or admin command that cannot be recorded is refused, and the
command says so:
the audit log could not be written, so nothing was changed. A push that overrides a lock, an approval, a release, or history is refused unless its override can be written. There is no setting that turns this off, because an override is exactly what an attacker with config access would set. - Ordinary pushes and web writes are reported, not refused. Those changes
are already durable in Git; a failed write appears as
audit log write failedin the server log (or on the pusher’s terminal forpost-receive) rather than making a working push look rejected. - A broken log stops the instance.
vole serveandvole adminrefuse to start when the log cannot be opened for append, which turns a silent, long-lived gap into a startup failure with a path in it.
An instance admin can read the log in the browser at Admin → Audit log. The page shows one record per row, newest first, and pages back through the file. The Admin overview repeats the newest few, so a review can start there. Both are read-only views: they never edit, truncate, or rewrite a line, and a record the reader cannot parse is skipped rather than hidden from the rest of the page.
Ship the file off the host — a read-only mount, a log shipper, or a periodic
rsync of an append-only copy — and alert on the absence of expected lines
as well as on the presence of unexpected ones. Local append-only storage is not
tamper-evident: anyone who can write data/ can edit it, so keep an off-host copy
that the service account cannot reach.
Back up an instance
Back up the instance state:
vole admin account backup backup/users.db # accounts, keys, sessions, rules, locks, views
sqlite3 data/runs.db ".backup 'backup/runs.db'" # CI history, queue, and approvals
cp -a data/logs backup/ # CI attempt logs
cp data/ssh_host_ed25519_key backup/ # the SSH host key
sqlite3 data/secrets.db ".backup 'backup/secrets.db'" # sealed repository secrets
cp data/secrets.key backup/ # the key those secrets need
cp -a data/acme backup/ 2>/dev/null # TLS account key and certificates, if ACME is on
cp data/audit.log backup/ 2>/dev/null # history of admin actions
vole admin account backup takes a consistent copy, even while Vole is running. Copying
users.db by hand can miss recent changes that have not been written to the main
database file yet. Do the same for runs.db with SQLite’s backup command; copying it
by hand while the server is writing can miss WAL pages.
Keep data/secrets.key apart from the copies of data/. A backup that holds both
the database and its key is a backup of plaintext: the encryption protects a copy
that loses the key, not one that keeps it. Restoring data/secrets.db beside a
newly generated key reads nothing, and Vole says so rather than handing a job an
empty value.
If you lose the account database, you need to recreate accounts and re-enroll their passkeys. You do not lose repositories. The backup contains account metadata, public keys, and hashes of session and enrollment tokens; it does not contain a usable password or a plaintext session token. Treat it as private anyway.
Repositories are ordinary Git repositories under data/repos/. You can back them
up by copying or cloning. Issues, change requests, and releases travel with them
because they are stored in Git. CI history does not: it lives in data/runs.db and
must be backed up with it.
Restore an instance
- Restore
data/users.db,data/runs.db,data/logs/,data/ssh_host_ed25519_key,data/audit.log,data/secrets.dbwith thedata/secrets.keythat sealed it, anddata/acme/if the instance obtains its own TLS certificates. - Restore the repositories under
data/repos/and the object store underdata/lfs/if you keep CI artifacts. - Run
vole admin index rebuild. - Run
vole admin account revoke-sessions --allso sessions from the restored copy cannot outlive their cookies. - Start
vole serve.
Release notes for an upgrade: an older users.db is upgraded in place when it is
opened. Accounts used to have an internal id and an optional display label; both
are gone, and every key, passkey, enrollment link, session and admin grant is rebuilt
around the registered name. Access-rule entries and file locks in that database
are rewritten through the same mapping, and the retired user_names alias table
is dropped. Take a backup first if you might roll the binary back. Approval files
live in Git and are not rewritten; an approval written under an old account id
stops matching, so ask for a fresh approval.
Contributor branches moved from refs/heads/contrib/<account-id>/ to
refs/heads/contrib/<name>/. Old refs stay in the repository and can still be
fetched, but only a maintainer can push them; existing change requests still name
the same head commit. A contributor who wants to keep working pushes the same
commit under their new folder and opens a request from it.
Large files and build artifacts live in data/lfs/; CI logs live in data/logs/.
Back both directories up too if you need those files. vole admin gc treats files
reachable from a repository or a published artifact as things to keep; logs are
ordinary files and are not swept.
Restart the server
A clean stop (systemctl restart, or Ctrl-C on a development instance) stops
handing out new CI jobs and waits up to a minute for the jobs already leased to
report their result. A job that finishes in that window is stored and is not run
again. A job still running when the window ends loses its runner connection and
is requeued as another attempt once its lease expires. The wait is best effort;
start long builds well before a planned restart.
Clean up stored files
vole admin gc --dry-run # show what would be removed
vole admin gc # remove unused files
vole admin gc --grace 24h # keep unused files for a day
vole admin gc cleans the shared storage used by LFS and build artifacts. CI logs are
ordinary files under data/logs/ and are not part of the sweep. It
also repairs each repository’s links into that storage, so it helps when files
are missing or links are broken.
The default grace period is one hour. A recent upload that has not been pushed yet will not be removed.
You can run vole admin gc while people are pushing. There is no need to schedule it;
storage only grows when someone pushes files.
Rebuild indexes
data/index.db is generated by Vole and can be deleted safely:
vole admin index rebuild
data/runs.db holds the CI queue, finished-run records, and contributor-branch CI
approvals. It is a source of truth, not a rebuildable index: deleting it loses CI
history as well as attempts that were in progress. Back it up with the rest of the
instance. vole admin index rebuild rebuilds only the repository and issue index;
it cannot restore CI history.
Security notes
- Two long-term secrets are stored: the SSH host key, and the key that seals
repository secrets. Keep both readable only by the Vole user (
0600). Browser session and enrollment tokens are stored as hashes. - Repository secrets are encrypted at rest, which protects a copy of
data/that leaves the host. It does not protect a host somebody already owns: file modes are the boundary, and the default key location is beside the database. Anything that can read the data directory can read the values. - Vole creates its own secrets at
0600and its data directory at0700: the roster (users.db), the audit log, the SSH host key and the ACME account directory. It also tightens an existing instance on startup, and logs the paths whose mode it changed, so an upgrade from an earlier version cannot leave a private key world-readable. If a mode cannot be changed, startup fails rather than serving keys it cannot protect. A site directory that is deliberately shared with a backup group is fine at0750: group access is left alone, world access is not. - Nothing Vole writes is signed: not pushes, not approvals, not CI results. Vole relies on the SSH key at the connection, the checks before a push is accepted, and the audit log.
- The web interface is read-only for anonymous visitors. Passkey sign-in enables
issue, comment, and approval writes; HTTP Git clone remains read-only. There is
no password store. Restoring an old
users.dbshould be followed byvole admin account revoke-sessions --all. The landing page introduction is not an exception: it isprofile/README.mdin the instance repository (instance_repo), edited by pushing like every other customization. - Account names are permanent. They name contributor branches, so an upgrade from a build where names could change keeps each account’s current name and forgets the former ones.
- Open registration creates accounts without email verification. Leave
open_registrationoff unless the instance is meant to accept public contributions. - Remote administration uses the same SSH listener and key. Only active instance admins can run it, and every admin command is audited.
- Private repositories return
404on the web and over HTTP, so visitors cannot confirm that they exist. - Page-view metrics are daily totals keyed by account, plus one anonymous total. They store no path, address, referrer, or user agent; the admin-only status page uses them only to show how much the instance is read.
- Vole can obtain and renew TLS certificates itself when
https_addris set; otherwise put it behind a proxy that terminates TLS and keeps theHostheader, so site routing still works. See Configuration § Automatic HTTPS with ACME.
Production baseline
A public instance is a web server, a Git server and a policy engine in one process. The settings below are the ones that matter on the day it is exposed; the linked sections explain each in full.
- Web behind TLS, on loopback. Leave
https_addrempty, bindhttp_addr = "127.0.0.1:8080", and put a proxy in front that terminates TLS and passes theHostheader through unchanged. Alternatively sethttps_addrwithacme_emailandacme_domainsand let Vole do it. Setpublic_urlto the canonicalhttps://address so links and passkeys agree with it. When the request arrives over TLS, orpublic_urlishttps://, Vole sends HSTS on every response, not only on the pages that use passkeys. See Configuration. - Compression is already here. Forge pages, hosted sites, the stylesheet,
scripts and source blobs are gzipped when the client asks for it, so a proxy in
front should not compress them again: double compression costs CPU and hides
the
ETagrevalidation that keeps the shared scripts and each Prism grammar to one download per browser. Archives, LFS objects, Git responses and ranged downloads are passed through untouched. - Shared caches. If a caching proxy or CDN sits in front of the instance, it
may keep public pages, static assets, and artifacts from public repositories.
Private pages and downloads are marked
privateorno-store, so a cache that honours those directives will not serve them to an anonymous reader. Do not strip or rewrite those headers at the proxy. - SSH firewalled. The SSH listener is the only write transport and the only
place an account is authenticated. Expose it to the people who push, not to the
internet at large, and leave it on a port other than 22 unless you also run
sshdelsewhere. - No development shortcuts.
dev_onlyanddev_loginare refused together with a public address, and an instance that needs neither should set neither. The same goes foropen_registration, which is off by default. - Quotas. Every repository is bounded, so one contributor or maintainer cannot
make every page slow or fill the disk:
repo_max_refs(500),repo_max_metadata_entries(2000) andrepo_max_metadata_bytes(32 MiB) of documents on the metadata branch (issues, requests, comments, approvals, and release records),repo_max_bytes(5 GiB) of Git objects, andrepo_max_lfs_bytes(10 GiB) of LFS objects and artifacts. The checks run in pre-receive, so a refused push moves no refs, and the object bound is enforced on upload. When one bites, the message names it: raise it invole.toml, delete the branches or metadata that are not wanted, or runvole admin gcto drop unreachable objects. Per-account browser writes are budgeted for the untrusted (contribute) role too (contributor_writes_per_hour, 300); a person discussing never reaches it, and maintainers and admins are trusted roles that are not budgeted at all. - Conservative limits. The defaults are chosen for a small instance:
git_http_max_concurrent = 16,git_http_max_per_client = 8,ssh_max_connections = 64,ssh_max_git_processes = 16,ssh_handshake_timeout_seconds = 20,max_blob_bytes = 100 MiB,receive_max_bytes = 1 GiB, and a 600 s budget for one clone. Raise them with measurements, not with optimism; the read path is anonymous, so a limit that is too low is a slow clone and a limit that is missing is an outage. - Bounded sign-in. The passkey ceremonies are the only anonymous endpoints
that cost the instance work, so they are rate-limited per client address
(
20begins a minute), per account name (10), and for registrations (5), and the in-flight ceremony map is capped at4096entries with a five-minute expiry. A refused client gets429withRetry-After. Behind a proxy, setgit_http_client_ip_headerso the budgets follow the real address. Invite tokens live in the URL fragment, so they never reach an access log; links in the old/-/invite/<token>shape are redirected into it. See Accounts § Browser sign-in. - Bounded before authentication. The SSH listener refuses connections past
ssh_max_connectionsbefore the handshake and drops a connection that has not finished its handshake inssh_handshake_timeout_seconds, so an anonymous client cannot hold a file descriptor by opening a socket and saying nothing. After the handshake one connection may hold eight session channels, and the git commands they run sharessh_max_git_processes; an exec line longer than 4 KiB and a runner request line longer than 1 MiB are refused. Address-based backoff for repeated bad keys is a quota concern, not a transport bound, so it belongs with the abuse controls rather than here. - Audited administration. Remote administration uses the same SSH listener and is restricted to instance admins. Ship the audit log off the host: local append-only is not tamper-evident. See Audit log and Architecture § Trust model.
- Backups you have restored once.
users.dbis not derived and not rebuildable; everything else either is, or can be re-pushed. Test the restore, not the backup. See Back up an instance. - The service unit. The packaged unit drops all capabilities, sets
UMask=0027, and hides other processes and devices. If you serve HTTPS or SSH on a port below 1024, grantCAP_NET_BIND_SERVICEwith a drop-in rather than running the service as root. See Packaging.