remuscodes.dk ❯ rits

self-hosted git forge · single binary each side

No HTTP.
No YAML.
Just SSH.

rits is a headless git server. Clone, push, browse history, enroll build machines, and run CI — everything over one SSH port, driven from a terminal UI instead of a web app.

git clone git@myserver:demo.git
rits-cli --remote myserver
rits — history
 history  Commits @ main  Branches  Jobs

✓ 8216de0 2026-08-19 remus  │ commit 8216de0a3bfc4d8a33d46
✓ e34cfe0 2026-08-18 remus  │ Author: remus
✗ 50cbe45 2026-08-18 ada    │ Date:   2026-08-19 10:27
✓ 12a27bf 2026-08-18 ada    │ ✓ Dockerfile.push@main j85c9ea87
● 91ee5f9 2026-08-18 remus  │ ✗ Dockerfile.tag@v*    jb6ad7a77
  fc289bf 2026-08-18 ada    │
  1bf42a3 2026-08-18 ada    │ ci: enable release checks

450 of 103,412 commits loaded • ↑/↓ select • b branches • esc back

one protocol

The forge is an SSH port.

Other platforms bolt a web application onto git. rits goes the other way: the git wire protocol is the whole interface. Humans authenticate as git with their public keys, build machines as runner — and every feature, from creating a repo to streaming a build log, is an SSH command on port 2222.

operate
One static binary, any setup — systemd on a VPS, k8s, a bare Raspberry Pi, or the bundled docker compose that runs server and example runner together. The only host dependency is git itself.
$ rits-server --root /srv/repos INF serving git over SSH addr=[::]:2222
work
Stock git speaks to it natively — clone, push, pull, fetch, protocol v2. The rits TUI browses repos, pages 100K-commit histories lazily, and creates repos with one keystroke.
$ rits-cli create demo created demo $ git push origin main
enroll
Runners live on your own machines — a VM, a Pi. A one-time token (its hash is all the server keeps) enrolls the runner's own keypair; from then on it holds a persistent channel and jobs stream down it.
$ rits-runner enroll --token ritsr_9f2… enrolled as pi-runner

ci without yaml

The pipeline is a Dockerfile.
The filename is the trigger.

Drop Dockerfile.push@main into .rits/ and every push to main builds it. The build is the job: its exit code is the verdict, its output is the log, its image is the artifact. No pipeline DSL to learn — you already know this file format. Several deliverables? Name the hooks — Dockerfile.api.tag and Dockerfile.worker.tag are two independent jobs on the same trigger, and either can be a symlink to a Dockerfile your repo already builds.

.rits/Dockerfile.push@main

vim
FROM golang:1.25 AS build
LABEL rits.runner="docker"
WORKDIR /src
COPY . .
RUN go vet ./... && go test ./...
RUN CGO_ENABLED=0 go build -o /out/app .

FROM alpine:3.22
COPY --from=build /out/app /usr/local/bin/app

what a push sets in motion

rits — jobs
$ git push origin main

j85c9ea87  ✓ success  push  main   push@main
jb6ad7a77  ✗ failed   tag   v2.0   tag@v*
j76d6ef0c  ● running  push  main   push@main
j9c1330a2  ○ queued   push  feat…  push@feature*

artifact: rits/demo/push-main:8216de0
4 jobs • enter view log • r refresh

No credentials in builds

Jobs never clone — the build context arrives via git archive. When a job does need a credential, it's a per-repo secret: delivered as a BuildKit mount for one RUN, never baked into layers, masked in logs.

Status lives in git

Verdicts are written as git notes under refs/notes/rits-ci — the ✓/✗ next to each commit travels with the repo and works offline in plain git log.

Queued, routed, retried

LABEL rits.runner routes jobs to matching machines. No runner online? The job waits. A runner dies mid-build? The job is requeued, up to three attempts.

runners

Build machines are your machines.
Enrolled once, connected always.

