Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
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.

Code

396 commit(s) on main

Turn off the disk reserve for test-spawned runners Keenan Nemetz · 35m ago c284cb2c
source preview history blame raw 24.2 KiB

Configuration

Vole is designed to work out of the box. The defaults are enough for a local instance. Change them when you want Vole to listen on another address, limit what people can push, or serve websites.

There is no general control panel. One TOML file is the whole configuration. An instance admin can see the effective values and the file they came from on the Admin page’s Configuration tab, and that view is read-only.

The one exception is the customizable repository: instance_repo names a private repository whose tree supplies the landing page’s introduction, a stylesheet, a favicon, and static assets. It is not written to vole.toml, because the packaged service runs with ProtectSystem=strict and cannot write its config file. See Customizing the instance.

A minimal config

data_dir = "./data"
http_addr = "127.0.0.1:8080"
ssh_addr = "127.0.0.1:2222"
# public_url = "https://forge.example.com"

Which config Vole reads

Local commands look for a config in this order:

  1. --config <path>
  2. the VOLE_CONFIG environment variable
  3. ./vole.toml
  4. ~/.vole/vole.toml

If none exists, Vole uses the built-in defaults with data_dir = "./data". This is why vole admin init --dir ~/.vole works from any directory: later commands find ~/.vole/vole.toml without repeating the path. For an instance somewhere else, run commands from that directory, pass --config, or set VOLE_CONFIG:

vole admin --config /srv/vole/vole.toml account list
export VOLE_CONFIG=/srv/vole/vole.toml
vole admin account list

Settings

Setting Default What it does
data_dir "./data" Where Vole keeps repositories, databases, stored files, and the audit log
secrets_key <data_dir>/secrets.key The file holding the key that seals each repository’s secrets. Point it somewhere a backup of data_dir does not reach, so a copy of the database is a copy of ciphertext
http_addr "127.0.0.1:8080" Address for the web interface and anonymous Git clone; with https_addr, the ACME challenge and redirect address
https_addr empty Address for automatic HTTPS; setting it enables ACME
ssh_addr "127.0.0.1:2222" Address for SSH, the authenticated Git transport; its port is also the one the clone link names, while the clone host comes from public_url
ssh_max_connections 64 Simultaneous connections on that listener; past it a connection is closed before the handshake rather than queued
ssh_max_git_processes 16 Git processes across every SSH connection; past it a command waits a short while, then is told to retry
ssh_handshake_timeout_seconds 20 How long a connection may take to finish the SSH handshake before it is dropped
instance_name "Vole" What the instance calls itself: the home page heading, every page’s document title, and the brand link’s accessible name
instance_repo "vole-instance" Repository whose tree customizes the instance; empty disables it
public_url empty Canonical browser address; must be HTTPS to enable passkey sign-in
acme_email empty Contact address given to the certificate authority
acme_domains empty Host names to obtain certificates for; defaults to the host in public_url
acme_cache_dir <data_dir>/acme Where the ACME account key and certificates are stored
acme_directory_url Let’s Encrypt production ACME directory; use the staging URL or a private CA while testing
dev_login empty Development only: sign loopback browser requests in as this account; requires dev_only
dev_only false Acknowledge that this instance is a development instance; without it dev_login is refused
open_registration false Let anyone create an account by registering a passkey; requires public_url to be HTTPS
open_registration_role "none" Instance-wide role a self-registered account gets; applies to public repositories, admin is refused
max_blob_bytes 104857600 (100 MiB) Largest single file accepted in a push
receive_max_bytes 1073741824 (1 GiB) Largest whole push Vole will accept
lfs_object_max_bytes 2147483648 (2 GiB) Largest single LFS object
lfs_warn_bytes 5242880 (5 MiB) Warn when a large binary is committed outside LFS; 0 turns the warning off
repo_max_refs 500 Branches and tags in one repository; a push that would exceed it is refused
repo_max_metadata_entries 2000 Documents on the metadata branch in one repository: issues, requests, comments, approvals, and release records
repo_max_metadata_bytes 33554432 (32 MiB) Total size of those files
repo_max_bytes 5368709120 (5 GiB) Git objects in one repository; a repository already over it refuses further pushes
repo_max_lfs_bytes 10737418240 (10 GiB) LFS objects and run artifacts in one repository, checked on upload
contributor_writes_per_hour 300 Browser writes an untrusted (contribute) account may make in an hour: issues, comments, requests, labels, state changes. Maintainers and admins are trusted and are not budgeted
git_http true Serve anonymous clone, fetch and LFS downloads over smart HTTP; off leaves SSH as the only transport
git_http_max_concurrent 16 Simultaneous anonymous reads on that transport; past it a clone is refused with 503 and Retry-After
git_http_max_per_client 8 Simultaneous reads from one address; 0 leaves only the concurrency bound
git_http_client_ip_header empty Header a trusted proxy sets with the client address, e.g. "X-Forwarded-For". The same header is what the sign-in rate limits key on
git_http_timeout_seconds 600 How long one clone may run, Git process included
lease_seconds 60 How long a runner may hold a job without checking in before it is given to someone else
max_jobs_per_repo 0 How many jobs one repository may run at once; 0 means no limit
pages_domain empty Domain suffix for websites, as in <repo>.<pages_domain>; empty disables sites. Use a different registrable domain from the forge when sites are untrusted — see Sites
pages_ref "pages" Branch served as a website
pages_procs 4 Number of Git reader processes per active site
pages_cache_bytes 33554432 (32 MiB) Memory used to cache site files

