- deploy1 month ago
- migrations1 month ago
- plans1 month ago
- src1 month ago
- tests1 month ago
- .dockerignore1 month ago634 B
- .gitignore1 month ago156 B
- build.rs2 months ago718 B
- Cargo.lock1 month ago81.3 KB
- Cargo.toml1 month ago1.5 KB
- CLAUDE.md1 month ago10.9 KB
- components.toml2 months ago1.3 KB
- deploy.sh1 month ago9.2 KB
- Dockerfile1 month ago4.0 KB
- install.sh1 month ago25.6 KB
- LICENSE1 month ago33.7 KB
- README.md1 month ago23.0 KB
- release.sh1 month ago10.0 KB
- styles.css1 month ago15.4 KB
Steid
A personal gitforge, in Rust. It hosts your repositories, and it is meant to become the place your work as a whole lives — code, writing, projects — under one identity you control.
Steid is portfolio-first. Gitea and Forgejo are GitHub scaled down; their unit is the
repository and the profile is a directory listing bolted to the side. Steid inverts that:
the profile page at /{handle} is the product, and git repositories are one kind of
thing that appears on it. That framing wins every tie-break in the design.
It runs as a single binary with a SQLite database, shells out to git for everything
git-shaped, and is small enough to host on the cheapest VPS you can find.
What works today
Honestly and only:
- Identity and sessions. One user. The instance is claimed on first run with a setup token, then you sign in with a password. There is no registration — see limitations.
- The profile page at
/{handle}, listing what you have. - Repositories. Created through the browser, or imported by cloning a URL. Stored as bare repos on disk and records in SQLite. Public or private, renameable, pinnable, archivable, deletable, with a default branch you choose.
- Clone and push over HTTPS, through
git http-backend, authenticated with personal access tokens over HTTP Basic. Private repositories require a token to clone; public ones are anonymous. - Personal access tokens — issue with an expiry (30 days, 90, a year, or never), see
when each was last used, revoke, at
/{handle}/settings/tokens. - Browsing — file tree with a per-file last-commit column, file contents with
syntax highlighting, commit log, a commit and its highlighted diff, a three-dot
comparison of two revisions, blame, branches and tags, and the README rendered on the
repository page with
#12and bare shas linked to the issue and commit they name. - A file's own history at
/{handle}/repos/{name}/log/{rev}/-/{path}, which follows renames, so a file's past does not stop where its name changed. - Markdown files render as documents, with
?view=sourcefor the text; headings get ids, so a link tonotes.md#usagelands on the section it names. Submodule rows link to their upstream at the sha they are pinned to, and symlink rows say where they point. - Code search over a revision, at
/{handle}/repos/{name}/search. - Archive download —
zipandtar.gzof any revision, and of every tag. - Issues — open, comment, edit, close and reopen, with labels from a palette of eight colours, and filtering by label and state.
- Pull requests — branch to branch inside one repository, with a Conversation, the
commits and the changed files under their own tabs, the same labels the issues wear,
and a merge box that says whether the branches actually merge before it offers a
button. Merge as a merge commit, a fast-forward or a squash, optionally deleting the
source branch afterwards; a merge fires the same webhooks a push does, and
Closes #4in the title or body closes issue 4 and says so on it. - Webhooks — a signed JSON POST per push, with a delivery log you can page through, redeliver an attempt from, and switch a hook off in.
- Atom feeds — a repository's commits and its issues, and the owner's push activity across repositories, linked from the pages they belong to and autodiscovered.
- Every long list pages, with
?page=N, clamped to the nearest page that exists rather than refused. - A JSON API —
/api/me,/api/users/{handle},/api/users/{handle}/repos. Not a separate product; every use case gets a second surface where that makes sense, which is what keeps the use-case layer honest.
Writing (markdown posts), multi-user, organizations, code review and SSH transport are
not built yet. The plan for them is in plans/ROADMAP.md.
Requirements
- Linux, x86_64 or aarch64, with systemd. The releases are built against glibc 2.31, so Debian 11 and Ubuntu 20.04 or newer.
gitonPATH. Not optional: Steid runsgit init --bareto create a repository andgit http-backendto serve every clone and push. Oneapt-get install git. git 2.38 or newer to merge a pull request from the web — that is whengit merge-tree --write-treearrived, which is how a merge is computed without a working tree; everything else works on any git, and where it is older the merge box says so instead of offering a button. Debian 12 and Ubuntu 22.04 and newer are fine.- A TLS-terminating reverse proxy, and a domain name for it. Also not optional. Steid speaks plain HTTP — the web framework it is built on (Topcoat 0.5) has no TLS support at all: no rustls, no ACME, no HTTPS listener. And because git authenticates with HTTP Basic, an instance served over plain HTTP leaks your access token to every hop on the network, on every push. The installer sets Caddy up for you.
- About 100 MB of disk before any repositories, plus whatever you push.
Install
One command
|
Point the domain's DNS at the machine first, and make sure ports 80 and 443 are open — Caddy needs both to obtain a certificate.
That does the following, and nothing else:
- installs
git,curlandca-certificates - downloads the release tarball for your architecture and verifies its SHA-256
- unpacks it to
/opt/steid— the binary with itsassets/directory beside it - creates a
steidsystem user and/var/lib/steidfor state, owned by it - writes
/etc/steid/steid.env, and a systemd unit that listens on127.0.0.1:3000 - installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies to that loopback port
- starts both
Re-run it with a newer --version to upgrade. It is idempotent: it replaces the binary
and assets, leaves /var/lib/steid alone, and will not overwrite /etc/steid/steid.env
once it exists.
Options: --version, --port, --flavour musl|gnu (gnu is what is built), and
--no-caddy if you are bringing your own proxy. sh install.sh --help lists them.
On piping a URL into a shell. It is a real trust decision, and "it's convenient" is not an answer to it. Download and read it first if you would rather:
Or do it by hand — the manual path below is the same steps, written out.
Manually
Every command as root, on Debian or Ubuntu. Adjust paths and package manager to taste;
nothing here is specific to a distribution except apt-get.
# 1. Prerequisites. git is mandatory — Steid shells out to it for everything.
# 2. Fetch and verify the release for your architecture.
VERSION=0.1.0
TARGET=x86_64-unknown-linux-gnu # or aarch64-unknown-linux-gnu
BASE=https://REPLACE-ME.example.com/steid/releases/download
# 3. Install. `assets/` MUST end up beside the binary: at startup Steid walks up
# from its own executable looking for `assets/manifest.toml`, and exits if it
# is not there. Moving the binary on its own gives you a program that will not
# boot.
# 4. A user to run as, and one directory for all state.
# 5. Configuration.
# 6. The service. deploy/steid.service in this repo is the file to copy.
# 7. The proxy. deploy/Caddyfile is a working starting point — change the
# hostname on its first line to yours.
There is no migration step. Steid runs its migrations itself on every boot.
First run: claiming the instance
A fresh instance has no owner. On startup it prints a setup token to its log:
This steid has no owner yet. Claim it at /auth/setup with:
<token>
Two things about that token that will bite you otherwise:
- It is printed only while the instance is unclaimed. Once you have claimed it, it is never shown again — because it no longer exists.
- It lives in memory only. Every restart mints a new one, and the one in an older log line is dead.
So read it from the current run's journal:
|
Then open https://git.example.com/auth/setup, paste it in, and choose your handle and
password. After that, /auth/login.
Passwords must be at least 12 characters. That is the only rule — no required digit or symbol, because length is the part that measurably helps and composition rules mostly make people write theirs down. The same rule applies to a password reset and to changing one in settings; accounts created before it keep working until their owner changes them.
Or set the token yourself
If reading a log line during the first minute of a scripted install is awkward, set the token instead and skip the race:
STEID_SETUP_TOKEN=
It is only consulted while the instance is unclaimed, it is never printed, and Steid
refuses to start if it is weak — at least 32 characters, no whitespace, and at least
eight distinct characters, so abababab… is rejected rather than accepted.
Using it
- Create a repository at
/{handle}/repos/new. Public or private. - Issue a personal access token at
/{handle}/settings/tokens. It is shown once. - Push:
Git will ask for a username and password. The token goes in the password field; the username is ignored (a token pasted as the username with an empty password also works, because people do that).
You only type it once. Git's credential helper stores it and answers every later push — which is the whole reason pushing to GitHub feels like it needs no credentials. Use the one that keeps secrets in your operating system's keystore:
Not credential.helper store. It writes the token in clear text to
~/.git-credentials, and a token is a password that never expires. Steid keeps only a
hash of it precisely so that a stolen database contains nothing anyone can present;
storing the plaintext on your laptop hands back what that was protecting.
Do not put the token in the remote URL (https://user:token@host/...) either. It
goes into .git/config in clear text and shows up in git remote -v, in shell history,
and in any log that records the URL.
Cloning a public repository needs no credentials at all.
Configuration
Environment variables, read from /etc/steid/steid.env by the systemd unit.
| Variable | Default | What it is |
|---|---|---|
STEID_DATABASE_URL | sqlite:steid.db?mode=rwc | SQLite connection string. The directory must be writable, not just the file — SQLite writes -wal and -shm siblings. |
STEID_DATA_DIR | ./data | Where bare repositories live, as {data_dir}/{handle}/{name}.git. |
STEID_INSECURE_COOKIES | unset (false) | Never set this in production. It strips Secure from the session cookie so it survives plain-HTTP localhost, and exists only for local development. |
HOST | 127.0.0.1 in the shipped unit | Bind address. Read by the framework, so deliberately not STEID_-prefixed. Keep it on loopback; the proxy is the way in. |
PORT | 3000 | Likewise not STEID_-prefixed. |
STEID_TRUSTED_PROXY | unset (false) | Whether to believe X-Forwarded-For / X-Real-IP when telling one rate-limited caller from another. install.sh sets it true, because that install puts Caddy in front. Leave it off for anything serving Steid straight to the internet — see Operator surface. |
STEID_OPERATOR_TOKEN | unset | A bearer token for the operator surface. Unset means that surface does not exist. |
Restart after editing: systemctl restart steid.
Operator surface
Two JSON routes for driving an instance without a browser — a control plane provisioning
a machine, a monitoring poll, a scripted install. No session, no cookie, no HTML.
Authenticated by STEID_OPERATOR_TOKEN as a bearer token, compared in constant time.
With no token configured the surface does not exist: those calls answer as if
unauthenticated, and nothing advertises that a token could be set.
GET /api/instance
Unauthenticated, it answers the one fact that is safe to tell anybody — and the one a provisioner needs, so it can wait for a machine to finish booting before handing its owner a claim link:
}
With the operator token it adds what the machine is and how big it has become:
}
A wrong or missing token is not an error — it is the public answer. git_version is
absent where git could not be run at all, which is the same case that leaves
merge_supported false.
The unauthenticated form is rate limited to 60 calls a minute per caller (600 across everyone), which is about one poll a second. The authenticated form is not: a caller holding the token has already proved it is the control plane.
POST /api/setup
The claim form, headless. Same rules as the page at /auth/setup: the setup token is
checked before anything is written, a claimed instance cannot be claimed again, and the
password must be at least 12 characters.
}
201 on success, and no session — a control plane is not signing in, so sign in through
the browser afterwards like anyone else. A password short of the minimum is refused like
everything else here — the same opaque 404, with the reason in the server log — because
a response that named the password would tell a stranger guessing tokens that they had
guessed one. Every failure is the same 404 with the same
body, whether the token was wrong or the instance was already claimed: telling those two
apart would say whether there is still something worth guessing. It shares the sign-in
rate limit — 10 attempts a minute per caller, 60 across everyone — because it is the same
secret being attacked.
Pair it with STEID_SETUP_TOKEN (see Or set the token yourself)
so the whole install is scriptable: the token goes into the environment, the machine boots,
GET /api/instance says {"claimed":false}, and one POST claims it.
STEID_TRUSTED_PROXY
The rate limiters have to tell one caller from another, and the web framework Steid is
built on (Topcoat 0.5) discards the peer address when it accepts a connection — a handler
sees request headers and nothing else. So the only thing available is
X-Forwarded-For.
Behind a proxy that is sound: the proxy appends the address it accepted the connection from, so the rightmost entry is the one the client cannot forge. Exposed directly, nothing appends anything, and a caller can invent a fresh header per request to buy itself a fresh budget every time.
STEID_TRUSTED_PROXY says which situation you are in, and defaults to the safe answer.
Left off, no header is read at all and every caller shares one window — a forged header
buys nothing, at the cost of one flood exhausting the shared budget for a minute. Turn it
on only when something in front of Steid is appending the header; install.sh does,
because it installs Caddy.
Backup and restore
The entire backup surface is two paths:
/var/lib/steid/steid.db— the SQLite database (users, repositories, tokens, sessions)/var/lib/steid/repos— the bare git repositories
Which is to say: back up /var/lib/steid. rsync is enough.
Stopping first is the honest version: SQLite in WAL mode leaves -wal and -shm files,
and copying them while a write is in flight can capture a torn state. If you would rather
not stop the service, snapshot the database properly and copy the repositories live —
they are only written during a push:
To restore: install Steid as above, stop it, drop both paths back into /var/lib/steid,
chown -R steid:steid /var/lib/steid, start it. Migrations run on boot, so a database
from an older version is brought forward automatically.
Building from source
cargo build --release on its own is not enough. It produces a binary that will not
boot: AssetBundle::load() walks up from the executable looking for
assets/manifest.toml, and build.rs does not write one. topcoat asset bundle runs
cargo build itself and then writes the bundle to target/assets. The binary at
target/release/steid needs that directory beside it.
Requires rustc ≥ 1.95 — Topcoat 0.5 demands it, and on an older toolchain cargo add topcoat silently resolves to an empty v0.0.0 placeholder instead of failing.
For development, topcoat dev does the bundling for you, and a local .env with
STEID_INSECURE_COOKIES=true is needed for the session cookie to survive plain-HTTP
localhost.
Cutting a release
Produces dist/steid-<version>-<target>.tar.gz and a .sha256 beside it. The tarball
extracts to a self-contained directory: the binary, assets/, and this README. Building
a Linux artefact on macOS needs a container (the script uses Docker) or a cross
toolchain; --native builds for the host instead, which is useful for checking the
artefact layout and not for releasing.
A container image also exists — see Dockerfile — but the supported
artefact is the plain binary.
Current limitations
Deliberately blunt. Steid is early.
- Single user. One account, claimed on first run. No registration, no invites, no organizations yet. Issues exist, but only the owner may open or comment on one, which is the same assumption showing.
- No TLS of its own. A reverse proxy is mandatory, not recommended — see requirements.
- No encryption at rest. The database and repositories are plain files. Anyone with the disk has everything. Token values are hashed, and passwords are Argon2, but repository contents are not encrypted.
- Rate limiting covers
/auth/login,/auth/setupand token authentication on the git routes — 10 attempts a minute per client for the first two, 60 for the third, each with a global backstop. An anonymous clone of a public repository is not limited at all, and neither is an archive download, which is the most expensive thing a stranger can ask for. - Rate limiting keys on forwarded headers, because Topcoat 0.5 does not expose the peer address at all. Behind a reverse proxy — the supported deployment — that works. Exposed directly to the internet with no proxy, a caller can vary the header and get a fresh budget each time, leaving only the global cap. Run it behind the proxy.
- Nothing sweeps expired tokens and nothing warns before one expires. A token that has expired authenticates exactly like one that never existed, and the tokens page is where you find out.
- No CI/CD, no code review, no SSH transport, no federation. Pull requests exist but are bare: no assignees, reviewers or milestones, and only the owner may open or merge one — the single-user assumption again.
- No writing yet — posts and markdown are the next milestone, and they are the point of the whole thing.
- Light mode is half-checked. The repository landing page, a tree and a blob were looked at and were fine; the profile, the settings pages and the log were not. Either way there is no way for a visitor to choose — the layout is hardcoded dark.
- Not battle-tested. It has not run under load, has not been audited, and has been deployed by approximately one person. Do not put anything irreplaceable in it that is not also somewhere else.
More, in unflattering detail, in plans/current.md.
Project layout
plans/ is the source of truth for intent, not the code:
| File | Holds |
|---|---|
plans/ROADMAP.md | vision, stack, the milestone ladder |
plans/current.md | the active milestone, and every known gap |
plans/progress.md | what shipped, and the decisions worth not rediscovering |
plans/architecture.md | layer rules and conventions |
plans/decisions/ | ADRs |
The code is a single crate in three layers — domain, application, infrastructure —
with dependencies pointing inward. Every use case takes an Actor and authorizes before
any side effect.
Licence
Copyright (C) 2026 James Gill.
Steid is free software under the GNU Affero General Public License v3.0 — see LICENSE. You may use, modify and redistribute it, including commercially.
The reason for AGPL rather than a permissive licence is section 13: if you modify Steid and offer it to others over a network, you must make your modified source available to those users. Running an unmodified copy — which is what self-hosting is — carries no such obligation. In other words: host it for yourself freely, fork it privately for yourself freely, but a modified public service has to share what it changed.
Worth being straightforward about what this does not do: it does not stop anyone running Steid as a commercial hosted service. AGPL is a copyleft licence, not a non-compete.