Gitea 自建 Git 服务实战:Docker 部署、Nginx 反代三个必配项、LFS 与 Webhook 自动部署闭环

代码放在云上,还是放在自己手里

个人站长和技术博主有一个被长期忽略的问题:你的代码、主题、配置、脚本到底存在哪里?大多数人的答案是一份 GitHub 或 Gitee 私有仓库。这在平时没问题,但有几种情况会让人立刻不安:

  • 私密内容不想交给第三方。服务器配置文件里往往带着 IP、端口、路径信息,甚至是某些服务的测试密钥。哪怕仓库是私有的,代码托管商仍然在技术上完全可读。
  • 仓库可能因政策或账号原因不可访问。账号被封、异地登录触发风控、平台调整服务策略——这类事情不常见,但一旦发生,你所有代码在那一刻都无法拉取。
  • CI 配额和环境不受控。私有仓库的 Actions 分钟数有限额,跑几个大项目就见底;构建环境的系统版本和依赖缓存也不完全由你决定。
  • 主题和插件的自动更新链路断了。如果你有自建的 WordPress 主题或 Typecho 插件,本来可以用 Git 做版本管理加自动部署,但在第三方平台上搭私有部署链路总要额外配置 Secret。

这些需求指向同一个结论:自己搭一个 Git 服务。这篇文章讲 Gitea——一个用 Go 写的自托管 Git 服务,单个二进制文件、内存占用常在几十 MB 级别、在 1 核 1G 的入门 VPS 上就能跑得很顺。相比 GitLab 那种动辄需要 4GB 内存起步的重量级方案,Gitea 才是个人站长真正用得起的自建 Git。

完整讲四件事:用 Docker 部署并配上 HTTPS、数据备份与恢复、从第三方平台迁移已有仓库、以及用 Webhook 做「推送即自动部署」的闭环。

一、部署前的容量与端口规划

自建 Git 最常见的失败不是安装失败,而是装完了发现磁盘和端口规划错了,迁移成本极高。先把这几件事定下来。

磁盘。Gitea 本体和数据库都很小,真正占空间的是仓库数据。经验值是:纯代码仓库 1GB 能放几百个;但如果仓库里混进了图片、字体、打包产物(*.zipnode_modules),体积会膨胀十倍以上。所以规划时按 5GB 起步 留,并且从一开始就配置 LFS(后文详述)。

端口。Gitea 需要两个入口:HTTP(默认 3000)和 SSH(默认 22)。SSH 端口这里有个必须提前决策的点——宿主机 22 端口已经被系统 SSH 占用了。两种解法:

  • 方案 A:宿主机 SSH 换成非标准端口(比如 2222),把 22 让给 Gitea。优点是 Git 的 SSH 地址看起来最干净(git@git.example.com:user/repo.git),缺点是改宿主 SSH 端口需要谨慎操作,改错就把自己关在门外。
  • 方案 B:宿主机 SSH 保持 22,Gitea 的 SSH 用 2222。缺点是所有 Git SSH 地址都要带端口:ssh://git@git.example.com:2222/user/repo.git。好处是零风险。

推荐方案 B。对一个自己用的服务,多写一个端口号远比冒险改宿主 SSH 端口划算。

独立数据库。Gitea 默认支持 SQLite,对个人使用完全够。但如果你的 VPS 上已经跑着 MySQL(比如站点的 Typecho 或 WordPress),直接复用 MySQL 更便于统一备份。这篇文章按 MySQL 独立库 的方案走。

二、用 Docker Compose 部署:一份可直接用的编排文件

用 Docker 部署的价值在于升级简单——换镜像 tag 重启即可,不用处理 Go 二进制与系统依赖的关系。下面是完整的 docker-compose.yml:

version: "3"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:1.22
    container_name: gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=mysql
      - GITEA__database__HOST=db:3306
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=改成你自己的强密码
      - GITEA__server__DOMAIN=git.example.com
      - GITEA__server__SSH_DOMAIN=git.example.com
      - GITEA__server__ROOT_URL=https://git.example.com/
      - GITEA__server__SSH_PORT=2222
      - GITEA__server__SSH_LISTEN_PORT=22
      - GITEA__service__DISABLE_REGISTRATION=true
      - GITEA__repository__DEFAULT_BRANCH=main
    restart: always
    networks:
      - gitea
    volumes:
      - ./gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:22"

  db:
    image: mysql:8.0
    container_name: gitea-db
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=改成你自己的root密码
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=与上面保持一致
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    volumes:
      - ./mysql:/var/lib/mysql
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --innodb-file-per-table=1

