Configuring spinney
All settings live in spinney.toml in this folder. Edit it,
check it, then apply it:
$EDITOR config/spinney.toml
./spinney -check # validate and show the resulting settings
sudo ./install.sh --domain git.example.com # install or update the server
install.sh copies this file to /etc/spinney/spinney.toml (keeping the
previous version as spinney.toml.bak) and restarts spinney. This folder is
the place to make changes: edits made directly in /etc/spinney are
overwritten the next time the installer runs.
How spinney finds the file
-config path/to/file.toml, if given.- Otherwise
config/spinney.toml, relative to the directory spinney is started from. - Otherwise built-in defaults.
The installed service always runs with
-config /etc/spinney/spinney.toml.
Command-line flags (-listen, -root, -depth, -title, -clone-http,
-git) override the file, which is handy for a quick test:
./spinney -root ~/src -listen :9000
Checking the file
./spinney -check loads the file exactly as the server would and prints the
result, without starting anything:
config: config/spinney.toml
listen: 127.0.0.1:8080
root: /srv/git
depth: 3
title: git
header: git
clone.http:
Misspelled or unknown keys are an error rather than being ignored:
config/spinney.toml: unknown keys: titel
Settings
| key | default | meaning |
|---|---|---|
listen |
":8080" |
Address and port to listen on. 127.0.0.1:8080 only accepts local connections (use this behind Caddy); :8080 accepts them from anywhere. |
root |
"." |
Folder that holds the repositories. Relative paths are resolved against the config file's folder. |
depth |
3 |
How many folders deep under root to look for repositories. |
title |
"spinney" |
Site name in the browser tab and footer, and in the page header unless header is set. |
header |
the title |
Site name in the page header, when it should differ from the browser tab's. |
description |
"" |
Tagline under the title on the index page. Markdown, so it can hold links: "Personal git server for [Alice](https://example.com)". Unsafe HTML is removed, as on the about page. |
git |
"git" |
Path to the git program, if it isn't on PATH. |
about |
none | Markdown file shown under an about tab next to index (like cgit's root-readme). Starts as about.md in this folder. Remove the setting to drop the tab. |
favicon |
built-in icon | The browser tab icon: an .svg, .png or .ico file. |
owner |
none | Owner shown on the index for repositories at the top of root. One in a folder is owned by that folder, so torvalds/linux.git shows torvalds; [repos."name"] owner overrides both. A folder named like owner (alice/… with owner = "alice") is listed right after the top-level repositories, before the other folders. |
tree_commits |
false |
true to show, on tree pages, the last commit that changed each file or folder (its subject and age), like GitHub's file list. It costs one git log per tree page; on a long history, entries untouched in the last 5000 commits of that folder are shown without one. The subject is hidden on narrow screens. |
about and favicon are paths relative to this file and are checked when
spinney starts (they must exist, have one of the listed extensions, and be at
most 1 MiB). Keep them in this folder: the installer copies them next to the
installed config. Like the config, edit them here and re-run the installer;
the about page is read on every request, so changes show up without a
restart. Links in it like [myproject](myproject) lead to that repository. While the about page is on,
a repository named about can't be reached at /about.
[clone]
| key | default | meaning |
|---|---|---|
http |
address of the request | Base URL shown for git clone, e.g. "https://git.example.com". Set it when spinney is behind a proxy that changes the host name. |
Cloning is read-only over HTTP. Pushing goes over SSH, outside spinney.
[activity]
An activity tab next to index, like a GitHub profile: the past year of commits as a graph of days (darker on busier days), the repositories they went to, and the newest commits and tags. Mirrors never count: they are other people's work. Hidden repositories don't either.
| key | default | meaning |
|---|---|---|
enabled |
false |
true to show the tab, at /activity. |
emails |
everyone | Author emails whose commits count, e.g. ["you@example.com", "123+you@users.noreply.github.com"]. Leave it out on a server where every commit is yours. |
[activity]
enabled = true
emails = ["you@example.com"]
A years list next to the graph shows any calendar year with commits
instead (/activity?year=2025), and the commits and tags are listed 10 to a
page. Commits on every branch count, on the day they were written (UTC), as
on GitHub. The repositories are read again at most once a minute. While the
tab is on, a repository named activity can't be reached at /activity.
[theme]
The site's colours and fonts. Nothing else of the look can be changed: the values are checked to be theme names, colours and font names, so a theme can't add other CSS. customisation.md walks through picking and making themes, with tips and previews.
| key | default | meaning |
|---|---|---|
light |
"default" |
The colour theme for visitors whose system is in light mode: a built-in one (below) or one of yours from [themes]. |
dark |
"default" |
The same for dark mode. |
sans |
system font | A Google Fonts family for text, e.g. "Inter". |
mono |
system monospace | A Google Fonts family for code, hashes and file lists, e.g. "JetBrains Mono". |
[theme]
light = "catppuccin-latte"
dark = "catppuccin-frappe"
sans = "Inter"
mono = "JetBrains Mono"
The site follows each visitor's light or dark setting, so it always shows one of the two. Built-in themes, each with matching syntax highlighting:
| light | dark |
|---|---|
default (spinney's own) |
default (spinney's own) |
github-light |
github-dark |
ayu-light |
ayu-mirage |
light-owl |
night-owl |
catppuccin-latte |
catppuccin-frappe |
Any theme can go in either slot: dark = "github-light" keeps the site
light for everyone. ./spinney -check prints the two in use.
Fonts are downloaded by spinney, not by visitors: when it starts it
fetches the family from Google (regular to bold, upright and italic, every
script it has) and serves it from /_static/fonts/, so no page makes a
request to Google. The name must be written as on fonts.google.com. Until the
download has finished, and if it fails, pages use the system fonts; a
failed download (no network yet) is retried in the background, a name
Google doesn't know is logged once (journalctl -u spinney). The files are
kept in /var/cache/spinney/fonts ($CACHE_DIRECTORY/fonts, or the user's
cache folder when run by hand), so later starts don't need Google; delete
that folder to download a family again.
[themes."name"]
Your own colour themes, picked by name in [theme] like the built-in ones.
Start from a built-in theme with base and change only some colours, or
leave base out to start from default (then any colour you don't set is
spinney's own for the mode the theme is used in).
[theme]
dark = "my-dark"
[themes.my-dark]
base = "night-owl" # optional: where the other colours come from
highlight = "dracula" # optional: syntax highlighting, else the base's
link = "#ff79c6"
header-bg = "#21222c"
act-4 = "#ff79c6"
Names are lower case letters, digits and -, and can't be a built-in
name. highlight is a chroma style
such as dracula, monokai, nord, onedark, gruvbox, rose-pine,
tokyonight-night or solarized-light, or one of the built-in themes'
names; a wrong name lists them all. A colour is #rgb, #rrggbb (with
optional alpha), rgb(), hsl(), oklch() and the like, or a CSS colour
name. The colours:
| name | used for |
|---|---|
bg, fg |
page background and text |
muted |
secondary text: dates, owners, line numbers |
link, link-hover |
links |
border |
lines and boxes |
header-bg |
the top bar and file headers in diffs |
event-bg |
commit boxes, table row hover |
code-bg |
source, diffs and code blocks |
btn-bg, btn-hover |
buttons and the active year on the activity tab |
target |
the highlighted line from a #L12 link |
add, add-bg, del, del-bg |
added and removed lines in diffs |
hunk |
@@ lines in diffs, renames |
branch-bg, tag-bg |
branch and tag labels |
act-0 … act-4 |
the activity graph, from no commits to the busiest day |
[mirrors]
Repositories you follow: each line names a repository and the URL it is copied from. The installer sets up a timer that keeps them up to date: a repository that doesn't exist yet is cloned, and every 30 minutes the rest fetch whatever is new. Only branches and tags are copied.
[mirrors]
"torvalds/linux" = "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git"
"neovim/neovim" = "https://github.com/neovim/neovim.git"
Put a mirror in a folder named after its owner, as above, and the index
shows that owner. Mirrors can be browsed but not cloned over HTTP or
downloaded as .tar.gz: both are refused with a message naming the
upstream URL, which the summary page shows as "mirror of" instead of a
clone address. Below it is the last successful update ("synced 12 minutes
ago"), in red with the error when the last one failed, or when there has
been none for two hours. URLs must start with https://, http:// or git://.
The first clone runs in the background after a deploy; follow it with
journalctl -u spinney-mirror -f, or run it by hand with
spinney -config spinney.toml -mirror. Removing a line stops updating that
repository but keeps it; delete its folder to remove it. Don't push to a
mirror: the next update overwrites its branches.
[repos."name"]
Settings for one repository. The name is the repository's path under root
without .git, as it appears in the URL:
| on disk | name |
|---|---|
/srv/git/tinyhttpd.git |
tinyhttpd |
/srv/git/tools/foo.git |
"tools/foo" |
/srv/git/.dotfiles.git |
".dotfiles" |
Quote names that contain / or ..
| key | meaning |
|---|---|
description |
Shown in the header and index, instead of the repository's own description file. |
homepage |
A website link shown on the repository's summary page. |
hidden |
true to leave the repository out entirely: not listed, not browsable, not clonable. The same can be done without a config change or deploy: ssh git@HOST private NAME on, or create NAME --private (see usage.md). |
owner |
Shown as its owner on the index, instead of its folder or the site's owner. |
[repos.tinyhttpd]
homepage = "https://example.com/tinyhttpd/"
[repos."lab/raytracer"]
description = "A weekend ray tracer."
[repos.secret]
hidden = true
Where repositories come from
spinney lists every git repository under root (bare foo.git folders and
ordinary checkouts with a .git inside), up to depth folders deep. New
repositories show up within about 15 seconds, without a restart.
- A repository's description comes from the
descriptionfile inside it (foo.git/description), unless the config sets one. - Repositories in subfolders are grouped into sections on the index page:
tools/foois listed asfoounder tools. Repositories at the top ofrootcome first, then the folder named like the site'sowner(withowner = "alice", alice), then the other folders alphabetically. - A repository is left out, like
hidden, when its own config hasspinney.private = true, which the SSH commandsprivateandcreate --privateset (orgitshow.private = true, from before the rename). - Folders whose names start with
.are only served if they are bare repositories (.dotfiles.git); other hidden folders, such as.ssh, are skipped. - The spinney service runs as its own user, which needs read access to
root. Folders inside home directories usually aren't readable by other users, so/srv/gitis a better place than~/git.
Examples
Behind Caddy, which install.sh --domain sets up:
listen = "127.0.0.1:8080"
root = "/srv/git"
title = "git.example.com"
description = "Small tools and experiments."
[clone]
http = "https://git.example.com"
Serving directly on a home network, without Caddy:
listen = ":8080"
root = "/srv/git"
title = "homelab git"
spinney