rits-runner is a single binary you put on a VM, a Pi, a GPU box under the desk — or leave as the runner service in the bundled compose file. It speaks to the server over the same SSH port as everyone else, as a separate identity class: runner, not git. No inbound port on the build machine, no agent protocol, no web dashboard.

register on the server — token shown exactly once

rits — runners
 Repos  Runners

● docker      docker        2026-08-12
● pi-runner   arm64,edge    2026-08-14
◌ gpu-box     gpu,cuda      2026-08-19  pending
○ laptop      docker        2026-08-03  offline

token for gpu-box (copy now — only its hash is kept):
ritsr_9f2c4b8e1a7d3f60b5c2e8a4d1f7…
n new • c copy • d revoke • esc back

enroll on the machine — then leave it running

gpu-box
$ rits-runner enroll --remote myserver:2222 \
    --token ritsr_9f2c4b8e1a7d…
generated ed25519 key  ~/.rits-runner/id_ed25519
enrolled as gpu-box    token burned server-side

$ rits-runner run --remote myserver:2222
INF listening for jobs labels=gpu,cuda
INF job j76d6ef0c  demo  push@main  ● building
INF job j76d6ef0c  ✓ success  artifact=rits/demo/push-main:8216de0
WRN connection lost — reconnecting in 2s… ok

A token you use once

Registering a runner mints a one-time enrollment token; the server stores only its SHA-256. The runner presents it as SSH password auth, generates its own ed25519 keypair, submits the public half, and the token is burned. From then on it's key auth like everyone else.

Least privilege by identity

The runner role may fetch repos and listen for jobs — nothing else. It cannot push, cannot create or delete repos, cannot run admin commands. A compromised build box gets read access to what it already builds, and no more.

Presence is the schedule

A runner holds one persistent channel open and jobs stream down it; that channel is what the TUI shows as ● online. Drop and it reconnects with backoff. LABEL rits.runner routes hooks to labelled machines — put the NVIDIA toolkit on a box, label it gpu, and build steps see the GPU.

variables & secrets

Set once, used at build time.
Vars readable, secrets write-only.

Per-repo variables and credentials, set once from any shell and handed to hooks at build time. Secrets are encrypted at rest and never shown back — not in a listing, not in a log, not in an image layer. Rule of thumb: if it would be fine in docker history, it's a var; otherwise it's a secret. Names starting with RITS_ configure rits itself — which registry to publish an image to, which runner to build it on — and never reach a build.

the TUI's vars view — v from commits

rits — myrepo
 myrepo  Commits @ main  Branches  Jobs  Vars

 var     NAS_URL        http://nas:8080
 var     DEPLOY_ENV     staging
 secret  NAS_TOKEN      ••••••••          
 secret  REGISTRY_PASS  ••••••••

 edit NAS_TOKEN  secret  value: ▏
 values are typed masked and never echoed back

4 entries • n new • enter edit • d delete • esc back

.rits/Dockerfile.tag@v*

vim
FROM debian:12 AS build
COPY . .
RUN make deb

ARG NAS_URL
RUN --mount=type=secret,id=NAS_TOKEN \
    TOKEN=$(cat /run/secrets/NAS_TOKEN) && \
    curl -H "Authorization: Bearer $TOKEN" \
         --upload-file out.deb "$NAS_URL/incoming/"

Vars as build args, secrets as mounts

Variables arrive as ARGs. Secrets are mounted for a single RUN via BuildKit and never touch ENV or ARG — nothing persists into image layers. Values are masked as *** in job logs.

Secrets have no read path

Variables are public config and list with their values. Secrets list by name only — the Vars view creates, edits and deletes them without ever displaying one. The same operations exist as one-shot commands: rits-cli var set, secret set, vars.

Encrypted at rest

A secret is encrypted the moment you set it and stays that way until the one build that needs it. Rotating is a single secret set; since nothing reads a value back, there is nothing for a listing, a log or a layer to leak.

no server required

