josie / simplegit

Self-hosting simplegit

simplegit is one static binary: it serves smart-HTTP git and a server-rendered web UI. TLS is terminated by a reverse proxy; the binary listens on plain HTTP and requires the git binary on PATH at runtime.

1. Install (recommended)

scripts/install.sh sets everything up except the account (binary, system user, data dir, config, proxy example conf, systemd unit):

sudo scripts/install.sh https://git.example.com apache
# or: ... https://git.example.com caddy | none

doas works in place of sudo everywhere in this guide (BSD and alpine hosts ship it instead); the script only needs to run as root.

The second argument picks the reverse-proxy example conf, with the domain substituted: apache installs /etc/apache2/sites-available/simplegit.conf (and enables modules/site), caddy installs the site into the Caddyfile (or a Caddyfile.simplegit to import), none skips it. The unit is enabled but not started; the config is written only if absent.

Manual equivalent, if you prefer to do it by hand:

1a. Build

go build -ldflags "-s -w" -o /usr/local/bin/simplegit ./cmd/simplegit

The binary embeds templates and static assets, so there is nothing else to copy. It needs git (with http-backend) installed on the host. Stripping symbols/DWARF cuts the binary from ~28 MB to ~19 MB; plain go build works too, just bigger.

2. Config

Create simplegit.toml (see simplegit.toml.example). Every key is optional.

data_dir    = "/var/lib/simplegit"       # bare repos, SQLite DB, uploads
listen_addr = "127.0.0.1:8080"           # plain HTTP; the proxy fronts it
base_url    = "https://git.example.com"  # public URL; builds clone URLs/links

base_url must be the public HTTPS URL, or clone URLs and Secure session cookies will be wrong.

3. Account and data dir

simplegit adduser -config simplegit.toml <username>

