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.
| 1 | # Configuration |
| 2 | |
| 3 | Vole is designed to work out of the box. The defaults are enough for a local |
| 4 | instance. Change them when you want Vole to listen on another address, limit what |
| 5 | people can push, or serve websites. |
| 6 | |
| 7 | There is no general control panel. One TOML file is the whole configuration. An |
| 8 | instance admin can see the effective values and the file they came from on the |
| 9 | Admin page's Configuration tab, and that view is read-only. |
| 10 | |
| 11 | The one exception is the customizable repository: `instance_repo` names a |
| 12 | private repository whose tree supplies the landing page's introduction, a |
| 13 | stylesheet, a favicon, and static assets. It is not written to `vole.toml`, |
| 14 | because the packaged service runs with `ProtectSystem=strict` and cannot write |
| 15 | its config file. See [Customizing the instance](#customizing-the-instance). |
| 16 | |
| 17 | ## A minimal config |
| 18 | |
| 19 | ```toml |
| 20 | data_dir = "./data" |
| 21 | http_addr = "127.0.0.1:8080" |
| 22 | ssh_addr = "127.0.0.1:2222" |
| 23 | # public_url = "https://forge.example.com" |
| 24 | ``` |
| 25 | |
| 26 | ## Which config Vole reads |
| 27 | |
| 28 | Local commands look for a config in this order: |
| 29 | |
| 30 | 1. `--config <path>` |
| 31 | 2. the `VOLE_CONFIG` environment variable |
| 32 | 3. `./vole.toml` |
| 33 | 4. `~/.vole/vole.toml` |
| 34 | |
| 35 | If none exists, Vole uses the built-in defaults with `data_dir = "./data"`. |
| 36 | This is why `vole admin init --dir ~/.vole` works from any directory: later |
| 37 | commands find `~/.vole/vole.toml` without repeating the path. For an instance |
| 38 | somewhere else, run commands from that directory, pass `--config`, or set |
| 39 | `VOLE_CONFIG`: |
| 40 | |
| 41 | ```sh |
| 42 | vole admin --config /srv/vole/vole.toml account list |
| 43 | export VOLE_CONFIG=/srv/vole/vole.toml |
| 44 | vole admin account list |
| 45 | ``` |
| 46 | |
| 47 | ## Settings |
| 48 | |
| 49 | | Setting | Default | What it does | |
| 50 | |---|---:|---| |
| 51 | | `data_dir` | `"./data"` | Where Vole keeps repositories, databases, stored files, and the audit log | |
| 52 | | `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 | |
| 53 | | `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 | |
| 54 | | `https_addr` | empty | Address for automatic HTTPS; setting it enables ACME | |
| 55 | | `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` | |
| 56 | | `ssh_max_connections` | `64` | Simultaneous connections on that listener; past it a connection is closed before the handshake rather than queued | |
| 57 | | `ssh_max_git_processes` | `16` | Git processes across every SSH connection; past it a command waits a short while, then is told to retry | |
| 58 | | `ssh_handshake_timeout_seconds` | `20` | How long a connection may take to finish the SSH handshake before it is dropped | |
| 59 | | `instance_name` | `"Vole"` | What the instance calls itself: the home page heading, every page's document title, and the brand link's accessible name | |
| 60 | | `instance_repo` | `"vole-instance"` | Repository whose tree customizes the instance; empty disables it | |
| 61 | | `public_url` | empty | Canonical browser address; must be HTTPS to enable passkey sign-in | |
| 62 | | `acme_email` | empty | Contact address given to the certificate authority | |
| 63 | | `acme_domains` | empty | Host names to obtain certificates for; defaults to the host in `public_url` | |
| 64 | | `acme_cache_dir` | `<data_dir>/acme` | Where the ACME account key and certificates are stored | |
| 65 | | `acme_directory_url` | Let's Encrypt production | ACME directory; use the staging URL or a private CA while testing | |
| 66 | | `dev_login` | empty | Development only: sign loopback browser requests in as this account; requires `dev_only` | |
| 67 | | `dev_only` | `false` | Acknowledge that this instance is a development instance; without it `dev_login` is refused | |
| 68 | | `open_registration` | `false` | Let anyone create an account by registering a passkey; requires `public_url` to be HTTPS | |
| 69 | | `open_registration_role` | `"none"` | Instance-wide role a self-registered account gets; applies to public repositories, `admin` is refused | |
| 70 | | `max_blob_bytes` | `104857600` (100 MiB) | Largest single file accepted in a push | |
| 71 | | `receive_max_bytes` | `1073741824` (1 GiB) | Largest whole push Vole will accept | |
| 72 | | `lfs_object_max_bytes` | `2147483648` (2 GiB) | Largest single LFS object | |
| 73 | | `lfs_warn_bytes` | `5242880` (5 MiB) | Warn when a large binary is committed outside LFS; `0` turns the warning off | |
| 74 | | `repo_max_refs` | `500` | Branches and tags in one repository; a push that would exceed it is refused | |
| 75 | | `repo_max_metadata_entries` | `2000` | Documents on the metadata branch in one repository: issues, requests, comments, approvals, and release records | |
| 76 | | `repo_max_metadata_bytes` | `33554432` (32 MiB) | Total size of those files | |
| 77 | | `repo_max_bytes` | `5368709120` (5 GiB) | Git objects in one repository; a repository already over it refuses further pushes | |
| 78 | | `repo_max_lfs_bytes` | `10737418240` (10 GiB) | LFS objects and run artifacts in one repository, checked on upload | |
| 79 | | `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 | |
| 80 | | `git_http` | `true` | Serve anonymous clone, fetch and LFS downloads over smart HTTP; off leaves SSH as the only transport | |
| 81 | | `git_http_max_concurrent` | `16` | Simultaneous anonymous reads on that transport; past it a clone is refused with `503` and `Retry-After` | |
| 82 | | `git_http_max_per_client` | `8` | Simultaneous reads from one address; `0` leaves only the concurrency bound | |
| 83 | | `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 | |
| 84 | | `git_http_timeout_seconds` | `600` | How long one clone may run, Git process included | |
| 85 | | `lease_seconds` | `60` | How long a runner may hold a job without checking in before it is given to someone else | |
| 86 | | `max_jobs_per_repo` | `0` | How many jobs one repository may run at once; `0` means no limit | |
| 87 | | `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](sites.md#same-site-risk) | |
| 88 | | `pages_ref` | `"pages"` | Branch served as a website | |
| 89 | | `pages_procs` | `4` | Number of Git reader processes per active site | |
| 90 | | `pages_cache_bytes` | `33554432` (32 MiB) | Memory used to cache site files | |
| 91 | |
| 92 | The two push limits work at different levels. `max_blob_bytes` checks each file in |
| 93 | a push. `receive_max_bytes` caps the size of the whole push and is enforced by Git |
| 94 | while it receives the data, before any branch is updated. |
| 95 | |
| 96 | ## Anonymous read limits |
| 97 | |
| 98 | Smart HTTP is the transport an unauthenticated visitor uses: `git clone`, |
| 99 | fetch, and the LFS downloads that share the same URLs. Pushes never arrive there; |
| 100 | they are SSH only. Because the reader has no account, the transport is bounded in |
| 101 | every dimension that costs the instance something. |
| 102 | |
| 103 | ```toml |
| 104 | git_http = true |
| 105 | git_http_max_concurrent = 16 |
| 106 | git_http_max_per_client = 8 |
| 107 | git_http_client_ip_header = "X-Forwarded-For" |
| 108 | git_http_timeout_seconds = 600 |
| 109 | ``` |
| 110 | |
| 111 | `git_http_max_concurrent` bounds simultaneous reads, and a clone past it is |
| 112 | refused with `503` and `Retry-After` rather than queued: a waiting queue is |
| 113 | another thing that grows with load. `git_http_max_per_client` keeps one address |
| 114 | from taking every slot, and `0` turns that rule off. |
| 115 | |
| 116 | Behind a proxy every connection appears to come from the proxy, so the |
| 117 | per-client rule needs the address the proxy saw. Name the header it sets: |
| 118 | |
| 119 | ```toml |
| 120 | git_http_client_ip_header = "X-Forwarded-For" |
| 121 | ``` |
| 122 | |
| 123 | Vole takes the last entry of that header, which is the one the nearest proxy |
| 124 | appended. Leave it empty when nothing sits in front of Vole: the connection's own |
| 125 | address cannot be forged, and a header no proxy sets can. |
| 126 | |
| 127 | `git_http = false` removes the endpoints entirely, which is the setting for an |
| 128 | instance whose repositories are all internal and none of which are cloned |
| 129 | anonymously. SSH pushes are unaffected. LFS downloads share the HTTP URLs, so they |
| 130 | go with it; `git lfs` will report a missing object rather than fetch from SSH. |
| 131 | |
| 132 | A release archive is served under the same limit: `git_http_timeout_seconds` |
| 133 | does not apply to the release page's tarball, which has its own concurrency bound. |
| 134 | |
| 135 | ## Customizing the instance |
| 136 | |
| 137 | `instance_repo` names a repository whose tree customizes the instance. The idea is |
| 138 | GitHub's `.github` repository: fixed paths read as data, not templates. |
| 139 | |
| 140 | ```toml |
| 141 | instance_repo = "vole-instance" # the default; empty disables customization |
| 142 | ``` |
| 143 | |
| 144 | An instance admin can scaffold it from **Admin → Configuration** (the Instance |
| 145 | repository panel), or create a repository with that name normally and push the |
| 146 | files. `vole admin init` creates it with the starter files when it is missing, |
| 147 | so a new instance starts with it. It is an ordinary repository: it appears in the |
| 148 | repository list, has the usual pages, history, issues, and change requests, and |
| 149 | its visibility is set in Settings. It is private by default, so only instance |
| 150 | admins see it until one of them publishes it. The files below are served to |
| 151 | visitors either way. |
| 152 | |
| 153 | | Path | What it does | |
| 154 | |---|---| |
| 155 | | `profile/README.md` | Landing page introduction; markdown, sanitized like an issue body | |
| 156 | | `profile/private/README.md` | Replaces it for signed-in viewers when present | |
| 157 | | `profile/custom.css` | Instance-wide styles, loaded last | |
| 158 | | `profile/themes/<id>.css` | One stylesheet per selectable theme | |
| 159 | | `profile/favicon.svg` | Replaces the favicon links | |
| 160 | | `assets/` | Served at `/-/static/custom/assets/`; CSS, images and fonts only | |
| 161 | |
| 162 | Other files are ignored, and assets are limited to CSS, images and fonts: a |
| 163 | same-origin script is not a theme. `profile/README.md` and every other file are |
| 164 | edited by pushing; the home page has no separate editor. A push is live within a |
| 165 | couple of seconds, and a load failure keeps the previous theme. |
| 166 | |
| 167 | The introduction is rendered and sanitized the same way an issue body is, and it |
| 168 | appears above the repository list. An empty `profile/README.md` removes the |
| 169 | card's body. The heading is `instance_name`, which is what the forge calls itself: |
| 170 | |
| 171 | ```toml |
| 172 | instance_name = "Acme Forge" |
| 173 | ``` |
| 174 | |
| 175 | The name is also the document title on every page (`Issues · Acme Forge`) and |
| 176 | the accessible name of the brand link ("Acme Forge home"), so a renamed instance |
| 177 | is renamed in the tab as well as in the heading. The brand mark in the top bar |
| 178 | stays Vole's emblem, and prose about the software itself, such as the status |
| 179 | page's "restart Vole", keeps the product name: the instance name describes the |
| 180 | organisation's forge, not the program serving it. |
| 181 | |
| 182 | ### Theming |
| 183 | |
| 184 | `profile/custom.css` is loaded after the built-in `vole.css`, `everforest.css`, |
| 185 | and `palettes.css`, and after any theme pack, so it can override every theme. |
| 186 | Override the design tokens rather than copying rules; the layout, contrast, and |
| 187 | responsive behaviour then stay intact. |
| 188 | |
| 189 | Vole ships the Everforest variants, Catppuccin Mocha and Latte, Nord, Solarized |
| 190 | Light, Rosé Pine Moon and Dawn, Kanagawa Wave, Dracula, Tokyo Night, and Ayu |
| 191 | Light. The Appearance picker lists them before the instance's own theme packs. |
| 192 | |
| 193 | #### Theme packs |
| 194 | |
| 195 | Account → Appearance lists the built-in Everforest variants plus every CSS file |
| 196 | in `profile/themes/`. A theme is self-describing: the file name is the id and |
| 197 | the label, and the rules are scoped to that id. `acme-ocean.css` is the |
| 198 | “Acme Ocean” theme; `acme-paper.css` is “Acme Paper”. |
| 199 | |
| 200 | ```css |
| 201 | /* profile/themes/acme-ocean.css */ |
| 202 | :root[data-theme="acme-ocean"] { |
| 203 | color-scheme: dark; |
| 204 | --bg: #0b1720; |
| 205 | --bg-panel: #102433; |
| 206 | --bg-raised: #163041; |
| 207 | --fg: #d7e8f0; |
| 208 | --fg-muted: #8fb0c0; |
| 209 | --fg-faint: #6f8f9f; |
| 210 | --accent: #38bdf8; |
| 211 | --accent-fg: #04121a; |
| 212 | --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); |
| 213 | } |
| 214 | ``` |
| 215 | |
| 216 | ```css |
| 217 | /* profile/themes/acme-paper.css */ |
| 218 | :root[data-theme="acme-paper"] { |
| 219 | color-scheme: light; |
| 220 | --bg: #fffdf7; |
| 221 | --bg-panel: #f8f3e8; |
| 222 | --bg-raised: #f1eadc; |
| 223 | --fg: #2b2a26; |
| 224 | --fg-muted: #6b675d; |
| 225 | --fg-faint: #8d887c; |
| 226 | --accent: #9a5b13; |
| 227 | --accent-fg: #fffdf7; |
| 228 | --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); |
| 229 | } |
| 230 | ``` |
| 231 | |
| 232 | The file name must use lowercase letters, digits, and dashes (at most 41 |
| 233 | characters), and cannot collide with a built-in value: `system`, `nord`, |
| 234 | `dracula`, `tokyo-night`, or a name starting with `everforest-`, `catppuccin-`, |
| 235 | `solarized-`, `rose-pine-`, `kanagawa-`, or `ayu-`. A file whose name breaks that |
| 236 | rule fails the snapshot load with a message on the Admin page; the previous theme |
| 237 | stays in use. A light theme must set `color-scheme: light` |
| 238 | and light surface and text tokens, or form controls and inherited colours stay |
| 239 | dark. |
| 240 | |
| 241 | Every theme file is linked on every page, so keep them small. The selection is a |
| 242 | browser preference, not an account setting: it lives in `localStorage`, and a |
| 243 | theme whose file is deleted falls back to System. |
| 244 | |
| 245 | #### Tokens |
| 246 | |
| 247 | Use `:root[data-theme]`, not plain `:root`. Everforest sets its tokens on |
| 248 | `:root[data-theme^="everforest-"]`, which is more specific than `:root`, so a |
| 249 | plain `:root` override can lose even though the custom file loads later. |
| 250 | |
| 251 | ```css |
| 252 | :root[data-theme] { |
| 253 | --accent: #c678dd; |
| 254 | --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); |
| 255 | --radius: 4px; |
| 256 | --radius-lg: 6px; |
| 257 | --page-max: 1200px; |
| 258 | } |
| 259 | ``` |
| 260 | |
| 261 | A value that has to differ between dark and light modes belongs in one of these |
| 262 | blocks. They match every Everforest variant: |
| 263 | |
| 264 | ```css |
| 265 | :root[data-theme^="everforest-dark"] { --bg: #101418; } |
| 266 | :root[data-theme^="everforest-light"] { --bg: #fffdf7; } |
| 267 | ``` |
| 268 | |
| 269 | The tokens, grouped by what they change: |
| 270 | |
| 271 | | Group | Tokens | |
| 272 | |---|---| |
| 273 | | surfaces | `--bg`, `--bg-panel`, `--bg-raised`, `--bg-hover`, `--bg-active`, `--border`, `--border-strong` | |
| 274 | | text | `--fg`, `--fg-muted`, `--fg-faint` | |
| 275 | | identity | `--accent`, `--accent-fg`, `--accent-soft` | |
| 276 | | status | `--good`, `--good-soft`, `--bad`, `--bad-soft`, `--warn`, `--warn-soft`, `--running`, `--merged`, `--merged-soft`, `--status-fg` | |
| 277 | | shape | `--radius`, `--radius-lg`, `--page-max`, `--page-gutter`, `--search-w` | |
| 278 | | type | `--font`, `--mono`, `--fs`, `--lh`. Leave `--font-scale` alone: it is the reader's text-size preset | |
| 279 | | syntax | `--syn-comment`, `--syn-punctuation`, `--syn-operator`, `--syn-number`, `--syn-string`, `--syn-keyword`, `--syn-function`, `--syn-type`, `--syn-variable`, `--syn-added`, `--syn-deleted` | |
| 280 | |
| 281 | If a token cannot express the change, these page-level classes cover most |
| 282 | re-skins: `.panel`, `.readme`, `.md`, `.repo-card`, `.repo-grid`, `.home-grid`, |
| 283 | `.home-rail`, `.repohead`, `.tabs`, `.chip`, `.label`, `.status`, |
| 284 | `.config-table`, `.diff`, `.blob`. They are not a public API: an upgrade may move |
| 285 | markup, so prefer a token when one exists. |
| 286 | |
| 287 | ## Public URL |
| 288 | |
| 289 | Set `public_url` when people reach Vole through a different address than |
| 290 | `http_addr`, for example through a proxy: |
| 291 | |
| 292 | ```toml |
| 293 | public_url = "https://forge.example.com" |
| 294 | ``` |
| 295 | |
| 296 | The CLI uses this to print a link to an issue, comment, or release it just |
| 297 | created. It is also the WebAuthn relying-party origin: browser sign-in is enabled |
| 298 | only when `public_url` is an `https://` URL. Vole does not change how it routes |
| 299 | requests based on this setting. |
| 300 | |
| 301 | The host name in `public_url` is also the import prefix a Go module path uses: |
| 302 | `public_url = "https://forge.example.com"` publishes a public repository as |
| 303 | `forge.example.com/<repository>`. See [Go modules](go-modules.md). |
| 304 | |
| 305 | It is also where generated clone and LFS download links point. That is a security |
| 306 | property, not a convenience: without it those links follow the request's `Host` |
| 307 | header, which is a value the browser's request carries and an attacker can choose. |
| 308 | Copying a clone URL out of a page and into a terminal is normal use, so the URL a |
| 309 | page shows should be the instance's, not the caller's. Set `public_url` on any |
| 310 | instance other than a loopback preview. |
| 311 | |
| 312 | The same host is what the SSH clone link shows, with the port from `ssh_addr`: |
| 313 | an instance listening on `0.0.0.0:2222` with `public_url = |
| 314 | "https://forge.example.com"` offers `ssh://git@forge.example.com:2222/demo.git`. |
| 315 | A bind address is not something a person can connect to, so it is never used as |
| 316 | the clone host when a name is configured. |
| 317 | |
| 318 | Forwarded headers (`X-Forwarded-Proto`, `X-Forwarded-For`) are believed only when |
| 319 | `git_http_client_ip_header` is set, because that is the instance's statement that |
| 320 | a proxy it trusts is rewriting them. Without it, a client that sets a forwarding |
| 321 | header is describing itself, and Vole ignores it. |
| 322 | |
| 323 | ## Automatic HTTPS with ACME |
| 324 | |
| 325 | Vole can obtain and renew its own TLS certificates, so a small public instance |
| 326 | does not need a proxy in front of it. Set `https_addr`, point the host name in |
| 327 | `public_url` at the server, and make both ports reachable: |
| 328 | |
| 329 | ```toml |
| 330 | http_addr = ":80" |
| 331 | https_addr = ":443" |
| 332 | public_url = "https://forge.example.com" |
| 333 | acme_email = "admin@example.com" |
| 334 | ``` |
| 335 | |
| 336 | `https_addr` turns it on, and `public_url` must then be an `https://` URL. Vole |
| 337 | serves the forge over TLS on that address. `http_addr` stops serving pages: it |
| 338 | answers ACME HTTP-01 challenges and redirects everything else to HTTPS. The |
| 339 | certificate host comes from `public_url`; to serve more than one name, list each |
| 340 | one in `acme_domains` (each host gets its own certificate): |
| 341 | |
| 342 | ```toml |
| 343 | acme_domains = ["forge.example.com", "git.example.com"] |
| 344 | ``` |
| 345 | |
| 346 | Certificates are obtained on first use and renewed in the background. Vole tries |
| 347 | tls-alpn-01 on port 443 and http-01 on port 80, so a network that blocks one can |
| 348 | still validate through the other. DNS must already point at the instance; ACME |
| 349 | cannot issue a name that does not resolve here. There is no wildcard support: |
| 350 | list concrete names, or keep a proxy for a wildcard certificate. |
| 351 | |
| 352 | State lives in `data/acme/` by default. It contains the ACME account key, so the |
| 353 | directory is created private; back it up with the other instance data, though |
| 354 | losing it only means registering again and asking for new certificates. |
| 355 | |
| 356 | While setting this up, use the staging directory so a mistake does not consume a |
| 357 | production rate limit: |
| 358 | |
| 359 | ```toml |
| 360 | acme_directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory" |
| 361 | ``` |
| 362 | |
| 363 | Ports below 1024 need `CAP_NET_BIND_SERVICE`. The packaged systemd unit can grant |
| 364 | it; see [Packages](packaging.md). An instance with `https_addr` empty behaves as |
| 365 | before and leaves TLS to a proxy. |
| 366 | |
| 367 | When `pages_domain` is also set, Vole obtains a certificate for a site host the |
| 368 | first time that host is requested, provided the repository exists and is |
| 369 | publicly readable. Site certificates therefore work with a wildcard DNS record |
| 370 | pointing at the instance, but they count against the certificate authority's |
| 371 | per-domain rate limit. |
| 372 | |
| 373 | ## Open registration |
| 374 | |
| 375 | By default only an instance admin can create an account. Set |
| 376 | `open_registration = true` to let anyone create one by registering a passkey: |
| 377 | |
| 378 | ```toml |
| 379 | public_url = "https://forge.example.com" |
| 380 | open_registration = true |
| 381 | open_registration_role = "contribute" |
| 382 | ``` |
| 383 | |
| 384 | The setting has no effect unless `public_url` is HTTPS. New accounts have no |
| 385 | repository-specific access, but `open_registration_role` gives them an |
| 386 | instance-wide role that applies to every public repository. `contribute` lets |
| 387 | them read, discuss, and push to their own `refs/heads/contrib/<name>/` namespace |
| 388 | without being added to each repository. It does not open private repositories; |
| 389 | `admin` is refused for self-registration. A registered account can add its own |
| 390 | SSH key from **Account**, so it can push. There is no email verification; |
| 391 | registration is a deliberate policy choice, not a safe default. |
| 392 | |
| 393 | A repository can still be published for anonymous readers with: |
| 394 | |
| 395 | ```sh |
| 396 | vole admin acl set-default-role demo read |
| 397 | ``` |
| 398 | |
| 399 | ## Development sign-in |
| 400 | |
| 401 | Browsers require HTTPS for passkeys, which makes a local `http://127.0.0.1:8080` |
| 402 | preview awkward to use for write testing. `dev_login` is an explicit escape hatch, |
| 403 | and `dev_only` is what makes it explicit: |
| 404 | |
| 405 | ```toml |
| 406 | http_addr = "127.0.0.1:8080" |
| 407 | dev_only = true |
| 408 | dev_login = "you" |
| 409 | ``` |
| 410 | |
| 411 | When set, a request from loopback is signed in as that account using a normal |
| 412 | server-side session. Vole refuses to start unless all of these hold: `dev_only` |
| 413 | is set, `http_addr` is a loopback address, there is no `https_addr`, and |
| 414 | `public_url` is unset or itself loopback. It logs a warning when the setting is |
| 415 | active. The session cookie is deliberately not named `__Host-` so a browser will |
| 416 | store it over plain HTTP. Never enable this on an instance other people can reach. |
| 417 | |
| 418 | The request check does not trust the `Host` header alone. A loopback listener |
| 419 | behind a proxy sees loopback as the peer address, so a development request must |
| 420 | also name the configured `http_addr` (same port, and either the same host or |
| 421 | another loopback name), and any request carrying `Forwarded`, |
| 422 | `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto` or `X-Real-IP` is |
| 423 | refused. A proxy that rewrites `Host` to `127.0.0.1` therefore gets no session. |
| 424 | |
| 425 | Signing out opts the browser out of automatic dev sign-in; visit `/-/dev/login` to |
| 426 | sign back in. Remove `dev_login` and restart to turn it off entirely. |
| 427 | |
| 428 | ## Refused configurations |
| 429 | |
| 430 | `vole serve` validates the configuration before it opens anything and refuses to |
| 431 | start on a combination that cannot be safe. It reports every problem at once, so |
| 432 | one restart is enough to fix the file. |
| 433 | |
| 434 | | Refused when | Why | |
| 435 | |---|---| |
| 436 | | `dev_login` without `dev_only` | A credential-free session must be a decision, not a copied line | |
| 437 | | `dev_login` with `https_addr` | That listener is reachable from the network | |
| 438 | | `dev_login` with a non-loopback `public_url` | A published instance is not a development instance | |
| 439 | | `dev_login` with a non-loopback `http_addr` | The session is only for the machine it runs on | |
| 440 | | `open_registration` without an HTTPS `public_url` | WebAuthn credentials are bound to an HTTPS origin | |
| 441 | | `open_registration_role` = `admin` | Self-registration cannot grant instance administration | |
| 442 | | `public_url` with a user, path, query or fragment | It is an origin that links are appended to | |
| 443 | | `pages_domain` that is not a bare DNS suffix | Site routing matches a visitor's `Host` against it | |
| 444 | | `receive_max_bytes` below `max_blob_bytes` | No push could ever contain a permitted file | |
| 445 | | `https_addr` with no `acme_domains` or `public_url` | There is no name to obtain a certificate for | |
| 446 | | `acme_email` that is not one address | The certificate authority has nobody to contact | |
| 447 | | `acme_directory_url` that is not HTTPS | Renewal would fail after the typo was forgotten | |
| 448 | | a negative size, lease or limit | It is never what the operator meant | |
| 449 | |
| 450 | ## Running behind a proxy |
| 451 | |
| 452 | If a proxy already terminates TLS, leave `https_addr` empty. Vole serves plain |
| 453 | HTTP on `http_addr`; keep the `Host` header intact, because Vole uses it to |
| 454 | decide which site a request belongs to. |
| 455 | |
| 456 | ```nginx |
| 457 | server { |
| 458 | listen 443 ssl; |
| 459 | server_name *.forge.example.com; |
| 460 | location / { |
| 461 | proxy_set_header Host $host; |
| 462 | proxy_set_header X-Forwarded-Proto $scheme; |
| 463 | proxy_pass http://127.0.0.1:8080; |
| 464 | } |
| 465 | } |
| 466 | ``` |
| 467 | |
| 468 | SSH is separate. Expose `ssh_addr` directly, or through a TCP proxy that keeps the |
| 469 | connection intact. |
| 470 | |
| 471 | ## What Vole stores |
| 472 | |
| 473 | | Path | What it is | |
| 474 | |---|---| |
| 475 | | `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. | |
| 476 | | `data/repos/` | Your Git repositories. | |
| 477 | | `data/index.db` | A rebuildable search index. | |
| 478 | | `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. | |
| 479 | | `data/lfs/` | Stored large files and build artifacts. | |
| 480 | | `data/logs/` | One file per CI attempt, streamed from the runner while the job runs. | |
| 481 | | `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](operations.md#audit-log). | |
| 482 | | `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. | |
| 483 | | `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. | |
| 484 | | `data/acme/` | The ACME account key and issued TLS certificates, when automatic HTTPS is enabled. | |
| 485 | |
| 486 | See [Operations](operations.md) for backup and recovery. |
| 487 |