steid

@jamesgill /

main

Branches

1 branch·0 tags
fix: a shipped migration is immutable, comments includeda6819c21 month ago

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 #12 and 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=source for the text; headings get ids, so a link to notes.md#usage lands 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 — zip and tar.gz of 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 #4 in 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.
  • git on PATH. Not optional: Steid runs git init --bare to create a repository and git http-backend to serve every clone and push. One apt-get install git. git 2.38 or newer to merge a pull request from the web — that is when git merge-tree --write-tree arrived, 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

curl -fsSL https://REPLACE-ME.example.com/steid/install.sh | sh -s -- --domain git.example.com

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:

  1. installs git, curl and ca-certificates
  2. downloads the release tarball for your architecture and verifies its SHA-256
  3. unpacks it to /opt/steid — the binary with its assets/ directory beside it
  4. creates a steid system user and /var/lib/steid for state, owned by it
  5. writes /etc/steid/steid.env, and a systemd unit that listens on 127.0.0.1:3000
  6. installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies to that loopback port
  7. 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:

curl -fsSL https://REPLACE-ME.example.com/steid/install.sh -o install.sh
less install.sh
sudo sh install.sh --domain git.example.com

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.
apt-get update
apt-get install -y git curl ca-certificates

# 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
curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz"
curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz.sha256"
sha256sum -c "steid-$VERSION-$TARGET.tar.gz.sha256"
tar -xzf "steid-$VERSION-$TARGET.tar.gz"

# 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.
mkdir -p /opt/steid
cp "steid-$VERSION-$TARGET/steid" /opt/steid/steid
cp -r "steid-$VERSION-$TARGET/assets" /opt/steid/assets
chmod 755 /opt/steid/steid

# 4. A user to run as, and one directory for all state.
useradd --system --home-dir /var/lib/steid --shell /usr/sbin/nologin steid
mkdir -p /var/lib/steid/repos
chown -R steid:steid /var/lib/steid
chmod 750 /var/lib/steid

# 5. Configuration.
mkdir -p /etc/steid
cat > /etc/steid/steid.env <<'EOF'
STEID_DATABASE_URL=sqlite:/var/lib/steid/steid.db?mode=rwc
STEID_DATA_DIR=/var/lib/steid/repos
HOST=127.0.0.1
PORT=3000
EOF
chmod 640 /etc/steid/steid.env
chown root:steid /etc/steid/steid.env

# 6. The service. deploy/steid.service in this repo is the file to copy.
cp deploy/steid.service /etc/systemd/system/steid.service
systemctl daemon-reload
systemctl enable --now steid

# 7. The proxy. deploy/Caddyfile is a working starting point — change the
#    hostname on its first line to yours.
apt-get install -y caddy      # or follow https://caddyserver.com/docs/install
cp deploy/Caddyfile /etc/caddy/Caddyfile
$EDITOR /etc/caddy/Caddyfile
systemctl reload caddy

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:

journalctl -u steid --no-pager | tail -n 30

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=$(head -c 32 /dev/urandom | base64)

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

  1. Create a repository at /{handle}/repos/new. Public or private.
  2. Issue a personal access token at /{handle}/settings/tokens. It is shown once.
  3. Push:
git remote add origin https://git.example.com/me/repos/my-project.git
git push -u origin main

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:

git config --global credential.helper osxkeychain   # macOS (usually already set)
git config --global credential.helper manager       # Windows
git config --global credential.helper libsecret     # Linux

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.

VariableDefaultWhat it is
STEID_DATABASE_URLsqlite:steid.db?mode=rwcSQLite connection string. The directory must be writable, not just the file — SQLite writes -wal and -shm siblings.
STEID_DATA_DIR./dataWhere bare repositories live, as {data_dir}/{handle}/{name}.git.
STEID_INSECURE_COOKIESunset (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.
HOST127.0.0.1 in the shipped unitBind address. Read by the framework, so deliberately not STEID_-prefixed. Keep it on loopback; the proxy is the way in.
PORT3000Likewise not STEID_-prefixed.
STEID_TRUSTED_PROXYunset (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_TOKENunsetA 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:

$ curl -s https://steid.example.com/api/instance
{"claimed":false}

With the operator token it adds what the machine is and how big it has become:

$ curl -s -H "Authorization: Bearer $STEID_OPERATOR_TOKEN" \
      https://steid.example.com/api/instance
{"claimed":true,"version":"0.4.0","git_version":"2.47","merge_supported":true,
 "repositories":3,"users":1,"uptime_seconds":8421}

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.

$ curl -s -X POST https://steid.example.com/api/setup \
      -H 'content-type: application/json' \
      -d '{"setup_token":"…","email":"you@example.com","handle":"you","password":"…"}'
{"handle":"you"}

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.

systemctl stop steid
rsync -a /var/lib/steid/ backup-host:/backups/steid/
systemctl start steid

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:

sqlite3 /var/lib/steid/steid.db ".backup '/tmp/steid-backup.db'"
rsync -a /tmp/steid-backup.db /var/lib/steid/repos backup-host:/backups/steid/

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 install topcoat-cli --version 0.5.0 --locked
topcoat asset bundle --release

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

./release.sh --target x86_64-unknown-linux-gnu

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/setup and 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:

FileHolds
plans/ROADMAP.mdvision, stack, the milestone ladder
plans/current.mdthe active milestone, and every known gap
plans/progress.mdwhat shipped, and the decisions worth not rediscovering
plans/architecture.mdlayer 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.