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, open sign-up behind a 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, CAPTCHA_TYPE |
true, image |
built-in image captcha on sign-up, no third party |
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 |
lax |
webhooks may call public hosts only, not your LAN or loopback |
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, captcha on sign-up,
backup. Then it stops Gitea and deletes everything.
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 overrides Gitea 1.23.x's repo list so that
.profile contact-card repos render after the other repos on each page (explore, user and org
pages). Copy it to $GITEA_CUSTOM/templates/explore/ and restart Gitea. The reorder is per page,
so it is exact while all repos fit on one page ([ui] EXPLORE_PAGING_NUM, default 20).