10 分钟用 Docker 搭一套私有 Gitea:HTTPS、SSH、CI、备份一次配齐

前阵子把几个项目的仓库从 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,省得以后迁移。


二、目录结构

先建目录。所有状态(仓库数据、数据库、证书)都落在宿主机上,容器随时可以删掉重建:

bash 复制代码
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 文件通常会被提交到仓库:

bash 复制代码
# /opt/gitea/.env
GITEA_DOMAIN=git.example.com
GITEA_VERSION=1.22
DB_PASSWORD=换成你自己的长密码

GITEA_VERSION 去 Gitea releases 取当前稳定版填进去。不要用 latest------某天自动拉到一个大版本,启动时才发现需要逐级迁移,那时你会很想回到前一晚。

yaml 复制代码
# /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 端口第一次被访问的人可以创建管理员账号------如果反代配好之前就被人扫到,等于把门开着。设了之后,管理员用命令行创建:

bash 复制代码
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 的定时任务。

caddyfile 复制代码
# /opt/gitea/Caddyfile
git.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:3000
}

前提是域名的 A 记录已经指向这台机器,且 80/443 没有被墙外的东西占着。

如果你已经有 Nginx,不想再起 Caddy,那这一段这样写:

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;

    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 日志里只会看到一次失败的请求。

启动:

bash 复制代码
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 直接可用,不用记端口:

sshconfig 复制代码
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 本身也得让位。

我选了解法一。多写四行配置,换来端口随便改的自由。

生成密钥并上传:

bash 复制代码
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:

bash 复制代码
#!/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。

bash 复制代码
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:

yaml 复制代码
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:

bash 复制代码
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,个人项目放本地",那这台机器哪天坏了,你手上其实什么都没有。我做这套东西最初的动机不是"想要个私有仓库",是想把数据拿回自己手里------它不依赖任何平台的账号状态、不因为定价调整消失、也不会在你离职那天被回收。

你现在有几个仓库只在本地?如果硬盘明天坏了,你最心疼的是哪个?

相关推荐
Blockbuater_drug6 小时前
Dify 1.18 本地部署实战:Windows + WSL2 + Docker,从零跑到本地可用
windows·docker·容器·dify·大模型应用平台
徐小黑ACG6 小时前
docker 基础教程
运维·docker·容器
wzg20168 小时前
ubuntu 离线安装 docker-compose
linux·ubuntu·docker
时间售卖铺8 小时前
Docker镜像构建全解析:从原理到实战优化
运维·docker·云原生·容器
jianjinwssy11 小时前
从零设计一个 Agent 安全沙箱:Docker 隔离、三级风控与 Kafka 审计
人工智能·docker·kafka
DarkAthena1 天前
wslc ps 卡死的排查:一次「停容器不停 VM」引发的连环故障
docker
小沈同学呀1 天前
【Agent开发第十五期】部署上线:把 Alex 搬进浏览器,15 期全栈串联收官
docker·agent·从零搭建ai助手
lwx9148521 天前
Docker-安装SSH服务
docker·容器·ssh
时间售卖铺1 天前
Docker实战:一键部署轻量级容器
运维·docker·云原生