几处关键决策的解释:

127.0.0.1:3000:3000 而不是 3000:3000注意前面的 127.0.0.1:——它让 3000 端口只监听本机回环,公网无法直连。外部访问全部经由 Nginx 反代提供 HTTPS。如果写成 3000:3000,Docker 默认绑 0.0.0.0,等于把一个 HTTP 明文入口暴露到公网。

DISABLE_REGISTRATION=true自建 Git 服务一旦开放注册,会被自动扫描器发现并注册垃圾账号(这类扫描器专门找暴露的 Gitea 实例)。个人使用必须关掉注册,账号在命令行手动创建:

docker exec -u git gitea gitea admin user create \
  --username yourname \
  --password '强密码' \
  --email you@example.com \
  --admin

挂载 /etc/localtime不挂这一项,容器内时间是 UTC,提交记录的时间戳会差 8 小时,看日志时非常容易误判。

字符集必须 utf8mb4。MySQL 8 默认虽然是 utf8mb4,但显式写出来更保险。用 utf8(三字节)会导致 emoji 和部分生僻汉字在 Issue 标题里报 Incorrect string value 错误——这是自建 Git 里相当经典的坑。

启动并验证:

docker compose up -d
docker compose logs -f server | grep -i "listen\|error"

# 本机验证服务是否起来
curl -sI http://127.0.0.1:3000/ | head -5

三、Nginx 反代与 HTTPS:两个必须写的配置

Gitea 放在 Nginx 后面时,有两条配置不写会出问题,而且症状都很隐蔽。