Flags come before the positional username (Go's flag parsing stops at the first non-flag). In a terminal you get a hidden password prompt (asked twice); for scripting, pipe the password on stdin so it never appears in the process list:

printf '%s\n' "$PASSWORD" | simplegit adduser -config simplegit.toml <username>

serve and adduser create the data-dir layout on first run, mode 0700. If the directory already exists from an older install, tighten it once: chmod -R go-rwx /var/lib/simplegit. Setting UMask=0077 in the systemd unit keeps everything the service creates private as well.

4. Run it

simplegit serve -config /etc/simplegit/simplegit.toml

systemd unit

[Unit]
Description=simplegit
After=network.target

[Service]
ExecStart=/usr/local/bin/simplegit serve -config /etc/simplegit/simplegit.toml
Restart=on-failure
User=simplegit
Group=simplegit

[Install]
WantedBy=multi-user.target

Run it as a dedicated user that owns data_dir. It does not need root and binds only the loopback address the proxy forwards to.

5. Reverse proxy (TLS)

Caddy terminates TLS automatically:

git.example.com {
    reverse_proxy 127.0.0.1:8080
}

Two proxy requirements:

  • Do not rewrite or buffer git request/response bodies. Pushes stream a packfile both ways.
  • Do not pass X-Forwarded-For expectations to the app — the guest-write rate limiter keys on the socket RemoteAddr, so behind a proxy all clients share one bucket. (Trusting the forwarded header is a deliberate future decision, not current behavior.)

nginx: proxy_pass http://127.0.0.1:8080; with proxy_request_buffering off; and a generous client_max_body_size (pushes and release uploads can be large).

A request body that reaches the app while a response is being written is handled safely (the CGI stdin is drained before the response), but proxies that buffer or retry bodies are still discouraged.

Apache httpd + certbot

Apache streams request bodies by default, so large pushes work once the timeouts are raised. On Debian/Ubuntu:

apt install apache2 certbot python3-certbot-apache
a2enmod ssl proxy proxy_http headers rewrite

Point the DNS record at the host, then use a hand-owned vhost so certbot never rewrites your proxy config — /etc/apache2/sites-available/simplegit.conf:

<VirtualHost *:80>
    ServerName git.example.com
    DocumentRoot /var/www/html
    <Location "/.well-known/acme-challenge/">
        Require all granted
    </Location>
    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
</VirtualHost>

<VirtualHost *:443>
    ServerName git.example.com
    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/git.example.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/git.example.com/privkey.pem

    ProxyPreserveHost On
    ProxyRequests Off
    # big pushes / release uploads
    ProxyTimeout 600s
    Timeout      600
    # pass %2f through instead of 404ing
    AllowEncodedSlashes NoDecode

    # never re-encode the git stream
    SetEnv no-gzip 1
    SetEnv dont-vary 1
    # unlimited (the default, but explicit)
    LimitRequestBody 0

    ProxyPass        / http://[IP_ADDRESS]:8080/ retry=0
    ProxyPassReverse / http://[IP_ADDRESS]:8080/

    ErrorLog  ${APACHE_LOG_DIR}/simplegit-error.log
    CustomLog ${APACHE_LOG_DIR}/simplegit-access.log combined
</VirtualHost>
a2ensite simplegit && apachectl configtest && systemctl reload apache2
certbot certonly --webroot -w /var/www/html -d git.example.com

(certbot --apache -d git.example.com also works, but writes its own -le-ssl.conf that you would then have to add the proxy block to; the certonly route keeps the file yours.) Renewal is automatic via the systemd timer — verify with certbot renew --dry-run.

Apache-specific gotchas, all load-bearing:

  • ProxyPreserveHost On is mandatory, or Apache sends Host: [IP_ADDRESS] and clone URLs/redirects come out wrong.
  • SetEnv no-gzip 1 — mod_deflate re-encoding the streaming git response breaks some clients.
  • ProxyTimeout / Timeout default to 60s and kill big pushes.
  • AllowEncodedSlashes NoDecode — Apache's default 404s any URL with %2f.
  • Keep comments on their own lines. On a 2.4.66/Ubuntu build, inline # … comments on ProxyTimeout/AllowEncodedSlashes lines made apachectl configtest fail with "takes one argument" (the comment text parsed as a second argument); the same vhost passed once the comments moved to their own lines. The example above is comment-free on directive lines for that reason.
  • The rate limiter sees [IP_ADDRESS]. mod_proxy sets X-Forwarded-For automatically, but the app deliberately doesn't trust it (see above).
  • Firewall only 80/443; keep the app on loopback.

6. Dogfood (host simplegit on itself)

# create the repo in the web UI, then:
git remote add origin https://git.example.com/<user>/simplegit.git
git push -u origin main
git tag -a v1.0.0 -m "v1.0.0" && git push origin v1.0.0

Pushing an annotated tag publishes a release automatically: the title is the tag message's first line, the notes the rest. Attach binaries the same way you'd do it on any git host — no SSH, from the work machine:

git push -u origin main
git tag -a v1.0.0 -m "v1.0.0
the notes for this release"
git push origin v1.0.0
SIMPLEGIT_TOKEN=sg_... simplegit release -base https://git.example.com \
    -repo <user>/simplegit -tag v1.0.0 simplegit-v1.0.0.tar.xz

simplegit release posts the owner's git token (see Account settings → tokens) over the server's release API with any number of asset files; re-running it for the same tag re-attaches/updates assets in place. A lightweight tag gets a release with the tag name as title and no notes.

7. Local UAT (single machine, no proxy)

To exercise the whole UI and git flows locally, without TLS or a reverse proxy, use the helper script:

scripts/local-uat.sh            # default port 8090
scripts/local-uat.sh 9000       # or pick a port

It builds the binary, writes a throwaway config, creates the user (josie / hunter2), and serves on 127.0.0.1:<port>. The binary and the whole data dir live in .uat/ (gitignored); delete that directory to reset. Override with UAT_DIR, UAT_USER, UAT_PASS.

Manual equivalent:

go build -o .uat/simplegit ./cmd/simplegit
mkdir -p .uat/data
printf 'data_dir = ".uat/data"\nlisten_addr = "127.0.0.1:8090"\nbase_url = "http://127.0.0.1:8090"\n' > .uat/sg.toml
printf '%s\n' hunter2 | .uat/simplegit adduser -config .uat/sg.toml josie
.uat/simplegit serve -config .uat/sg.toml

base_url is plain http:// here, so the session cookie is not Secure — correct for localhost, wrong for production.

Create a repo in the UI, then push with:

git -c credential.helper= push http://josie:hunter2@127.0.0.1:8090/josie/<repo>.git main

8. Backups

Everything lives under data_dir:

  • repos/<user>/<repo>.git — the bare repositories (authoritative history)
  • simplegit.db (+ -wal, -shm) — users, sessions, tokens, issues, PRs, releases metadata
  • uploads/releases/<id>/ — release attachments

Back up all three together; the DB and uploads reference each other. SQLite is in WAL mode, so snapshot with sqlite3 simplegit.db ".backup <dest>" (or copy the DB plus its -wal) rather than copying a live file blindly.

9. Maintenance (gc)

simplegit gc audits data_dir: DB size, repo counts, release upload sizes, DB↔disk inconsistencies (repo rows without dirs, asset rows without files), orphaned upload files/dirs from crashed publishes, and expired sessions.

simplegit gc                 # read-only report
simplegit gc --clean         # also delete orphan uploads + expired sessions
simplegit gc --vacuum        # rewrite the DB, checkpoint the WAL

--vacuum is best run while serve is stopped:

systemctl stop simplegit
simplegit gc --clean --vacuum
systemctl start simplegit

gc never deletes bare repo directories that lack a DB row (an out-of-band git init by hand is not garbage it should auto-remove) — it reports them.