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.
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:
--config <path>- the
VOLE_CONFIGenvironment variable ./vole.toml~/.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.