The same CI, on your laptop,
before the push leaves it.

Not everyone wants to run a forge. rits-hooks installs a git pre-push hook that builds the very same .rits/ files on your own machine — no server, no runner, no SSH. A failure stops the push. The trigger rules are shared code, not a second implementation, so a green run here is a green job there if you adopt a server later.

git push — the hook takes over

rits-hooks
=^.^= myrepo  Hooks  Vars   Artifacts        main · 2 hooks

 ✓ Dockerfile.lint.push                  3.7s
 ● Dockerfile.test.push

 ── Dockerfile.test.push
 #5 [2/2] RUN go test ./...
 #5 2.27 ok   myrepo/internal/store   0.41s

space mark · enter run this one · a all · q stop the push

two commands

bash
$ rits-hooks install
installed .git/hooks/pre-push
2 hook(s) fire for main

$ rits-hooks --docs > .rits/GUIDE.md
# the whole hook reference, printed by every rits binary

Secrets, still encrypted

Values live in a hidden ref inside the clone, sealed with a key in your config directory — same design as the server's, so a hook that mounts a secret works locally too.

Artifacts on disk

What a build declares lands in .rits-artifacts/, permissions intact — the .deb you just built is right there, executable.

Grow into a server

Nothing in .rits/ is local-only. Point the repo at a rits server when you want shared runners, and the same files run there unchanged.

roadmap

Built, and what's ahead.

v0

soon

Working today, verified end to end.

  • ✓Full git over SSH — clone, push, pull, protocol v2, ed25519 host keys, public-key auth only.
  • ✓Terminal UI — browse and create repos; commits (lazy-paged), branches, jobs and live build logs as tabs.
  • ✓Self-hosted runners — one-time-token enrollment, persistent SSH channel, labels, live online presence.
  • ✓Dockerfile CI — .rits/Dockerfile.[<name>.]<event>[@glob]; branch and tag triggers, several named hooks per event; builds as sibling containers, never DinD.
  • ✓Job queue — label routing, sequential per runner, requeue on runner loss, 30-minute timeout.
  • ✓Image artifacts — builds tagged rits/repo/hook:sha, newest three kept, older pruned.
  • ✓Build artifacts — any file a build produces — a .deb, a disk image, a binary — uploaded to the server, browsable and downloadable from the CLI and the TUI, permissions intact.
  • ✓Registry publishing — a green build is tagged and pushed to GCR, ghcr.io, ECR or any registry; credentials are per-repo secrets, used in a throwaway docker config and never left on the runner.
  • ✓Local CI, no server — rits-hooks runs the same .rits/ hooks from a git pre-push hook: a failing build stops the push, with its own encrypted variables and artifacts on disk.
  • ✓Install as a package — one .deb with all four tools and a systemd unit each, configured from a single env file; installing enables nothing.
  • ✓Commit status — ✓/✗/● per commit, stored as git notes, fetchable by any clone.
  • ✓One-file deploy — docker compose runs server and runner.
  • ✓Variables & secrets — per repo, encrypted at rest; vars arrive as build args, secrets as BuildKit mounts, masked in logs.

ahead

exploring

Directions under consideration, in rough order.

  • ◇Continuous deployment — a deploy step after a green build, entirely in your hands: an image rits builds and then runs — kubectl, helm, ssh, rsync — with the repo's secrets as its environment. Sandboxed like every other job, and no docker socket needed.
  • ◇Merge requests — propose a branch, review the diff and discuss it in the TUI, merge when CI is green — the whole flow over SSH, no web UI.
  • ◇Per-key permissions — read-only deploy keys, per-repo access, protected branches.
  • ◇Repo lifecycle — delete, archive, and mirror to or from other remotes.
  • ◇Notifications — job verdicts to webhooks or mail.
❯

Ready to use — self-hosting only. You bring the box, the keys, and the runners. There is no hosted offering, no account system, no telemetry — your repositories never leave hardware you control.