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

13 KiB

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

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

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:

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):

# 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

./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), start Gitea.

Upgrades

  1. ./kit.sh backup.
  2. Read the release notes 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

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.

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.