The two push limits work at different levels. max_blob_bytes checks each file in a push. receive_max_bytes caps the size of the whole push and is enforced by Git while it receives the data, before any branch is updated.

Anonymous read limits

Smart HTTP is the transport an unauthenticated visitor uses: git clone, fetch, and the LFS downloads that share the same URLs. Pushes never arrive there; they are SSH only. Because the reader has no account, the transport is bounded in every dimension that costs the instance something.

git_http = true
git_http_max_concurrent = 16
git_http_max_per_client = 8
git_http_client_ip_header = "X-Forwarded-For"
git_http_timeout_seconds = 600

git_http_max_concurrent bounds simultaneous reads, and a clone past it is refused with 503 and Retry-After rather than queued: a waiting queue is another thing that grows with load. git_http_max_per_client keeps one address from taking every slot, and 0 turns that rule off.

Behind a proxy every connection appears to come from the proxy, so the per-client rule needs the address the proxy saw. Name the header it sets:

git_http_client_ip_header = "X-Forwarded-For"

Vole takes the last entry of that header, which is the one the nearest proxy appended. Leave it empty when nothing sits in front of Vole: the connection’s own address cannot be forged, and a header no proxy sets can.

git_http = false removes the endpoints entirely, which is the setting for an instance whose repositories are all internal and none of which are cloned anonymously. SSH pushes are unaffected. LFS downloads share the HTTP URLs, so they go with it; git lfs will report a missing object rather than fetch from SSH.

A release archive is served under the same limit: git_http_timeout_seconds does not apply to the release page’s tarball, which has its own concurrency bound.

Customizing the instance

instance_repo names a repository whose tree customizes the instance. The idea is GitHub’s .github repository: fixed paths read as data, not templates.

instance_repo = "vole-instance"   # the default; empty disables customization

An instance admin can scaffold it from Admin → Configuration (the Instance repository panel), or create a repository with that name normally and push the files. vole admin init creates it with the starter files when it is missing, so a new instance starts with it. It is an ordinary repository: it appears in the repository list, has the usual pages, history, issues, and change requests, and its visibility is set in Settings. It is private by default, so only instance admins see it until one of them publishes it. The files below are served to visitors either way.

Path What it does
profile/README.md Landing page introduction; markdown, sanitized like an issue body
profile/private/README.md Replaces it for signed-in viewers when present
profile/custom.css Instance-wide styles, loaded last
profile/themes/<id>.css One stylesheet per selectable theme
profile/favicon.svg Replaces the favicon links
assets/ Served at /-/static/custom/assets/; CSS, images and fonts only