server {
    listen 443 ssl http2;
    server_name git.example.com;

    ssl_certificate     /etc/letsencrypt/live/git.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/git.example.com/privkey.pem;

    # 关键 1:Git 推送大仓库需要放大请求体上限,默认 1m 会让 push 失败
    client_max_body_size 512m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;

        # 关键 2:WebSocket 支持,不配会导致仓库页面部分动态功能异常
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

client_max_body_size 的坑。Nginx 默认请求体上限是 1MB。Git 推送时如果单次传输的数据超过这个值,会看到非常令人困惑的报错:RPC failed; HTTP 413 或者干脆 unexpected disconnect while reading sideband packet。很多人以为是自己仓库太大,其实是 Nginx 拦在了前面。512m 对个人使用足够;如果要推送含大文件的仓库,配合 LFS 比继续调大这个值更合理。

WebSocket 的坑。Gitea 的部分前端功能(通知推送、部分页面的实时更新)依赖 WebSocket。不配 UpgradeConnection 两个头,页面能打开但行为异常,且浏览器控制台只报 WebSocket 连接失败——不看控制台根本发现不了。

X-Forwarded-Proto 的坑。不传这个头,Gitea 无法判断外部是 HTTPS,生成出来的克隆地址会是 http:// 开头,用户复制粘贴就会遇到重定向或证书问题。这也是为什么它必须配上 ROOT_URL=https://... 一起用。

证书用 certbot 标准流程签发:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d git.example.com --agree-tos -m you@example.com --redirect
systemctl list-timers | grep certbot   # 确认自动续期定时器存在

四、LFS:别让一张图片把仓库撑爆

Git 的设计假设是「文本文件、增量小」,天生不适合管理二进制文件。一旦把图片、PSD、视频、打包产物提交进普通仓库,会造成三重伤害:

  • 仓库体积不可逆膨胀。删掉文件再提交也没用,历史记录里还是留着那份数据,克隆时照样全量下载。
  • 克隆越来越慢。协作者每次都要拉全部历史。
  • 无法真正删除。敏感文件误提交后,即使删掉,历史里仍然可被翻出来。

Gitea 内置了 Git LFS 支持,需要两个条件:服务端开启 + 仓库启用

# 1. 服务端:确保配置文件里 LFS 是开启的
# /data/gitea/conf/app.ini
# [server]
# LFS_START_SERVER = true
# LFS_JWT_SECRET = (首次启动时自动生成,不要手改)

# 2. 客户端:在仓库里声明哪些后缀走 LFS
git lfs install
git lfs track "*.psd" "*.zip" "*.mp4" "*.woff2" "*.png"
git add .gitattributes
git commit -m "启用 LFS 跟踪二进制文件"

顺序至关重要:LFS 必须在文件被提交进去之前就配好。如果仓库里已经有这些文件的历史,后配 LFS 只会让提交走 LFS,旧的历史数据依然在普通对象库里。要彻底清理历史数据需要 git filter-repo 重写历史,那是个高风险操作,会把所有克隆者的本地仓库搞坏。

还有一个容易被忽略的点:.gitattributes 文件本身必须提交到仓库并推到远端,否则别人克隆下来没有跟踪规则,他们提交的大文件照样进普通仓库。

五、备份:自建服务的生死线

自建服务最大的风险不是被攻击,而是运维事故导致数据永久丢失。Gitea 的数据分布在三个位置,缺一不可:

  • MySQL 数据库——用户、仓库元数据、Issue、PR、评论
  • ./gitea 目录——仓库的裸库文件、LFS 对象、附件、头像
  • ./mysql 目录——如果用的是容器内 MySQL,数据在这里(但如果走 mysqldump 就不需要单独备份)

关键认知:只备份数据库是没用的。数据库里只有元数据,真正的代码在 ./gitea/git/repositories/ 下的裸库文件里。只还原数据库不还原文件,结果是仓库列表还在但每个仓库都是空的。

Gitea 自带了 dump 命令,能一次性打包数据库和仓库文件:

docker exec -u git gitea gitea dump -c /data/gitea/conf/app.ini \
  --type sql --file /data/backup/gitea-dump-$(date +%Y%m%d).zip

# 检查产物大小是否合理(几百 MB 到几 GB 都正常,几 KB 说明出错)
ls -lh /data/backup/

dump 命令有两个业界公认的坑,更稳妥的备份方案是底层文件级备份:

#!/bin/bash
# /root/backup-gitea.sh —— 建议加入 cron 每天执行
set -euo pipefail
DEST=/backup/gitea/$(date +%Y%m%d)
mkdir -p "$DEST"

# 1. 数据库导出(一致性快照)
docker exec gitea-db mysqldump -uroot -p"$MYSQL_ROOT_PW" \
  --single-transaction --routines --triggers gitea > "$DEST/gitea.sql"

# 2. 仓库文件打包(用 --acls --xattrs 保留属性)
tar --acls --xattrs -czf "$DEST/gitea-data.tar.gz" -C /root/gitea .

# 3. 校验产物非空
[ -s "$DEST/gitea.sql" ] || { echo "SQL 备份为空,失败"; exit 1; }
[ -s "$DEST/gitea-data.tar.gz" ] || { echo "文件备份为空,失败"; exit 1; }

# 4. 保留最近 14 天
find /backup/gitea -maxdepth 1 -type d -mtime +14 -exec rm -rf {} \;
echo "备份完成:$DEST"

两个细节值得说:

--single-transaction它让 mysqldump 在 InnoDB 上取得一致性快照而不锁表。不加这个参数,备份期间有写入会产生不一致的导出结果——还原出来的数据库可能引用了不存在的对象。

--acls --xattrstar 默认不保留扩展属性。而 --xattrs 决定了安全标签是否保留。还原后如果权限或标签不对,Git 仓库会因为「非裸库目录」被 Gitea 拒绝加载,症状是仓库页面能打开但显示 500 错误。这是文档里很少提但实际极易踩到的坑。

关于恢复演练。备份不验证等于没有备份。至少每季度做一次:在一台临时机器上解压 tar、导入 SQL、起容器、确认能 git clone 出一个仓库。整套流程跑通的耗时,就是你真实故障时的 RTO(恢复时间目标)。

六、从 GitHub 迁移已有仓库

Gitea 提供了官方的迁移功能,支持从 GitHub、GitLab、Gitee 等平台导入,能保留的不只是代码:

  • 提交历史(完整,包括所有分支和 tag)
  • Issue 和 Pull Request
  • 标签(Labels)和里程碑
  • Wiki 页面
  • 协作者权限(如果对方账号在本地也存在)

迁移的三种方式,按适用场景选:

方式一:仓库页面上的「迁移外部仓库」。适合单个仓库。在 Gitea 后台点 + → 「迁移外部仓库」,填上游 URL。如果仓库是私有的,把 GitHub 的 Personal Access Token 一起填进去。

方式二:API 批量迁移。仓库多了以后,逐个点效率太低,用 API:

# 生成一个 Gitea 访问令牌(用户设置 → 应用 → 生成令牌)
TOKEN="你的gitea令牌"

curl -s -X POST "https://git.example.com/api/v1/repos/migrate" \
  -H "Authorization: token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "clone_addr": "https://github.com/用户名/仓库名.git",
    "repo_name": "仓库名",
    "repo_owner": "你的Gitea用户名",
    "service": "github",
    "auth_token": "GitHub的PAT",
    "issues": true,
    "pull_requests": true,
    "labels": true,
    "wiki": true,
    "mirror": false
  }'

