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:
- render
config/app.inifromconfig/app.ini.templatewith fresh random secrets (generated on your machine, never committed); - start Gitea (Docker, or the downloaded binary) and wait for
/api/healthz; - create the admin user (password generated into
secrets/admin.passwordunless you setADMIN_PASSWORD) and an admin API token insecrets/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 (insecrets/agents/my-agent.token) haswrite:repository,write:issueandwrite: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 storewith a 0600 file, orGIT_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
./kit.sh backup.- Read the release notes for breaking config changes.
- Docker: change the image tag in
docker-compose.yml, thendocker compose pull && docker compose up -d. Binary: changeGITEA_VERSIONinkit.sh,rm bin/gitea && ./kit.sh up(systemd:sudo install -m 0755 bin/gitea /usr/local/bin/gitea && sudo systemctl restart gitea). - Gitea migrates the database on start. Check
./kit.sh statusand 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 = 10repos 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 = externalon 1.23) keeps webhooks off loopback, private and link-local addresses. - Tokens, not passwords, on the API:
ENABLE_BASIC_AUTHENTICATION = falserefuses 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'sPOST /users/{name}/tokensneeds password basic auth, so mint tokens withkit.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).
- cap request bodies at your proxy (e.g. nginx
- Registration switches (in
.env/app.ini):DISABLE_REGISTRATION=true: invite-only.REGISTER_EMAIL_CONFIRM=true: only once[mailer]works.REGISTER_MANUAL_CONFIRM = true: holds new web sign-ups until an admin activates them. It does not hold accounts created through the admin API and does not slow password guessing.[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_upandPOST /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
/joinflow are not reserved in Gitea itself.
Rollout order (existing instance with a captcha)
- Add the edge rate limit on
POST /user/sign_upandPOST /user/login, and verify it: a burst from one IP gets 429. - Gate while password basic auth is still on. If
ENABLE_BASIC_AUTHENTICATIONmust staytrue(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: Basicto/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 = falseis live.
- edge rate limits also cover requests carrying
- Then set
ENABLE_CAPTCHA = false(and the settings above) and restart Gitea. - Rollback: set
ENABLE_CAPTCHA = trueand 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.