Other files are ignored, and assets are limited to CSS, images and fonts: a same-origin script is not a theme. profile/README.md and every other file are edited by pushing; the home page has no separate editor. A push is live within a couple of seconds, and a load failure keeps the previous theme.

The introduction is rendered and sanitized the same way an issue body is, and it appears above the repository list. An empty profile/README.md removes the card’s body. The heading is instance_name, which is what the forge calls itself:

instance_name = "Acme Forge"

The name is also the document title on every page (Issues · Acme Forge) and the accessible name of the brand link (“Acme Forge home”), so a renamed instance is renamed in the tab as well as in the heading. The brand mark in the top bar stays Vole’s emblem, and prose about the software itself, such as the status page’s “restart Vole”, keeps the product name: the instance name describes the organisation’s forge, not the program serving it.

Theming

profile/custom.css is loaded after the built-in vole.css, everforest.css, and palettes.css, and after any theme pack, so it can override every theme. Override the design tokens rather than copying rules; the layout, contrast, and responsive behaviour then stay intact.

Vole ships the Everforest variants, Catppuccin Mocha and Latte, Nord, Solarized Light, Rosé Pine Moon and Dawn, Kanagawa Wave, Dracula, Tokyo Night, and Ayu Light. The Appearance picker lists them before the instance’s own theme packs.

Theme packs

Account → Appearance lists the built-in Everforest variants plus every CSS file in profile/themes/. A theme is self-describing: the file name is the id and the label, and the rules are scoped to that id. acme-ocean.css is the “Acme Ocean” theme; acme-paper.css is “Acme Paper”.

/* profile/themes/acme-ocean.css */
:root[data-theme="acme-ocean"] {
  color-scheme: dark;
  --bg: #0b1720;
  --bg-panel: #102433;
  --bg-raised: #163041;
  --fg: #d7e8f0;
  --fg-muted: #8fb0c0;
  --fg-faint: #6f8f9f;
  --accent: #38bdf8;
  --accent-fg: #04121a;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
}
/* profile/themes/acme-paper.css */
:root[data-theme="acme-paper"] {
  color-scheme: light;
  --bg: #fffdf7;
  --bg-panel: #f8f3e8;
  --bg-raised: #f1eadc;
  --fg: #2b2a26;
  --fg-muted: #6b675d;
  --fg-faint: #8d887c;
  --accent: #9a5b13;
  --accent-fg: #fffdf7;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
}

The file name must use lowercase letters, digits, and dashes (at most 41 characters), and cannot collide with a built-in value: system, nord, dracula, tokyo-night, or a name starting with everforest-, catppuccin-, solarized-, rose-pine-, kanagawa-, or ayu-. A file whose name breaks that rule fails the snapshot load with a message on the Admin page; the previous theme stays in use. A light theme must set color-scheme: light and light surface and text tokens, or form controls and inherited colours stay dark.

Every theme file is linked on every page, so keep them small. The selection is a browser preference, not an account setting: it lives in localStorage, and a theme whose file is deleted falls back to System.

Tokens

Use :root[data-theme], not plain :root. Everforest sets its tokens on :root[data-theme^="everforest-"], which is more specific than :root, so a plain :root override can lose even though the custom file loads later.

:root[data-theme] {
  --accent: #c678dd;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
  --radius: 4px;
  --radius-lg: 6px;
  --page-max: 1200px;
}

A value that has to differ between dark and light modes belongs in one of these blocks. They match every Everforest variant:

