| 1 | # Steid |
| 2 | |
| 3 | A personal gitforge, in Rust. It hosts your repositories, and it is meant to become the |
| 4 | place your work as a whole lives — code, writing, projects — under one identity you |
| 5 | control. |
| 6 | |
| 7 | Steid is **portfolio-first**. Gitea and Forgejo are GitHub scaled down; their unit is the |
| 8 | repository and the profile is a directory listing bolted to the side. Steid inverts that: |
| 9 | the profile page at `/{handle}` is the product, and git repositories are one kind of |
| 10 | thing that appears on it. That framing wins every tie-break in the design. |
| 11 | |
| 12 | It runs as a single binary with a SQLite database, shells out to `git` for everything |
| 13 | git-shaped, and is small enough to host on the cheapest VPS you can find. |
| 14 | |
| 15 | ## What works today |
| 16 | |
| 17 | Honestly and only: |
| 18 | |
| 19 | - **Identity and sessions.** One user. The instance is claimed on first run with a setup |
| 20 | token, then you sign in with a password. There is no registration — see |
| 21 | [limitations](#current-limitations). |
| 22 | - **The profile page** at `/{handle}`, listing what you have. |
| 23 | - **Repositories.** Created through the browser, stored as bare repos on disk and |
| 24 | records in SQLite. Public or private. |
| 25 | - **Clone and push over HTTPS**, through `git http-backend`, authenticated with personal |
| 26 | access tokens over HTTP Basic. Private repositories require a token to clone; public |
| 27 | ones are anonymous. |
| 28 | - **Personal access tokens** — issue, list, revoke, at `/{handle}/settings/tokens`. |
| 29 | - **Browsing** — file tree, file contents, and commit log, at |
| 30 | `/{handle}/repos/{name}/tree/{rev}` and `/log`. |
| 31 | - **A JSON API** — `/api/me`, `/api/users/{handle}`, `/api/users/{handle}/repos`. Not a |
| 32 | separate product; every use case gets a second surface where that makes sense, which is |
| 33 | what keeps the use-case layer honest. |
| 34 | |
| 35 | Writing (markdown posts), multi-user, organizations, issues, pull requests and SSH |
| 36 | transport are **not built yet**. The plan for them is in [`plans/ROADMAP.md`](plans/ROADMAP.md). |
| 37 | |
| 38 | ## Requirements |
| 39 | |
| 40 | - Linux, x86_64 or aarch64, with systemd. |
| 41 | - **`git` on `PATH`.** Not optional: Steid runs `git init --bare` to create a repository |
| 42 | and `git http-backend` to serve every clone and push. One `apt-get install git`. |
| 43 | - **A TLS-terminating reverse proxy, and a domain name for it.** Also not optional. |
| 44 | Steid speaks plain HTTP — the web framework it is built on (Topcoat 0.5) has no TLS |
| 45 | support at all: no rustls, no ACME, no HTTPS listener. And because git authenticates |
| 46 | with HTTP Basic, an instance served over plain HTTP leaks your access token to every |
| 47 | hop on the network, on every push. The installer sets Caddy up for you. |
| 48 | - About 100 MB of disk before any repositories, plus whatever you push. |
| 49 | |
| 50 | ## Install |
| 51 | |
| 52 | ### One command |
| 53 | |
| 54 | ```sh |
| 55 | curl -fsSL https://REPLACE-ME.example.com/steid/install.sh | sh -s -- --domain git.example.com |
| 56 | ``` |
| 57 | |
| 58 | Point the domain's DNS at the machine first, and make sure ports 80 and 443 are open — |
| 59 | Caddy needs both to obtain a certificate. |
| 60 | |
| 61 | That does the following, and nothing else: |
| 62 | |
| 63 | 1. installs `git`, `curl` and `ca-certificates` |
| 64 | 2. downloads the release tarball for your architecture and **verifies its SHA-256** |
| 65 | 3. unpacks it to `/opt/steid` — the binary with its `assets/` directory beside it |
| 66 | 4. creates a `steid` system user and `/var/lib/steid` for state, owned by it |
| 67 | 5. writes `/etc/steid/steid.env`, and a systemd unit that listens on `127.0.0.1:3000` |
| 68 | 6. installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies |
| 69 | to that loopback port |
| 70 | 7. starts both |
| 71 | |
| 72 | Re-run it with a newer `--version` to upgrade. It is idempotent: it replaces the binary |
| 73 | and assets, leaves `/var/lib/steid` alone, and will not overwrite `/etc/steid/steid.env` |
| 74 | once it exists. |
| 75 | |
| 76 | Options: `--version`, `--port`, `--flavour musl|gnu`, and `--no-caddy` if you are |
| 77 | bringing your own proxy. `sh install.sh --help` lists them. |
| 78 | |
| 79 | **On piping a URL into a shell.** It is a real trust decision, and "it's convenient" is |
| 80 | not an answer to it. Download and read it first if you would rather: |
| 81 | |
| 82 | ```sh |
| 83 | curl -fsSL https://REPLACE-ME.example.com/steid/install.sh -o install.sh |
| 84 | less install.sh |
| 85 | sudo sh install.sh --domain git.example.com |
| 86 | ``` |
| 87 | |
| 88 | Or do it by hand — the manual path below is the same steps, written out. |
| 89 | |
| 90 | ### Manually |
| 91 | |
| 92 | Every command as root, on Debian or Ubuntu. Adjust paths and package manager to taste; |
| 93 | nothing here is specific to a distribution except `apt-get`. |
| 94 | |
| 95 | ```sh |
| 96 | # 1. Prerequisites. git is mandatory — Steid shells out to it for everything. |
| 97 | apt-get update |
| 98 | apt-get install -y git curl ca-certificates |
| 99 | |
| 100 | # 2. Fetch and verify the release for your architecture. |
| 101 | VERSION=0.1.0 |
| 102 | TARGET=x86_64-unknown-linux-musl # or aarch64-unknown-linux-musl |
| 103 | BASE=https://REPLACE-ME.example.com/steid/releases/download |
| 104 | curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz" |
| 105 | curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz.sha256" |
| 106 | sha256sum -c "steid-$VERSION-$TARGET.tar.gz.sha256" |
| 107 | tar -xzf "steid-$VERSION-$TARGET.tar.gz" |
| 108 | |
| 109 | # 3. Install. `assets/` MUST end up beside the binary: at startup Steid walks up |
| 110 | # from its own executable looking for `assets/manifest.toml`, and exits if it |
| 111 | # is not there. Moving the binary on its own gives you a program that will not |
| 112 | # boot. |
| 113 | mkdir -p /opt/steid |
| 114 | cp "steid-$VERSION-$TARGET/steid" /opt/steid/steid |
| 115 | cp -r "steid-$VERSION-$TARGET/assets" /opt/steid/assets |
| 116 | chmod 755 /opt/steid/steid |
| 117 | |
| 118 | # 4. A user to run as, and one directory for all state. |
| 119 | useradd --system --home-dir /var/lib/steid --shell /usr/sbin/nologin steid |
| 120 | mkdir -p /var/lib/steid/repos |
| 121 | chown -R steid:steid /var/lib/steid |
| 122 | chmod 750 /var/lib/steid |
| 123 | |
| 124 | # 5. Configuration. |
| 125 | mkdir -p /etc/steid |
| 126 | cat > /etc/steid/steid.env <<'EOF' |
| 127 | STEID_DATABASE_URL=sqlite:/var/lib/steid/steid.db?mode=rwc |
| 128 | STEID_DATA_DIR=/var/lib/steid/repos |
| 129 | HOST=127.0.0.1 |
| 130 | PORT=3000 |
| 131 | EOF |
| 132 | chmod 640 /etc/steid/steid.env |
| 133 | chown root:steid /etc/steid/steid.env |
| 134 | |
| 135 | # 6. The service. deploy/steid.service in this repo is the file to copy. |
| 136 | cp deploy/steid.service /etc/systemd/system/steid.service |
| 137 | systemctl daemon-reload |
| 138 | systemctl enable --now steid |
| 139 | |
| 140 | # 7. The proxy. deploy/Caddyfile is a working starting point — change the |
| 141 | # hostname on its first line to yours. |
| 142 | apt-get install -y caddy # or follow https://caddyserver.com/docs/install |
| 143 | cp deploy/Caddyfile /etc/caddy/Caddyfile |
| 144 | $EDITOR /etc/caddy/Caddyfile |
| 145 | systemctl reload caddy |
| 146 | ``` |
| 147 | |
| 148 | There is **no migration step**. Steid runs its migrations itself on every boot. |
| 149 | |
| 150 | ## First run: claiming the instance |
| 151 | |
| 152 | A fresh instance has no owner. On startup it prints a setup token to its log: |
| 153 | |
| 154 | ``` |
| 155 | This steid has no owner yet. Claim it at /auth/setup with: |
| 156 | |
| 157 | <token> |
| 158 | ``` |
| 159 | |
| 160 | Two things about that token that will bite you otherwise: |
| 161 | |
| 162 | - It is printed **only while the instance is unclaimed**. Once you have claimed it, it is |
| 163 | never shown again — because it no longer exists. |
| 164 | - It lives **in memory only**. Every restart mints a new one, and the one in an older log |
| 165 | line is dead. |
| 166 | |
| 167 | So read it from the current run's journal: |
| 168 | |
| 169 | ```sh |
| 170 | journalctl -u steid --no-pager | tail -n 30 |
| 171 | ``` |
| 172 | |
| 173 | Then open `https://git.example.com/auth/setup`, paste it in, and choose your handle and |
| 174 | password. After that, `/auth/login`. |
| 175 | |
| 176 | ### Or set the token yourself |
| 177 | |
| 178 | If reading a log line during the first minute of a scripted install is awkward, set the |
| 179 | token instead and skip the race: |
| 180 | |
| 181 | ```sh |
| 182 | STEID_SETUP_TOKEN=$(head -c 32 /dev/urandom | base64) |
| 183 | ``` |
| 184 | |
| 185 | It is only consulted while the instance is unclaimed, it is never printed, and Steid |
| 186 | **refuses to start** if it is weak — at least 32 characters, no whitespace, and at least |
| 187 | eight distinct characters, so `abababab…` is rejected rather than accepted. |
| 188 | |
| 189 | ## Using it |
| 190 | |
| 191 | 1. Create a repository at `/{handle}/repos/new`. Public or private. |
| 192 | 2. Issue a personal access token at `/{handle}/settings/tokens`. It is shown once. |
| 193 | 3. Push: |
| 194 | |
| 195 | ```sh |
| 196 | git remote add origin https://git.example.com/me/repos/my-project.git |
| 197 | git push -u origin main |
| 198 | ``` |
| 199 | |
| 200 | Git will ask for a username and password. The token goes in the **password** field; the |
| 201 | username is ignored (a token pasted as the username with an empty password also works, |
| 202 | because people do that). |
| 203 | |
| 204 | You only type it once. Git's credential helper stores it and answers every later push — |
| 205 | which is the whole reason pushing to GitHub feels like it needs no credentials. Use the |
| 206 | one that keeps secrets in your operating system's keystore: |
| 207 | |
| 208 | ```sh |
| 209 | git config --global credential.helper osxkeychain # macOS (usually already set) |
| 210 | git config --global credential.helper manager # Windows |
| 211 | git config --global credential.helper libsecret # Linux |
| 212 | ``` |
| 213 | |
| 214 | **Not `credential.helper store`.** It writes the token in clear text to |
| 215 | `~/.git-credentials`, and a token is a password that never expires. Steid keeps only a |
| 216 | hash of it precisely so that a stolen database contains nothing anyone can present; |
| 217 | storing the plaintext on your laptop hands back what that was protecting. |
| 218 | |
| 219 | **Do not put the token in the remote URL** (`https://user:token@host/...`) either. It |
| 220 | goes into `.git/config` in clear text and shows up in `git remote -v`, in shell history, |
| 221 | and in any log that records the URL. |
| 222 | |
| 223 | Cloning a public repository needs no credentials at all. |
| 224 | |
| 225 | ## Configuration |
| 226 | |
| 227 | Environment variables, read from `/etc/steid/steid.env` by the systemd unit. |
| 228 | |
| 229 | | Variable | Default | What it is | |
| 230 | |---|---|---| |
| 231 | | `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. | |
| 232 | | `STEID_DATA_DIR` | `./data` | Where bare repositories live, as `{data_dir}/{handle}/{name}.git`. | |
| 233 | | `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. | |
| 234 | | `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. | |
| 235 | | `PORT` | `3000` | Likewise not `STEID_`-prefixed. | |
| 236 | |
| 237 | Restart after editing: `systemctl restart steid`. |
| 238 | |
| 239 | ## Backup and restore |
| 240 | |
| 241 | The entire backup surface is **two paths**: |
| 242 | |
| 243 | - `/var/lib/steid/steid.db` — the SQLite database (users, repositories, tokens, sessions) |
| 244 | - `/var/lib/steid/repos` — the bare git repositories |
| 245 | |
| 246 | Which is to say: back up `/var/lib/steid`. `rsync` is enough. |
| 247 | |
| 248 | ```sh |
| 249 | systemctl stop steid |
| 250 | rsync -a /var/lib/steid/ backup-host:/backups/steid/ |
| 251 | systemctl start steid |
| 252 | ``` |
| 253 | |
| 254 | Stopping first is the honest version: SQLite in WAL mode leaves `-wal` and `-shm` files, |
| 255 | and copying them while a write is in flight can capture a torn state. If you would rather |
| 256 | not stop the service, snapshot the database properly and copy the repositories live — |
| 257 | they are only written during a push: |
| 258 | |
| 259 | ```sh |
| 260 | sqlite3 /var/lib/steid/steid.db ".backup '/tmp/steid-backup.db'" |
| 261 | rsync -a /tmp/steid-backup.db /var/lib/steid/repos backup-host:/backups/steid/ |
| 262 | ``` |
| 263 | |
| 264 | To restore: install Steid as above, stop it, drop both paths back into `/var/lib/steid`, |
| 265 | `chown -R steid:steid /var/lib/steid`, start it. Migrations run on boot, so a database |
| 266 | from an older version is brought forward automatically. |
| 267 | |
| 268 | ## Building from source |
| 269 | |
| 270 | ```sh |
| 271 | cargo install topcoat-cli --version 0.5.0 --locked |
| 272 | topcoat asset bundle --release |
| 273 | ``` |
| 274 | |
| 275 | **`cargo build --release` on its own is not enough.** It produces a binary that will not |
| 276 | boot: `AssetBundle::load()` walks up from the executable looking for |
| 277 | `assets/manifest.toml`, and `build.rs` does not write one. `topcoat asset bundle` runs |
| 278 | `cargo build` itself and then writes the bundle to `target/assets`. The binary at |
| 279 | `target/release/steid` needs that directory beside it. |
| 280 | |
| 281 | Requires rustc ≥ 1.95 — Topcoat 0.5 demands it, and on an older toolchain `cargo add |
| 282 | topcoat` silently resolves to an empty `v0.0.0` placeholder instead of failing. |
| 283 | |
| 284 | For development, `topcoat dev` does the bundling for you, and a local `.env` with |
| 285 | `STEID_INSECURE_COOKIES=true` is needed for the session cookie to survive plain-HTTP |
| 286 | localhost. |
| 287 | |
| 288 | ### Cutting a release |
| 289 | |
| 290 | ```sh |
| 291 | ./release.sh --target x86_64-unknown-linux-musl |
| 292 | ``` |
| 293 | |
| 294 | Produces `dist/steid-<version>-<target>.tar.gz` and a `.sha256` beside it. The tarball |
| 295 | extracts to a self-contained directory: the binary, `assets/`, and this README. Building |
| 296 | a Linux artefact on macOS needs a container (the script uses Docker) or a cross |
| 297 | toolchain; `--native` builds for the host instead, which is useful for checking the |
| 298 | artefact layout and not for releasing. |
| 299 | |
| 300 | A container image also exists — see [`Dockerfile`](Dockerfile) — but the supported |
| 301 | artefact is the plain binary. |
| 302 | |
| 303 | ## Current limitations |
| 304 | |
| 305 | Deliberately blunt. Steid is early. |
| 306 | |
| 307 | - **Single user.** One account, claimed on first run. No registration, no invites, no |
| 308 | organizations yet. |
| 309 | - **No TLS of its own.** A reverse proxy is mandatory, not recommended — see |
| 310 | [requirements](#requirements). |
| 311 | - **No encryption at rest.** The database and repositories are plain files. Anyone with |
| 312 | the disk has everything. Token *values* are hashed, and passwords are Argon2, but |
| 313 | repository contents are not encrypted. |
| 314 | - **Rate limiting covers `/auth/login` and `/auth/setup` only** — 10 attempts a minute |
| 315 | per client, with a global backstop. Token authentication on the git routes is *not* |
| 316 | limited; a token is 256 bits, so guessing is not the concern there, but unbounded |
| 317 | hashing on an open endpoint still is. |
| 318 | - **Rate limiting keys on forwarded headers, because Topcoat 0.5 does not expose the |
| 319 | peer address at all.** Behind a reverse proxy — the supported deployment — that works. |
| 320 | Exposed directly to the internet with no proxy, a caller can vary the header and get a |
| 321 | fresh budget each time, leaving only the global cap. Run it behind the proxy. |
| 322 | - **Tokens never expire** and carry no "last used" timestamp, which makes it hard to know |
| 323 | which are safe to revoke. |
| 324 | - **No CI/CD**, no issues, no pull requests, no code review, no SSH transport, no |
| 325 | webhooks, no federation. |
| 326 | - **No writing yet** — posts and markdown are the next milestone, and they are the point |
| 327 | of the whole thing. |
| 328 | - **Light mode is untested.** It is defined; nobody has looked at it. |
| 329 | - **Not battle-tested.** It has not run under load, has not been audited, and has been |
| 330 | deployed by approximately one person. Do not put anything irreplaceable in it that is |
| 331 | not also somewhere else. |
| 332 | |
| 333 | More, in unflattering detail, in [`plans/current.md`](plans/current.md). |
| 334 | |
| 335 | ## Project layout |
| 336 | |
| 337 | `plans/` is the source of truth for intent, not the code: |
| 338 | |
| 339 | | File | Holds | |
| 340 | |---|---| |
| 341 | | [`plans/ROADMAP.md`](plans/ROADMAP.md) | vision, stack, the milestone ladder | |
| 342 | | [`plans/current.md`](plans/current.md) | the active milestone, and every known gap | |
| 343 | | [`plans/progress.md`](plans/progress.md) | what shipped, and the decisions worth not rediscovering | |
| 344 | | [`plans/architecture.md`](plans/architecture.md) | layer rules and conventions | |
| 345 | | [`plans/decisions/`](plans/decisions/) | ADRs | |
| 346 | |
| 347 | The code is a single crate in three layers — `domain`, `application`, `infrastructure` — |
| 348 | with dependencies pointing inward. Every use case takes an `Actor` and authorizes before |
| 349 | any side effect. |