方式三:mirror 镜像模式。如果暂时不想彻底切断和 GitHub 的关系,把 mirror 设为 true。Gitea 会定期从上游拉取更新,本地仓库保持只读跟随状态。适合「GitHub 作为主库、Gitea 作为只读备份」的保守过渡方案。等确认稳定后再决定是否切换主库。

关于 LFS 对象的迁移。这是个真实的坑:普通迁移可能不会把 LFS 对象一起搬过来,结果代码在、大文件缺失,克隆时报 LFS: this server does not support LFS 或对象 404。迁移完成后务必验证:

git clone ssh://git@git.example.com:2222/yourname/repo.git /tmp/r
cd /tmp/r && git lfs ls-files        # 列出 LFS 跟踪文件
git lfs pull                           # 真正拉取 LFS 对象,看是否报错
du -sh .git/lfs/objects                # 目录为空说明对象没迁过来

如果 LFS 对象确实没迁移,只能手工处理:从上游 git lfs fetch --all,再 git lfs push --all 到新库。

七、Webhook:把「推送即部署」的闭环搭起来

自建 Git 最大的红利是可以顺手把自动部署也自建了,不用依赖第三方 CI 的配额。原理很简单:Gitea 在收到 push 时向一个 URL 发 HTTP POST,那台服务器收到后执行 git pull 并重载服务。

第一步,在 Gitea 里配置 Webhook。进入仓库 → 设置 → Web 钩子 → 添加 Gitea 类型,目标 URL 填 https://deploy.example.com/hook,密钥填一个随机字符串。触发事件选「推送事件」。

第二步,在部署机上写一个极简的接收服务。用 Python 标准库即可,不需要额外依赖:

#!/usr/bin/env python3
# /opt/deploy/hook.py —— 部署钩子接收端
import hmac, hashlib, subprocess, json
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = b"与Gitea里填的密钥完全一致"
DEPLOY_CMD = ["/opt/deploy/deploy.sh"]

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length)
        # 校验签名,防止任何人伪造推送触发部署
        sig = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(sig, self.headers.get('X-Gitea-Signature', '')):
            self.send_response(403); self.end_headers(); return
        event = json.loads(body)
        # 只对 main 分支的推送做部署
        if event.get('ref') == 'refs/heads/main':
            subprocess.Popen(DEPLOY_CMD)
        self.send_response(200); self.end_headers()
        self.wfile.write(b'ok')

HTTPServer(('127.0.0.1', 9000), Handler).serve_forever()

三个必须注意的安全点:

  • 签名校验不能省。X-Gitea-Signature 是 HMAC-SHA256,不校验的话,任何人都能 POST 这个 URL 触发部署,等于给了陌生人一个远程执行入口。
  • 必须用 hmac.compare_digest 而不是 ==普通字符串比较会短路返回,理论上存在时序攻击空间。对于安全校验,恒定时间比较是标准做法。
  • 服务只监听 127.0.0.1前面套 Nginx 提供 HTTPS 入口,接收服务本身绝不直接暴露公网。

第三步,写部署脚本。关键是加锁,防止两次推送并发执行导致工作区状态混乱:

#!/bin/bash
# /opt/deploy/deploy.sh
set -euo pipefail
exec 9>/var/lock/deploy.lock
flock -n 9 || { echo "已有部署在跑,跳过"; exit 0; }

cd /var/www/myapp
git fetch --all
git reset --hard origin/main       # 用 reset 而不是 pull,避免工作区脏文件冲突
composer install --no-dev --optimize-autoloader 2>/dev/null || true
systemctl reload php8.2-fpm
echo "$(date '+%F %T') 部署完成 $(git rev-parse --short HEAD)" >> /var/log/deploy.log