:root[data-theme^="everforest-dark"]  { --bg: #101418; }
:root[data-theme^="everforest-light"] { --bg: #fffdf7; }

The tokens, grouped by what they change:

Group Tokens
surfaces --bg, --bg-panel, --bg-raised, --bg-hover, --bg-active, --border, --border-strong
text --fg, --fg-muted, --fg-faint
identity --accent, --accent-fg, --accent-soft
status --good, --good-soft, --bad, --bad-soft, --warn, --warn-soft, --running, --merged, --merged-soft, --status-fg
shape --radius, --radius-lg, --page-max, --page-gutter, --search-w
type --font, --mono, --fs, --lh. Leave --font-scale alone: it is the reader’s text-size preset
syntax --syn-comment, --syn-punctuation, --syn-operator, --syn-number, --syn-string, --syn-keyword, --syn-function, --syn-type, --syn-variable, --syn-added, --syn-deleted

If a token cannot express the change, these page-level classes cover most re-skins: .panel, .readme, .md, .repo-card, .repo-grid, .home-grid, .home-rail, .repohead, .tabs, .chip, .label, .status, .config-table, .diff, .blob. They are not a public API: an upgrade may move markup, so prefer a token when one exists.

Public URL

Set public_url when people reach Vole through a different address than http_addr, for example through a proxy:

public_url = "https://forge.example.com"

The CLI uses this to print a link to an issue, comment, or release it just created. It is also the WebAuthn relying-party origin: browser sign-in is enabled only when public_url is an https:// URL. Vole does not change how it routes requests based on this setting.

The host name in public_url is also the import prefix a Go module path uses: public_url = "https://forge.example.com" publishes a public repository as forge.example.com/<repository>. See Go modules.

It is also where generated clone and LFS download links point. That is a security property, not a convenience: without it those links follow the request’s Host header, which is a value the browser’s request carries and an attacker can choose. Copying a clone URL out of a page and into a terminal is normal use, so the URL a page shows should be the instance’s, not the caller’s. Set public_url on any instance other than a loopback preview.

The same host is what the SSH clone link shows, with the port from ssh_addr: an instance listening on 0.0.0.0:2222 with public_url = "https://forge.example.com" offers ssh://git@forge.example.com:2222/demo.git. A bind address is not something a person can connect to, so it is never used as the clone host when a name is configured.

Forwarded headers (X-Forwarded-Proto, X-Forwarded-For) are believed only when git_http_client_ip_header is set, because that is the instance’s statement that a proxy it trusts is rewriting them. Without it, a client that sets a forwarding header is describing itself, and Vole ignores it.

Automatic HTTPS with ACME

Vole can obtain and renew its own TLS certificates, so a small public instance does not need a proxy in front of it. Set https_addr, point the host name in public_url at the server, and make both ports reachable:

http_addr  = ":80"
https_addr = ":443"
public_url = "https://forge.example.com"
acme_email = "admin@example.com"

https_addr turns it on, and public_url must then be an https:// URL. Vole serves the forge over TLS on that address. http_addr stops serving pages: it answers ACME HTTP-01 challenges and redirects everything else to HTTPS. The certificate host comes from public_url; to serve more than one name, list each one in acme_domains (each host gets its own certificate):

acme_domains = ["forge.example.com", "git.example.com"]

Certificates are obtained on first use and renewed in the background. Vole tries tls-alpn-01 on port 443 and http-01 on port 80, so a network that blocks one can still validate through the other. DNS must already point at the instance; ACME cannot issue a name that does not resolve here. There is no wildcard support: list concrete names, or keep a proxy for a wildcard certificate.

State lives in data/acme/ by default. It contains the ACME account key, so the directory is created private; back it up with the other instance data, though losing it only means registering again and asking for new certificates.

While setting this up, use the staging directory so a mistake does not consume a production rate limit:

acme_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"

Ports below 1024 need CAP_NET_BIND_SERVICE. The packaged systemd unit can grant it; see Packages. An instance with https_addr empty behaves as before and leaves TLS to a proxy.

When pages_domain is also set, Vole obtains a certificate for a site host the first time that host is requested, provided the repository exists and is publicly readable. Site certificates therefore work with a wildcard DNS record pointing at the instance, but they count against the certificate authority’s per-domain rate limit.

Open registration

By default only an instance admin can create an account. Set open_registration = true to let anyone create one by registering a passkey:

public_url = "https://forge.example.com"
open_registration = true
open_registration_role = "contribute"

The setting has no effect unless public_url is HTTPS. New accounts have no repository-specific access, but open_registration_role gives them an instance-wide role that applies to every public repository. contribute lets them read, discuss, and push to their own refs/heads/contrib/<name>/ namespace without being added to each repository. It does not open private repositories; admin is refused for self-registration. A registered account can add its own SSH key from Account, so it can push. There is no email verification; registration is a deliberate policy choice, not a safe default.

A repository can still be published for anonymous readers with:

vole admin acl set-default-role demo read

Development sign-in

Browsers require HTTPS for passkeys, which makes a local http://127.0.0.1:8080 preview awkward to use for write testing. dev_login is an explicit escape hatch, and dev_only is what makes it explicit:

http_addr = "127.0.0.1:8080"
dev_only = true
dev_login = "you"

When set, a request from loopback is signed in as that account using a normal server-side session. Vole refuses to start unless all of these hold: dev_only is set, http_addr is a loopback address, there is no https_addr, and public_url is unset or itself loopback. It logs a warning when the setting is active. The session cookie is deliberately not named __Host- so a browser will store it over plain HTTP. Never enable this on an instance other people can reach.

The request check does not trust the Host header alone. A loopback listener behind a proxy sees loopback as the peer address, so a development request must also name the configured http_addr (same port, and either the same host or another loopback name), and any request carrying Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto or X-Real-IP is refused. A proxy that rewrites Host to 127.0.0.1 therefore gets no session.

Signing out opts the browser out of automatic dev sign-in; visit /-/dev/login to sign back in. Remove dev_login and restart to turn it off entirely.

Refused configurations

vole serve validates the configuration before it opens anything and refuses to start on a combination that cannot be safe. It reports every problem at once, so one restart is enough to fix the file.

Refused when Why
dev_login without dev_only A credential-free session must be a decision, not a copied line
dev_login with https_addr That listener is reachable from the network
dev_login with a non-loopback public_url A published instance is not a development instance
dev_login with a non-loopback http_addr The session is only for the machine it runs on
open_registration without an HTTPS public_url WebAuthn credentials are bound to an HTTPS origin
open_registration_role = admin Self-registration cannot grant instance administration
public_url with a user, path, query or fragment It is an origin that links are appended to
pages_domain that is not a bare DNS suffix Site routing matches a visitor’s Host against it
receive_max_bytes below max_blob_bytes No push could ever contain a permitted file
https_addr with no acme_domains or public_url There is no name to obtain a certificate for
acme_email that is not one address The certificate authority has nobody to contact
acme_directory_url that is not HTTPS Renewal would fail after the typo was forgotten
a negative size, lease or limit It is never what the operator meant

Running behind a proxy

If a proxy already terminates TLS, leave https_addr empty. Vole serves plain HTTP on http_addr; keep the Host header intact, because Vole uses it to decide which site a request belongs to.

server {
  listen 443 ssl;
  server_name *.forge.example.com;
  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://127.0.0.1:8080;
  }
}

SSH is separate. Expose ssh_addr directly, or through a TCP proxy that keeps the connection intact.

What Vole stores

Path What it is
data/users.db Accounts, names, instance roles, SSH keys, passkeys, enrollment links, browser sessions, access rules, locks, and daily page-view counters. This is the main account database. Session and enrollment-link tokens are stored only as hashes.
data/repos/ Your Git repositories.
data/index.db A rebuildable search index.
data/runs.db The CI queue, finished-run records and steps, artifact pointers, and contributor-branch CI approvals. It is a source of truth: back it up. Deleting it loses CI history and pending work.
data/lfs/ Stored large files and build artifacts.
data/logs/ One file per CI attempt, streamed from the runner while the job runs.
data/audit.log A record of privileged actions: one tab-separated line per action, timestamp, action, actor, subject, detail. Created and kept at mode 0600. See Audit log.
data/secrets.db, data/secrets.key Each repository’s secrets, sealed with AES-256-GCM under the key. Losing the key loses every stored value, and restoring the database beside a newly generated key reads nothing.
data/ssh_host_ed25519_key The SSH host key. It is the instance’s long-term secret; browser credentials are public keys and session tokens are hashed.
data/acme/ The ACME account key and issued TLS certificates, when automatic HTTPS is enabled.

See Operations for backup and recovery.