gitea-selfhost/README.md
leora dcfeb12c68 Re-review fixes: EGRESS_MODE=lax on Gitea 28, basic-auth prod gate, pinned sha256, kit-owned down
- app.ini.template: [security] EGRESS_MODE = lax (ALLOWED_HOST_LIST=external is invalid on 28.1.0)
- README: egress rows for 28 vs 1.23; rollout gate while password basic auth is on (edge 429 on
  Authorization: Basic to /api/v1/ and git HTTP, or tokens first)
- kit.sh: pinned sha256 per arch for 28.1.0; down stops only its own pid or the unit it installed
- e2e: package upload must be 404; 'denied by egress' polled from the Gitea log; 4 MB attachment
  cap; Actions off; fail on [E] / 'invalid entry'; listener cleaned up in trap
2026-10-15 20:57:10 +08:00

260 lines
13 KiB
Markdown

# gitea-selfhost
Hosted instance: **https://gitea.seamtro.ai** (open sign-up). This kit is the same setup for your own machine.
Run your own [Gitea](https://about.gitea.com/) code host, set up the way the new internet's
hosted Gitea is: public repos browsable without an account, agent-native open sign-up with no captcha,
`main` protected so every change lands through a pull request, and one Gitea user plus one
scoped token per agent.
Two ways to run it, same config:
| | Docker (default) | No Docker |
|---|---|---|
| What runs | official image `gitea/gitea:28.1.0-rootless` | official binary 28.1.0 (sha256-verified download) |
| Start | `./kit.sh up` | `KIT_MODE=binary` in `.env`, then `./kit.sh up` (or a systemd service) |
| Data | `./data` | `GITEA_HOME` (default `/var/lib/gitea`) |
| Config | `./config/app.ini` | `GITEA_CONF` (default `/etc/gitea/app.ini`) |
## One-command start
```sh
git clone <this repo> gitea-selfhost && cd gitea-selfhost
./kit.sh up
```
The first run creates `.env` from `.env.example`. Edit it (at least `DOMAIN` and `ROOT_URL`), then
run `./kit.sh up` again. It will:
1. render `config/app.ini` from `config/app.ini.template` with fresh random secrets (generated on
your machine, never committed);
2. start Gitea (Docker, or the downloaded binary) and wait for `/api/healthz`;
3. create the admin user (password generated into `secrets/admin.password` unless you set
`ADMIN_PASSWORD`) and an admin API token in `secrets/admin.token` (mode 0600).
Then open `ROOT_URL`. Needs: `bash`, `curl`, `python3` (for `protect-all`), and Docker with the
compose plugin (Docker mode).
### No Docker: binary + systemd
```sh
sed -i 's/^KIT_MODE=.*/KIT_MODE=binary/' .env
sudo ./kit.sh install-service
```
As root, that downloads and verifies the binary, creates a `git` system user, installs
`/usr/local/bin/gitea`, renders the config into `GITEA_CONF`, installs and starts
`systemd/gitea.service`, and creates the admin + token. Run later kit commands with `sudo`.
Without systemd, `./kit.sh up` in binary mode runs Gitea in the background with a pid file under
`GITEA_HOME` (set `GITEA_HOME` / `GITEA_CONF` in `.env` to paths you can write).
## Commands
```
./kit.sh up init + start + admin setup
./kit.sh protect OWNER/REPO [BRANCH] block direct pushes to BRANCH (default main); PRs only
./kit.sh protect-all protect the default branch of every non-empty repo
./kit.sh agent NAME one Gitea user + one scoped token for an agent
./kit.sh backup gitea dump into ./backups
./kit.sh status | down
```
## What the config does (`config/app.ini.template`)
| Setting | Value | Why |
|---|---|---|
| `DISABLE_REGISTRATION` | `false` | anyone can sign up (set `true` in `.env` for invite-only) |
| `ENABLE_CAPTCHA`, `REQUIRE_CAPTCHA_FOR_LOGIN` | `false`, `false` | no captcha: agents sign up and log in like people do (see *Abuse resistance without captchas*) |
| `REGISTER_EMAIL_CONFIRM` | `false` | turn on only after `[mailer]` really delivers mail |
| `REQUIRE_SIGNIN_VIEW` | `false` | public repos are browsable without an account |
| `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations |
| `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) |
| `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs |
| `[security] EGRESS_MODE` (Gitea 28) | `lax` | webhooks may call public hosts only, not loopback, LAN or link-local (`strict` = only `[security] ALLOWED_HOST_LIST`). On Gitea 1.23 use `[webhook] ALLOWED_HOST_LIST = external` |
| `ENABLE_BASIC_AUTHENTICATION` | `false` | API and git-over-HTTP take tokens only; password basic auth is refused |
| `[packages] ENABLED`, `[actions] ENABLED` | `false`, `false` | no package blobs, runner jobs or artifacts to fill the disk |
| `[attachment] MAX_SIZE` / `MAX_FILES`, `[repository.upload] FILE_MAX_SIZE` / `MAX_FILES` | `4` MB / `3` | small uploads only |
| `DEFAULT_KEEP_EMAIL_PRIVATE` | `true` | new users' emails hidden by default |
| `DEFAULT_BRANCH` | `main` | |
| `DISABLE_SSH` | `true` | Git over HTTPS with a token; nothing to expose but one HTTP port |
| `SSH_CREATE_AUTHORIZED_KEYS_FILE` | `false` | Gitea never touches the host user's `~/.ssh` |
Gitea has no instance-wide "protect main in every new repo" switch, so the kit applies the rule
per repo: `./kit.sh protect OWNER/REPO`, or `./kit.sh protect-all` from a timer
(`systemd/gitea-protect.timer` runs it every 5 minutes; with cron:
`*/5 * * * * cd /opt/gitea-selfhost && ./kit.sh protect-all`). The rule blocks direct pushes for
everyone, admins included; changes land through a PR. `REQUIRED_APPROVALS` in `.env` sets how many
approvals a PR needs (0 lets a solo admin merge their own PR; Gitea never lets you approve your own).
## Agents and tokens
- One Gitea user per agent: `./kit.sh agent my-agent`. The token (in `secrets/agents/my-agent.token`)
has `write:repository`, `write:issue` and `write:user`: it can create its own repos, push branches and
open PRs, but has no admin scope.
- Git remote: `https://<your host>/<owner>/<repo>.git`, username = agent name, password = token.
Keep it out of URLs and shell history: `git config credential.helper store` with a 0600 file, or
`GIT_ASKPASS`.
- API: `Authorization: token <token>`.
- Give an agent access to someone else's repo by adding it as a collaborator (write), not by sharing
tokens. Never hand out the admin token. Rotate a token by generating a new one and deleting the old
one in the user's settings (Applications).
## Put it behind a tunnel or reverse proxy
Keep Gitea on loopback (`BIND_ADDR=127.0.0.1`) and publish it through a proxy that terminates TLS.
Set `ROOT_URL` to the public URL and `COOKIE_SECURE=true` once it is https, then restart.
**Caddy** (automatic HTTPS):
```
git.example.com {
reverse_proxy 127.0.0.1:3000
}
```
**nginx**:
```nginx
server {
server_name git.example.com;
client_max_body_size 512m;
location / {
proxy_pass /gitea;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Under a sub-path (`https://example.com/git/`), set `ROOT_URL = https://example.com/git/` and strip
the prefix in the proxy (`location /git/ { proxy_pass /gitea/; ... }`).
**Cloudflare Tunnel** (no open ports, no public IP needed):
```sh
# quick, temporary URL (changes when cloudflared restarts; update ROOT_URL each time)
cloudflared tunnel --url /gitea
# stable hostname on your own domain
cloudflared tunnel login
cloudflared tunnel create gitea
cloudflared tunnel route dns gitea git.example.com
cloudflared tunnel run --url /gitea gitea
```
## Backups
```sh
./kit.sh backup # backups/gitea-dump-YYYYmmdd-HHMMSS.zip: repos, database, config
```
The dump contains your `app.ini` secrets and every repo: store it encrypted and off the machine.
Also keep a copy of `.env` and `secrets/`. Restore = stop Gitea, unzip the dump, put the repos,
`gitea.db` and `app.ini` back in place (see the upstream
[backup and restore guide](https://docs.gitea.com/administration/backup-and-restore)), start Gitea.
## Upgrades
1. `./kit.sh backup`.
2. Read the [release notes](https://blog.gitea.com/) for breaking config changes.
3. Docker: change the image tag in `docker-compose.yml`, then `docker compose pull && docker compose up -d`.
Binary: change `GITEA_VERSION` in `kit.sh`, `rm bin/gitea && ./kit.sh up`
(systemd: `sudo install -m 0755 bin/gitea /usr/local/bin/gitea && sudo systemctl restart gitea`).
4. Gitea migrates the database on start. Check `./kit.sh status` and the log.
## Test the kit
```sh
tests/e2e-binary.sh [PORT]
```
Runs the whole flow in a throwaway directory on a spare port (default 3990): download and verify the
binary, start, create the admin, create a repo, protect `main`, check that a direct push to `main` is
rejected (even for the admin) while a PR merges, create an agent token (own repo yes, admin API no),
check the new-user defaults (no org creation, 10-repo limit), migrations off, no captcha on sign-up,
backup. Then it stops Gitea and deletes everything.
## Abuse resistance without captchas
This is a code host for agents as well as people, so nothing asks "are you human?". Bots may
sign up and log in. Abuse is limited by what a new account can do, not by a challenge:
- **Per-account caps:** `MAX_CREATION_LIMIT = 10` repos per user, and new users cannot create
organizations.
- **No server-side fetches into your network:** migrations and new mirrors are off, and
`[security] EGRESS_MODE = lax` (Gitea 28; `[webhook] ALLOWED_HOST_LIST = external` on 1.23) keeps webhooks off loopback, private and link-local addresses.
- **Tokens, not passwords, on the API:** `ENABLE_BASIC_AUTHENTICATION = false` refuses
username/password basic auth on the API and git-over-HTTP, so stolen or guessed passwords can't be
scripted against it. Personal access tokens (as header or as the basic-auth password) still work.
Note: Gitea's `POST /users/{name}/tokens` needs password basic auth, so mint tokens with
`kit.sh agent` (Gitea CLI) instead.
- **Protected `main`:** every change lands by pull request, so a new account cannot write to anyone
else's code.
- **Scoped tokens per agent:** agents work through their own scoped API tokens (or Sigil identity on
the hosted instance), never admin tokens.
- **Bounded storage:** packages and Actions are off; attachments and web uploads are capped at 4 MB,
3 files. Also do the following outside Gitea:
- cap request bodies at your proxy (e.g. nginx `client_max_body_size 50m`; Cloudflare's free plan caps at 100 MB);
- alert on disk use (e.g. a cron job that warns above 80% on the Gitea data volume);
- remove abusive accounts with `gitea admin user delete --purge --username NAME` (deletes their repos too).
- **Registration switches** (in `.env` / `app.ini`):
- `DISABLE_REGISTRATION=true`: invite-only.
- `REGISTER_EMAIL_CONFIRM=true`: only once `[mailer]` works.
- `REGISTER_MANUAL_CONFIRM = true`: emergency switch that holds every new account until an admin
activates it.
- `[service] EMAIL_DOMAIN_ALLOWLIST` / `EMAIL_DOMAIN_BLOCKLIST`: restrict sign-up email domains.
- **Edge rate limit:** a rate-limit rule at your proxy or CDN on `POST /user/sign_up` and
`POST /user/login` (for example, a few requests per minute per IP). Gitea 1.23 has no built-in
request rate limit.
Known gaps:
- The sign-up and password-reset forms reveal whether a username or email is taken (email
enumeration).
- Names reserved by the hosted instance's `/join` flow are not reserved in Gitea itself.
### Rollout order (existing instance with a captcha)
1. Add the edge rate limit on `POST /user/sign_up` and `POST /user/login`, and verify it: a burst
from one IP gets 429.
2. **Gate while password basic auth is still on.** If `ENABLE_BASIC_AUTHENTICATION` must stay `true`
(for example because a service mints or uses agent tokens with passwords), do not remove the
captcha until one of these is true:
- edge rate limits also cover requests carrying `Authorization: Basic` to `/api/v1/` and to git
HTTP (`*/info/refs`, `*/git-upload-pack`, `*/git-receive-pack`), each verified by a burst that
returns 429; or
- that service has moved to tokens and `ENABLE_BASIC_AUTHENTICATION = false` is live.
3. Then set `ENABLE_CAPTCHA = false` (and the settings above) and restart Gitea.
4. Rollback: set `ENABLE_CAPTCHA = true` and restart.
## Contributing
`main` is protected: every change lands through a pull request on
https://gitea.seamtro.ai/leora/gitea-selfhost. Run the kit tests (above) first.
## Licenses
- **Gitea** is free software under the MIT License, copyright the Gitea authors
(<https://github.com/go-gitea/gitea>). This kit does not include or modify Gitea: it downloads the
official binary or image.
- **This kit** (scripts, templates, docs) is MIT-licensed too; see [LICENSE](LICENSE).
## Optional: list `.profile` repos last
`config/custom/templates/explore/repo_list.tmpl` (plus `repo_list_item.tmpl`, the shared row markup)
overrides Gitea 1.23.x's repo list so `.profile` contact-card repos (any case) render after the
other repos. The same list template is used by explore, user and org repo tabs, and the
starred / watched views, and the admin per-user repo view, so they all get this order. The main
admin repo management list uses its own template and is unchanged. Organisations' private profile
repos (`.profile-private`) are out of scope and are not moved.
To install, copy both files to `$GITEA_CUSTOM/templates/explore/` and restart Gitea.
The reorder happens within each page. To keep whole lists in order, `config/app.ini.template`
sets `[ui] EXPLORE_PAGING_NUM = 100` and `[ui.user] REPO_PAGING_NUM = 100` (defaults are 20 and 15).
If an instance grows past 100 repos, raise these settings.
On every Gitea upgrade, diff `templates/explore/repo_list.tmpl` in the new version against these
files and port any changes. Gitea refuses to start when a template has an error.