flock -n 9|| exit 0 的组合是关键:短时间连续推送时,后一个部署直接跳过而不是排队,避免两个 git 操作同时改同一个工作区。

八、日常运维的几个实务点

升级。Docker 部署的升级就是改 tag 再重启:

# 1. 先备份(永远先备份)
bash /root/backup-gitea.sh
# 2. 改 docker-compose.yml 里的镜像版本,然后
docker compose pull && docker compose up -d
docker compose logs -f server | grep -i "migration\|error"

Gitea 启动时会自动执行数据库迁移,日志里能看到 migration 记录。如果迁移失败,容器会不断重启,这时候不要强上,回退到旧镜像并从备份恢复。

资源占用实测。在 1 核 1G 的 VPS 上,Gitea 空闲时内存约 60-100MB,MySQL 约 150-250MB。跑十来个仓库完全无压力。真正吃资源的是 git gc 和仓库索引维护,大仓库首次索引时 CPU 会短暂打满,属正常现象。

防扫描。暴露在公网的 Gitea 每天都会有扫描器来试 /api/v1/version 和默认管理员路径。三条措施:关闭注册、管理账号用非 admin 的名字、开双因素认证。Gitea 内置 TOTP 支持,在用户设置的「安全」页开启即可。

九、常见问题

Q:Gitea 和 GitLab CE 怎么选?看内存。GitLab 官方最低推荐 4GB,实际顺畅跑要 8GB;Gitea 1GB 就够。个人站长场景下 Gitea 是明显更优解。需要完整 CI/CD 流水线、容器镜像仓库、代码质量扫描这些企业级功能时,才考虑 GitLab。

Q:推送时报 Permission denied (publickey),但网页能登录?这是两套独立认证。网页用密码/令牌走 HTTP,git push 走 SSH 用的是公钥。检查 ~/.ssh/config 里有没有为这个域名配端口:

Host git.example.com
    HostName git.example.com
    Port 2222
    User git
    IdentityFile ~/.ssh/id_ed25519

然后再确认公钥已经加到 Gitea 的「SSH 密钥」设置里。

Q:仓库页面 500 错误?九成是文件权限问题——Gitea 容器内用户是 uid 1000,如果 ./gitea 目录的属主被改成了 root,仓库文件就读不了。修复:

chown -R 1000:1000 /root/gitea
docker compose restart server

Q:能不能用 Gitea 跑 CI?可以,项目是 Gitea Actions(兼容 GitHub Actions 的 workflow 语法),另起一个 act_runner 容器即可。但要注意 runner 默认在容器里执行,需要给它挂载 Docker socket 才能构建镜像,这本身是个安全权衡,建议自用场景下接受、多人场景下慎重。

Q:怎么让 Git 提交记录里的邮箱和名字正确?在服务器和本地都配置:

git config --global user.name "你的名字"
git config --global user.email "you@example.com"

自建服务不会像 GitHub 那样用邮箱匹配账号,所以提交归属完全取决于本地配置,写错会导致提交记录里显示一串奇怪的自动生成名字。

总结

自建 Gitea 的收益可以归纳成三句话:代码回到自己手里、部署链路完全自主、成本几乎为零

把整篇的关键决策再收拢一遍:

  • 端口规划先定——宿主 SSH 保持 22,Gitea 的 SSH 用 2222,零风险。
  • 3000 端口只绑 127.0.0.1——外部访问一律走 Nginx 的 HTTPS,别把明文入口暴露出去。
  • Nginx 三个必配项——client_max_body_size(不然 push 大文件 413)、WebSocket 头(不然前端功能异常)、X-Forwarded-Proto(不然克隆地址是 http)。
  • LFS 一定要在提交大文件之前配——事后补救要重写历史,代价极高。
  • 备份要覆盖数据库 + 仓库裸库文件两块——只备份数据库还原出来的是空仓库,并且要每季度做恢复演练。
  • Webhook 必须校验 HMAC 签名——不校验等于开了一个匿名远程执行入口。

最后是一句运维层面的提醒:自建服务的成本不在「搭起来」,而在「长期维护」。至少要建立三件事:每日自动备份并检查产物非空镜像版本记录在案(方便回退)恢复流程至少演练过一次。这三件事做到位,自建 Git 就是一个比云端更让人安心的存在——因为你知道它出问题时该从哪里救回来。

Last modification:September 23rd, 2026 at 10:25 pm

Leave a Comment