Tahinli/forge
publicREADME.md
forge
forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released.
It runs as a single process (forge) that serves the web UI, git over HTTPS (smart HTTP), and git over SSH. Repositories are plain bare git repositories on disk. Account and repo metadata live in a SQLite/Turso database.
Status
forge is at Milestone 2 ("repo browsing and raw files"). It currently provides:
- local accounts (argon2id passwords), with the first user created as admin
- sessions, CSRF protection on every POST, and a
__Host-session cookie - SSH public keys and personal access tokens (
forge_...) - creating repos, with public or private visibility, collaborators, and an archive flag
- clone and push over HTTPS and SSH
- basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes
- repo browsing: the repo home page with the rendered README, directory listings with the last commit per entry, file views with syntax highlighting and
#L12line anchors, Markdown rendering with a source toggle, commit history (paginated), commit views with diffs, branch and tag lists, and.tar.gz/.ziparchives - raw files served from a separate user-content origin (see Raw user content)
Not yet: issues, pull requests, webhooks, mirrors, CI. See Roadmap.
Requirements
- Rust stable, edition 2024
- git 2.38 or newer (boot refuses to start with an older git)
Quick start
-
Copy the example config. forge reads
config/forge.tomlby default.cp config/forge.example.toml config/forge.toml -
Start the server.
cargo run -p forge-web -
Create the admin account. Either open http://127.0.0.1:7660/setup (it works only while there are zero users), or use the CLI:
cargo run -p forge-web -- admin create-user <username> --adminThe CLI reads the password from
FORGE_PASSWORDif set, or prompts for it twice. Passwords must be at least 12 characters. The first user is always an admin, even without--admin. -
Add an SSH key at
/settings/keys. Use an ed25519 key:ssh-keygen -t ed25519Paste the contents of the
.pubfile. RSA keys are rejected with a message asking for ed25519. The reason is thatssh-rsakeys sign with SHA-1, and forge disables SHA-1 signatures. ECDSA and security-key (sk-) variants are also accepted by the form. -
To change only the capitalization of your username (for example
tahinlitoTahinli), open/settings/profile. Any other change is refused. The instance operator can do the same from a shell withcargo run -p forge-web -- admin set-username <current> <NewCase>. -
Create an access token at
/settings/tokens. The token starts withforge_and is shown once. Store it somewhere safe. -
Account security lives at
/settings/security:- Password: change it with the current password. The new one needs 10 to 1024 characters. Your other sessions are signed out; the one you are using stays signed in. Wrong current passwords count toward the same per-user login limit as failed logins.
- Email: change it with the current password. The address must contain one
@, have no spaces, and be at most 254 characters. - Two-factor authentication (TOTP): click "Enable 2FA", add the secret or
otpauth://link to an authenticator app, and confirm with a 6-digit code. Ten single-use recovery codes are then shown once, so save them. Turning 2FA off needs the password and a code. Regenerating recovery codes needs a code. Codes are TOTP (RFC 6238, SHA-1, 6 digits, 30-second steps), and a code from one step either side of the current time is accepted. Each code works only once. With 2FA on, a correct password leads to/login/2fa, which asks for a code or a recovery code. The pending login lasts 5 minutes. Each user gets 10 second-factor attempts per 15 minutes. - Git over HTTPS is unaffected by 2FA. It authenticates with access tokens (step 6), not with passwords or TOTP codes. Revoke a token at
/settings/tokensif it leaks.
-
Create a repo at
/new, then push over either transport:# SSH git remote add origin ssh://git@127.0.0.1:2222/<owner>/<repo>.git git push -u origin main # HTTPS: username and token as the password git remote add origin http://127.0.0.1:7660/<owner>/<repo>.git git push -u origin main
Configuration
The file is TOML. Every key in config/forge.example.toml is listed below. Paths are relative to the working directory unless absolute.
| Key | Example value | Meaning |
|---|---|---|
database |
"./data/forge.db" |
SQLite/Turso database file. admin create-user creates its parent directory. |
data_dir |
"./data" |
Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. |
listen |
"127.0.0.1:7660" |
HTTP listen address for the web UI and smart HTTP. |
base_url |
"http://127.0.0.1:7660" |
Public base URL used in links and clone URLs. Set this to your public https:// URL in production. |
user_content_base_url |
"http://127.0.0.1:7661" |
Separate origin for raw user content. Set it to a different registrable domain in production (see Raw user content). |
user_content_listen |
"127.0.0.1:7661" |
Address of the listener that serves raw user content. Your reverse proxy forwards the user-content hostname to it. |
ssh_listen |
"0.0.0.0:2222" |
SSH listen address for git over SSH. |
ssh_public_host |
"127.0.0.1" |
Host shown in SSH clone URLs. |
ssh_public_port |
2222 |
Port shown in SSH clone URLs. |
registration |
"closed" |
Self-registration policy: closed, invite, or open. Default is closed. M1 has no sign-up page, so accounts come from /setup, the CLI, or /admin/users. |
trusted_proxies |
[] |
Addresses or CIDR networks (for example ["127.0.0.1"]) whose X-Forwarded-For header is trusted. Set it to the address your reverse proxy connects from. Empty means the TCP peer is the client, so a spoofed header cannot change the client IP. Used for login and git throttling. |
Production config normally lives at /etc/forge/forge.toml (see the systemd unit below).
Command line and environment
forge [--config <path>] [serve | hook <pre-receive|post-receive> | runner <register|run|check> ... | admin create-user <username> [--admin] | admin set-username <current> <NewCase> | admin reset-password <username> | admin trash-list | admin trash-purge --older-than-days <N> | admin audit-purge --older-than-days <N> | admin delete-user <username> [--yes] | admin backup --out <DIR> [--include-key] [--repos-only | --db-only] | admin backup-verify <PATH> | admin fsck [--full] | admin lfs-gc | admin restore --from <SET> --data-dir <NEW_DIR>]
--config <path>sets the config file. Precedence:--config, thenFORGE_CONFIG, thenconfig/forge.toml.serve(the default when no subcommand is given) starts the server.hook <stage>is called by git hook shims. You do not run it by hand.admin create-user <username> [--admin]creates a user from the CLI.admin set-username <current> <NewCase>changes only the capitalization of a user's username (currentmatches any case). Example:forge admin set-username tahinli Tahinli.admin reset-password <username>sets a random 20-character temporary password, prints it once, and signs the user out of every session. Give the password to the user, who should change it at/settings/security. Nothing else changes. Use it when a user is locked out of their account. Two-factor settings are not changed, so the user still needs a code.admin trash-listlists deleted repos indata_dir/trash, oldest first, as<deleted_at>\t<repo id>\t<path>.admin trash-purge --older-than-days Npermanently removes trash entries deleted at least N days ago. Nothing else is removed, and this cannot be undone.FORGE_PASSWORDsupplies the password foradmin create-userwithout a prompt.RUST_LOGsets the log filter (defaultinfo). Logs go to stderr.GET /healthzreturnsok <build sha>.
Data layout
Everything under data_dir:
| Path | Purpose |
|---|---|
forge.db, forge.db-wal, forge.db-shm |
Metadata database (users, sessions, keys, tokens, repos). Sits next to database in the example config. |
forge.key |
Instance key. Encrypts sealed values. Created on first boot. |
ssh_host_ed25519_key |
SSH host key. Created on first boot. |
gitconfig |
Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. |
hooks/ |
pre-receive and post-receive shims that run forge hook <name>. Rewritten at boot. |
repos/ab/cd/<id>.git |
Bare repositories. <id> is the lowercased ULID; the path does not depend on the repo name. |
internal.sock |
Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. |
trash/<id>-<unix_ts>.git |
Repos deleted from the settings page (/{owner}/{repo}/settings). Each is a plain bare repository, moved here by a rename. The database rows are gone, so the repo no longer appears in forge. |
Recovering a deleted repo
A deleted repo is not erased at once. Its bare directory is moved to data_dir/trash/, and only admin trash-purge removes it. To recover it, list the trash with forge admin trash-list and clone from the directory, which works without the server:
git clone --mirror /var/lib/forge/trash/<id>-<unix_ts>.git recovered.git
The clone restores all branches and tags. The owner, visibility, description and collaborators are not restored, so re-create the repo under the same name and push the mirror to it with git push --mirror.
Backups
See docs/backup.md for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill.
-
forge.keymust be backed up separately and stored safely, offline. Without it, sealed values cannot be decrypted, and they are lost. Keep it out of the same backup set as the database if you can. -
Never copy a live
forge.db(or its-wal/-shmfiles) withcporrsync. Take a consistent snapshot withVACUUM INTO, for example from thesqlite3shell while forge is running:VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; -
Repositories: do not copy bare repos while pushes are in progress. Use
git bundle create --allorgit push --mirrorto a copy. -
Unix socket path length:
data_dir/internal.sockmust be shorter than 108 bytes, the unix socket path limit. Boot fails with an error otherwise. Choose a shortdata_dir(for example/var/lib/forge).
Deployment
systemd
deploy/forge.service runs /usr/local/bin/forge serve as a dedicated forge user, with FORGE_CONFIG=/etc/forge/forge.toml, working directory /var/lib/forge, and a hardened sandbox (ProtectSystem=strict, /var/lib/forge and, if present, /var/backups/forge writable, no capabilities, UMask=0077).
Typical setup:
sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge
sudo install -d -o forge -g forge /var/lib/forge /etc/forge
sudo install -m 0640 -g forge config/forge.example.toml /etc/forge/forge.toml
sudo install -m 0755 target/release/forge /usr/local/bin/forge
sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service
sudo systemctl daemon-reload && sudo systemctl enable forge
Edit /etc/forge/forge.toml before starting:
base_url: publichttps://URL.user_content_base_url: a separate registrable domain, not a subdomain ofbase_url.ssh_public_hostandssh_public_port: public hostname and port.databaseanddata_dir: absolute paths under/var/lib/forge.trusted_proxies = ["127.0.0.1"]when the reverse proxy runs on this host. Otherwise every client shares the proxy's IP for login and git throttling.[backup] dir: under/var/lib/forgeor/var/backups/forge. The unit makes only those writable. Create the latter withsudo install -d -o forge -g forge -m 0700 /var/backups/forge.
Create the admin before starting. /setup lets the first visitor claim the admin account while there are no users, so create the admin before the reverse proxy exposes the site.
sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user <name> --admin
sudo systemctl start forge
Open the SSH port (2222 by default) in the firewall.
Reverse proxy and TLS
Run forge behind a TLS reverse proxy for any non-localhost use. The session cookie is named __Host-forge_session and is marked Secure, so browsers send it only over HTTPS. Logging in over plain HTTP outside localhost will not work. Set base_url to the public HTTPS URL.
Git routes need a proxy that does not buffer or time out early. The example below targets Ferron 3.0.0-rc.7, the newest release found (a pre-release; the latest stable, 2.8.1, was not checked against this config). Ferron uses its own ferron.conf format, not KDL.
- Buffering:
buffer_requestandbuffer_responseare off by default. The example sets both to0so they stay off, because git smart HTTP streams packs in both directions. - Timeouts:
connection_timeout(default 2s) andidle_timeout(default 60s) are per upstream. The example raisesidle_timeoutto3600s, becausegit upload-packcan stay silent while it builds a pack. Ferron's proxy docs list no total-request timeout, so there is no separate read/send timeout to raise. - Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (
/<owner>/<repo>/git-receive-pack,git-upload-pack). - Forwarded headers: Ferron sets
X-Forwarded-For,X-Forwarded-Proto,X-Real-IPandForwardeditself and keeps the incomingHost, so the example adds no header lines. - SSH: port 2222 is not proxied. Ferron handles HTTP(S) only, so forge serves SSH directly on
ssh_listen. Open that port on the host.
Brute-force throttling
Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window:
- Login: 10 failed attempts per username, and 30 per client IP. A throttled attempt gets HTTP 429 with the message "Too many attempts. Try again later.", and the password is not checked. A successful login clears that username's counter.
- Git HTTP (Basic auth with a token): 30 failed attempts per client IP. The next request gets HTTP 429 with
Retry-After. Anonymous requests and successful logins are not counted. - The client IP is the TCP peer, unless the peer is in
trusted_proxies. Then it is the right-mostX-Forwarded-Foraddress that is not a trusted proxy. IPv6 addresses are counted per /64. - Throttling events are logged at
warnwith the IP and the folded username. Counters are lost on restart and are not shared between instances.
Raw user content
forge runs a third listener, user_content_listen (default 127.0.0.1:7661). It serves only the raw files of repos, on the origin user_content_base_url. It has its own explicit routes and does not share the app's router.
Put it on its own registrable domain in production, for example usercontent.example.net next to forge.example.com. Browsers treat that as a separate site, so a file a user uploads cannot run script against the app origin. The raw origin is also built to be inert:
- No cookies are read or set on it, so app sessions never reach it.
- Every response has
Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox,X-Content-Type-Options: nosniff, andReferrer-Policy: no-referrer. - SVG, HTML, XML and other non-image binaries are sent with
Content-Disposition: attachment.
Private repos need a signed link. The app puts a ?token= on raw links for private repos, and that token is valid for 10 minutes. Public repos need no token. A missing, hidden, or bad token gets 404.
Example Ferron 3.0.0-rc.7 config (ferron.conf) with two hostnames. A bare host block already gets automatic ACME TLS; the tls block only sets the account contact:
forge.example.com {
tls {
provider acme
challenge http-01
contact admin@example.com
}
buffer_request 0
buffer_response 0
proxy {
upstream http://127.0.0.1:7660 {
idle_timeout "3600s"
}
}
}
usercontent.example.net {
tls {
provider acme
challenge http-01
contact admin@example.com
}
buffer_request 0
buffer_response 0
proxy {
upstream http://127.0.0.1:7661
}
}
The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies.
With that setup, set base_url = "https://forge.example.com" and user_content_base_url = "https://usercontent.example.net".
Limits
- Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file.
- Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting.
- Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed.
- Commit diffs: at most 300 files, 2,000 lines per file, and 20,000 lines in total. Anything past that is cut off with a notice.
Compression is currently off for every route. Git smart-HTTP responses must not be compressed, and the server cannot yet switch compression off per route, so it is disabled globally (TODO(M1) in boot.rs). Let the proxy do no compression on git paths either.
Security notes
From docs/architecture.md. These are the properties forge is designed for. Not all are fully implemented in M1.
- Authorization goes through a single function,
authorize(). A private repo that the actor cannot see returns 404, so its existence is not revealed. - Passwords use argon2id. Tokens and session tokens are stored as SHA-256 hashes and compared in constant time. Tokens are shown once and kept out of URLs and logs.
- SSH keys must match exactly.
ssh-rsawith SHA-1 is disabled, and the SSH server accepts onlyGIT_PROTOCOLfrom env requests. Handshake and inactivity timeouts apply. - No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments.
- Git runs with
receive.fsckObjects=true, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected. - Users cannot write hooks or repo-local config. Submodules are not recursed.
- Run as a dedicated service user, and keep the git binary patched.
- Keep
forge.keyanddata_dirprivate. Back upforge.keyseparately.
Roadmap
From docs/architecture.md (section 10).
- M1: clone and push work. Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI.
- M2 (current): repo browsing and raw domain. Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on
user_content_base_url. - M3: issues. Issues, comments, labels, milestones, with permissions through
authorize(). - M4: pull requests. PR refs, mergeability via
merge-tree, reviews, merge strategies. - M5: webhooks, push mirrors, replication, backups, export. Including
VACUUM INTOsnapshots, integrity checks, and restore drills. - M6: custom CI runner and commit-status API. Required checks enforced on push as well as PR merge.
License
forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later).
Fonts
The web UI bundles two fonts, served from the forge origin under /assets/fonts/: Figtree (UI) and IBM Plex Mono (code). Both are licensed under the SIL Open Font License 1.1. The license texts are in style/fonts/ and are also served at /assets/fonts/Figtree-OFL.txt and /assets/fonts/IBMPlexMono-OFL.txt. Sources, pinned versions and checksums are listed in style/fonts/README.md.