前阵子把几个项目的仓库从 GitHub 搬回了自己的服务器。
不是什么"数据主权"的大词,就是三件小事凑在一起让我下定了决心:私有仓库每月要付费、国内 clone 速度看运气、以及某次 CI 跑了 40 分钟才发现是拉镜像卡住了。
搬完之后的效果:一台 2 核 2G 的机器,四个容器,内存占用 300 MB 出头,clone 速度跑满带宽,CI 从推送到达成 90 秒。
下面是从零开始的完整过程。每一步都可以直接复制执行。
一、先算清楚要几个容器
很多人以为自建 Gitea 就是 docker run gitea/gitea。能跑,但只适合在本机玩。
一套能日常用的服务是四个组件:
| 容器 | 作用 | 内存(实测) |
|---|---|---|
gitea | 主服务:Web、Git HTTP、SSH | 120~180 MB |
db | PostgreSQL,存用户、issue、PR | 30~50 MB |
caddy | 反向代理 + 自动 HTTPS | 15~25 MB |
runner | CI 执行器(可以后加) | 空闲 10 MB,跑任务时看负载 |
加起来空载 200~300 MB。2 核 2G 完全够用,甚至还能再跑两三个小服务。
数据库我选 PostgreSQL 而不是 Gitea 默认的 SQLite。理由很实际:SQLite 在并发写入时会锁库,多人同时 push + CI 频繁更新状态时容易遇到 database is locked。单人也建议直接上 PG,省得以后迁移。
二、目录结构
先建目录。所有状态(仓库数据、数据库、证书)都落在宿主机上,容器随时可以删掉重建:
sudo mkdir -p /opt/gitea/{data,db,backup,caddy/data,caddy/config,runner}
cd /opt/gitea
sudo chown -R 1000:1000 data db backup runner
chown 1000:1000 这一步不能漏。Gitea 官方镜像里跑的是 git 用户,UID 1000。目录属主不对,容器起来就报 Permission denied,而且报错位置很靠后(在初始化仓库时),容易误判成配置问题。
三、写 compose 文件
.env 放密码和域名,compose 里只引用变量。密码不要写进 compose,因为 compose 文件通常会被提交到仓库:
# /opt/gitea/.env
GITEA_DOMAIN=git.example.com
GITEA_VERSION=1.22
DB_PASSWORD=换成你自己的长密码
GITEA_VERSION 去 Gitea releases 取当前稳定版填进去。不要用 latest——某天自动拉到一个大版本,启动时才发现需要逐级迁移,那时你会很想回到前一晚。
# /opt/gitea/docker-compose.yml
services:
gitea:
image: gitea/gitea:${GITEA_VERSION}
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${DB_PASSWORD}
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}/
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN}
- GITEA__server__SSH_PORT=2222
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__HTTP_PORT=3000
- GITEA__security__INSTALL_LOCK=true
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__service__REQUIRE_SIGNIN_VIEW=true
- GITEA__repository__DEFAULT_BRANCH=main
- GITEA__lfs__START_SERVER=true
volumes:
- ./data:/data
- ./backup:/backup # 备份输出目录,见第七节
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # 只监听本机,外部走反代
- "2222:22" # SSH,必须对外
depends_on:
db:
condition: service_healthy
networks: [gitea]
db:
image: postgres:16-alpine
container_name: gitea-db
restart: always
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=gitea
volumes:
- ./db:/var/lib/postgresql/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
interval: 10s
timeout: 5s
retries: 5
networks: [gitea]
caddy:
image: caddy:2-alpine
container_name: gitea-caddy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
depends_on: [gitea]
networks: [gitea]
networks:
gitea:
几个环境变量值得单独解释,因为它们决定后面会不会踩坑:
INSTALL_LOCK=true 跳过网页安装向导。不设的话,服务起来后 3000 端口第一次被访问的人可以创建管理员账号——如果反代配好之前就被人扫到,等于把门开着。设了之后,管理员用命令行创建:
docker exec -u git gitea gitea admin user create \
--username harry --password '你的密码' \
--email you@example.com --admin
ROOT_URL 决定所有 clone 地址、邮件里的链接、webhook 地址。它必须在第一次启动前就正确。写在数据库里的部分绝对 URL 不会因为你改环境变量而自动更新,改错了只能重建容器甚至清理数据库。
SSH_PORT=2222 与 SSH_LISTEN_PORT=22 是一对:容器内部监听 22,对外告诉用户连 2222。这两个值搞反,表现是网页上显示的 clone 地址端口不对,但手工改端口又能用——很容易被误判为"偶发问题"。
四、反代:Caddy 三行搞定
Caddy 的价值在于自动申请和续期 Let's Encrypt 证书,不需要你写 certbot 的定时任务。
# /opt/gitea/Caddyfile
git.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
前提是域名的 A 记录已经指向这台机器,且 80/443 没有被墙外的东西占着。
如果你已经有 Nginx,不想再起 Caddy,那这一段这样写:
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;
client_max_body_size 512m; # 不设这个,推大仓库会 413
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;
}
}
client_max_body_size 是 Nginx 这边最容易漏的一行。默认 1 MB,push 一个带图片的提交就 413,而 Gitea 日志里只会看到一次失败的请求。
启动:
cd /opt/gitea
docker compose up -d
docker compose ps
docker compose logs -f gitea | head -40
看到 Listen: http://0.0.0.0:3000 和 ORM engine initialized 就说明起来了。第一次访问 https://git.example.com 应该直接是登录页(因为 INSTALL_LOCK)。
五、SSH 怎么配才不别扭
Gitea 的 SSH 走 2222,但 Git 默认连 22。两种解法。
解法一:客户端配置(推荐)。 在 ~/.ssh/config 里加一段,之后 git clone git@git.example.com:user/repo.git 直接可用,不用记端口:
Host git.example.com
HostName git.example.com
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519
解法二:把 2222 换成 22。 如果这台机器只跑 Gitea,可以把 compose 里的端口映射改成 "22:22",同时把 GITEA__server__SSH_PORT 改成 22。缺点是以后再加别的服务要抢端口,而且 sshd 本身也得让位。
我选了解法一。多写四行配置,换来端口随便改的自由。
生成密钥并上传:
ssh-keygen -t ed25519 -C "harry@laptop" -f ~/.ssh/id_ed25519_gitea
# 把 ~/.ssh/id_ed25519_gitea.pub 的内容贴到 Gitea → 设置 → SSH 密钥
ssh -T -p 2222 git@git.example.com
# Hi there, harry! You've successfully authenticated...
注意最后那条测试命令。返回 Hi there 说明 SSH 通了;如果卡住不动,多半是防火墙没放行 2222,而不是密钥问题。
六、我踩过的 8 个坑
1. clone 地址显示 http://localhost:3000
ROOT_URL 没设或设错。改完 docker compose up -d 重建容器(不是 restart,环境变量只在创建时生效)。
2. push 报 413
Nginx 的 client_max_body_size。Caddy 没有这个限制,所以用 Caddy 的人不会遇到。
3. 容器里时间是 UTC,提交时间差 8 小时
没挂 /etc/localtime。这也是为什么我的 compose 里每个服务都挂了时区文件。更稳的做法是在环境变量里加 TZ=Asia/Shanghai。
4. Permission denied 出现在创建仓库时
/opt/gitea/data 属主不是 1000。chown -R 1000:1000 data 之后重启。
5. 数据库密码里有 $ 或 #
compose 会对 $ 做变量替换。.env 里用单引号包起来,或者把 $ 写成 $$。最省事的办法是密码只用字母数字,长度补到 32 位——熵够了,也不会有转义问题。
6. 升级后起不来
Gitea 不支持跨多个小版本直接升级。1.20 升到 1.22 要经过 1.21。正确做法是逐级改 .env 里的 GITEA_VERSION,每升一级 docker compose up -d 一次,确认 docker compose logs gitea | grep -i migration 没有报错再继续。
7. 备份文件在容器里,忘了拷出来
gitea dump 默认输出到容器内的工作目录。要么用 -f /data/backup/xxx.zip 落到挂载卷里,要么 docker cp 出来。前者更省事。
8. 仓库页面的头像裂了
上传的头像存在 /data/avatars,如果换了域名又没改 ROOT_URL,数据库里存的是旧地址。改完配置重启,旧头像需要重新上传。
七、备份:能恢复才算备份
Gitea 的数据分两块:仓库文件和配置在 /data,元数据在数据库。只备份其中一个,恢复出来都是残的。
官方提供了 gitea dump,它会把仓库、配置、数据库一次性打成一个 zip:
#!/usr/bin/env bash
# /opt/gitea/backup.sh
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
DIR=/opt/gitea/backup
mkdir -p "$DIR"
# 1) 应用层完整导出(含数据库)
docker exec -u git gitea \
/usr/local/bin/gitea dump -c /data/gitea/conf/app.ini \
-f "/backup/gitea-${STAMP}.zip"
# 2) 数据库单独再来一份(恢复时更灵活)
docker exec gitea-db pg_dump -U gitea gitea \
| gzip > "$DIR/gitea-db-${STAMP}.sql.gz"
# 3) 验证:zip 能列出内容、SQL 能解压
unzip -tq "$DIR/gitea-${STAMP}.zip" >/dev/null
gzip -t "$DIR/gitea-db-${STAMP}.sql.gz"
# 4) 保留最近 7 份,只清自己的文件名前缀
ls -1t "$DIR"/gitea-2*.zip | tail -n +8 | xargs -r rm -f
ls -1t "$DIR"/gitea-db-*.sql.gz | tail -n +8 | xargs -r rm -f
注意第 1 步的路径:gitea dump 在容器里执行,输出必须落在挂载卷里,否则文件只存在于容器内部,容器一删就没了。上面 compose 里已经挂了 ./backup:/backup,所以宿主机上看到的就是同名的 zip。
gitea dump 有两个已知局限,得知道:
- LFS 文件:1.21 之后默认包含,老版本要确认配置里
LFS相关项。 - 它会在导出期间对数据库加锁,仓库多的时候有几十秒写入受影响。定在凌晨跑。
恢复的步骤反过来:停容器 → 解压 zip 覆盖 /data → psql 导入 SQL → 起容器。
只备份不验证,等于没有备份。 我每个月会做一次演练:把 zip 解到一个临时目录,确认 repositories/ 下每个仓库的 objects 都在,再用 git fsck 校一个仓库。
八、加 CI:一个 runner 就够
Gitea 的 Actions 兼容 GitHub 的 workflow 语法,执行器叫 act_runner。
先去站点管理里拿注册令牌:管理后台 → Actions → Runners → 创建 Runner,复制那串 token。
docker run -d --name gitea-runner --restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea/runner:/data \
-e GITEA_INSTANCE_URL=https://git.example.com \
-e GITEA_RUNNER_REGISTRATION_TOKEN=粘贴刚才的token \
-e GITEA_RUNNER_NAME=docker-1 \
-e GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm \
gitea/act_runner:latest
挂 docker.sock 意味着 runner 能在宿主机上起容器——这件事有安全含义。仓库的 workflow 相当于能在这台机器上执行任意代码。所以:私有仓库可以这么干,如果开放注册就千万别。
workflow 文件放在 .gitea/workflows/ci.yml:
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm test
actions/checkout@v4 这类官方 action 在 Gitea 上是能直接用的(act_runner 会从 GitHub 拉取)。但依赖 GitHub 专有能力的 action 不行,比如 actions/cache 的某些后端、需要 OIDC 的云厂商登录。遇到不兼容的,换成直接执行命令。
我实测一个 Node 项目从 push 到测试通过是 90 秒左右——比之前用 GitHub Actions 排队快得多,因为队列里只有我自己的任务。
九、迁移:把 GitHub 的仓库搬过来
单个仓库最稳的方式是镜像推送,保留全部提交、分支、tag:
git clone --mirror git@github.com:user/repo.git
cd repo.git
git remote set-url --push origin git@git.example.com:user/repo.git
git push --mirror
--mirror 会把所有 ref(包括 refs/pull/*)都推上去,所以推完记得清理一下,否则 PR 的临时 ref 会一直堆着。
Gitea 也提供网页版的"迁移外部仓库",支持填 GitHub token 拉取 issue、PR、label、milestone。但不要用它迁代码——大批量仓库走 Web 迁移容易超时,用上面的 mirror 方式更可靠,issue 之后再单独迁。
搬完之后建议保留 GitHub 作为只读镜像:Gitea 仓库设置 → 仓库镜像 → 推送镜像,填 GitHub 地址。这样两边都有,哪边挂了都不至于抓瞎。
十、最后几个运维建议
别把 80/443 直接暴露给 Gitea。 反代做一层,好处是以后换端口、加第二个服务、上 WAF 都不用动应用。
监控三件事就够了:容器是否在跑(docker compose ps)、磁盘水位、备份是否成功。前两个用 cron 每分钟看一眼,第三个每天看一次。
.env 权限设 600。 里面有数据库密码,别让同机器上的其他用户读到。
给 Gitea 单独建一个系统用户来跑运维脚本,不要用 root 执行备份和迁移。gitea dump 需要 -u git,其他操作都不需要 root。
整套下来,成本是一台 2 核 2G 的服务器。我现在用的是腾讯云轻量应用服务器,一年几百块,跑 Gitea 加几个小服务绰绰有余。比起按人头收费的私有仓库,这个账很好算。
自建服务的真正成本不在搭,在维护。所以我在设计时尽量让"维护"变成"看一眼":容器 restart: always、备份脚本带验证、升级路径写进文档。
搭完之后最大的感受不是省钱,是心理上的确定性:代码在哪、备份在哪、出问题该看哪个日志,全都是自己定的。
这比省下的那点订阅费值钱。
速查卡
搭好之后日常就用这些。
| 目的 | 命令 | 注意 |
|---|---|---|
| 起停全部容器 | docker compose up -d / docker compose down | 改了环境变量必须 up -d,restart 不读新变量 |
| 看启动日志 | docker compose logs -f --tail=100 gitea | 迁移失败会在这里报,别只看容器是否 running |
| 生成 clone 地址 | 管理后台「仓库设置」→ 复制 | 地址错基本都是 ROOT_URL 没配对 |
| 配 SSH 免端口 | ~/.ssh/config 写 Port 2222 | 之后 git clone git@host:user/repo.git 照常 |
| 一次性备份 | docker exec -u git gitea /usr/local/bin/gitea dump -c /data/gitea/conf/app.ini | 产出 /data/gitea-dump-*.zip |
| 单独备份数据库 | docker exec pg pg_dump -U gitea gitea > gitea.sql | 恢复时比 dump 包灵活 |
| 校验备份 | git fsck --full 在解出的仓库里跑 | 只看 zip 大小等于没验证 |
| 升级(逐级) | 改 GITEA_VERSION → up -d → 看日志 | 跨版本必须一级一级来 |
| 清理旧镜像 | docker image prune -f | 升级几次后能省几个 G |
| 看谁在占用磁盘 | du -sh /var/lib/docker/volumes/* | Gitea 的 LFS 和附件最容易涨 |
附:8 个高频问题(直接给结论)
自建 Gitea 最少需要多少配置? 2 核 2G 足够。实测空载 200~300 MB,四个容器(Gitea + PostgreSQL + Caddy + Runner)跑满日常使用,还能再挂两三个小服务。
一定要用 PostgreSQL 吗,SQLite 行不行?
单人用 SQLite 可以,但它并发写入会锁库。多人 push 叠加 CI 频繁更新状态时会出现 database is locked。第一次部署就上 PG,省掉后期迁移的麻烦。
clone 地址显示 localhost:3000 怎么办?
GITEA__server__ROOT_URL 没设或设错。改完必须 docker compose up -d 重建容器(不是 restart),因为环境变量只在容器创建时生效。
push 时报 413 是什么原因?
Nginx 的 client_max_body_size 默认 1 MB。加一行 client_max_body_size 512m; 即可。用 Caddy 反代不会有这个问题。
SSH 端口为什么不直接用 22?
22 通常被宿主机 sshd 占用。做法是容器映射 2222:22,同时在客户端 ~/.ssh/config 里给该域名指定 Port 2222,之后 git clone git@host:user/repo.git 照常可用,不用记端口。
升级 Gitea 能直接跨多个版本吗?
不能。1.20 升到 1.22 必须经过 1.21,逐级修改 GITEA_VERSION 并重建容器,每次确认日志里没有 migration 报错再继续。
备份要备哪些东西?
两部分:/data 目录(仓库文件 + 配置)和数据库。用 gitea dump 可以一次性打包,但建议同时单独跑 pg_dump,恢复时更灵活。只备其中一个,恢复出来都是残的。
怎么验证备份真的能用?
解压后确认 repositories/ 下每个仓库的 objects 都在,再用 git fsck 校一个仓库。只备份不验证等于没有备份。
一个问题
你现在的代码在哪?
如果答案是"公司 GitLab,个人项目放本地",那这台机器哪天坏了,你手上其实什么都没有。我做这套东西最初的动机不是"想要个私有仓库",是想把数据拿回自己手里——它不依赖任何平台的账号状态、不因为定价调整消失、也不会在你离职那天被回收。
你现在有几个仓库只在本地?如果硬盘明天坏了,你最心疼的是哪个?