@jpgilldev /

steid

Just a git forge

Clone

git clone https://jpgill.dev/jpgilldev/repos/steid.git
mainCommits

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, stored as bare repos on disk and records in SQLite. Public or private.
  • 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, list, revoke, at /{handle}/settings/tokens.
  • Browsing — file tree, file contents, and commit log, at /{handle}/repos/{name}/tree/{rev} and /log.
  • 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, issues, pull requests and SSH transport are not built yet. The plan for them is in plans/ROADMAP.md.

Requirements

  • Linux, x86_64 or aarch64, with systemd.
  • 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.
  • 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, 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-musl        # or aarch64-unknown-linux-musl
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.

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.

Restart after editing: systemctl restart steid.

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-musl

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.
  • 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 and /auth/setup only — 10 attempts a minute per client, with a global backstop. Token authentication on the git routes is not limited; a token is 256 bits, so guessing is not the concern there, but unbounded hashing on an open endpoint still is.
  • 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.
  • Tokens never expire and carry no "last used" timestamp, which makes it hard to know which are safe to revoke.
  • No CI/CD, no issues, no pull requests, no code review, no SSH transport, no webhooks, no federation.
  • No writing yet — posts and markdown are the next milestone, and they are the point of the whole thing.
  • Light mode is untested. It is defined; nobody has looked at it.
  • 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.