NordBastion 的北极熊吉祥物站在一间幽暗的北欧石室中,身旁是一台服务器机架,一只爪子搭在悬浮的青色全息分支图上——图中展示着一段有三条线汇合的 Git 历史,工作台上放着一个封印着源代码的符文保险箱,极光透过一扇高耸的拱形窗洒落而入
操作指南 · 开发者基础设施·阅读 18 分钟 · 实操 45 分钟

属于你自己的 Git 服务器。
仓库就是组织架构图。

一个私有仓库,是关于你在构建什么、和谁一起构建、以及进度如何的一份带日期的记录——而在某个平台上,这份记录是由一家身处别人司法管辖区的公司掌握的。六个步骤,把它搬到一台你匿名租用的机器上:Caddy 之后是 Forgejo,底层是 PostgreSQL,SSH 推送走自己专属的端口,一个永远看不到生产环境密钥的 Actions 运行器,以及一份你已经真正恢复验证过的备份。这台机器每月 $3.90。已在 Debian 12 上测试。

六个步骤
  1. 01

    选择

    裸仓库、Forgejo、GitLab

  2. 02

    配置

    开机前先定好端口

  3. 03

    部署

    Forgejo、Postgres、Caddy

  4. 04

    推送

    SSH、部署密钥、签名

  5. 05

    构建

    运行器,敬而远之

  6. 06

    Keep

    镜像、备份、升级

开始之前 · 平台掌握着什么

代码是最不值得关注的部分。元数据才是真正的资产。

几乎所有关于自建 Git 的争论,争论的都是源代码,而源代码恰恰是最不重要的部分。它大多平平无奇,很大一部分只是开源库外面裹的一层薄壳,就算明天泄露出去,实际造成的伤害通常也只是尴尬,而不是真正的损害。这就是为什么“我的代码又不是什么秘密”听起来像是一个完整的答案——也是为什么它其实并不是。

真正值得关注的,是围绕在代码周围的东西。一个仓库是一份带日期、有署名、高分辨率的工作记录:提交时间戳描述了你的作息,共同作者信息描述了你的合作对象,分支名称描述了尚未公布的产品,工单系统描述了哪里出了问题、问题有多严重,而第一次提交则为整个项目标注了起点。用这种方式读上一百个仓库,你读到的就不再是软件了——你读到的是一家公司,它的路线图、它的团队规模,以及它的麻烦事。

在某个平台上,这份记录在任何有意义的层面上都不属于你。它存放在你无法掌控的基础设施上,挂在一个绑定着你已验证身份的账户之下,处于一个自身法律程序可以触及它的司法管辖区里,被一些扫描范围由一份可以随时修改的文件来设定的系统扫描着。这些都不需要任何人心怀恶意——这就是“租用”一个工单系统的本质含义。下面是四种安排方式,以及每一种实际上都出卖了什么:

仓库存放在哪里 谁掌握着历史记录 是否绑定你的法律身份 会如何出问题
平台,个人账户平台方,在其自身的司法管辖区内是——邮箱、账单信息,往往还有电话号码一次封禁就会带走你所有的远程仓库
平台,组织账户平台方,加上任何管理该组织的人是——而且还绑定着你的雇主离职流程会剥夺你对自己成果的访问权限
自建,架设在大型云服务商上你自己,在应用层面上是——账单账户本身就是身份快照存放在一个可以被关闭的账户里
自建,架设在无 KYC 的 VPS 上只有你自己否——无需证件、无需银行卡,加密货币结算现在你自己就是系统管理员,一份失败的备份就是终局

第三行,正是那些自以为已经解决了这个问题的团队会栽跟头的地方。一个你亲自安装的代码托管平台,架设在用公司信用卡、以经过验证的公司账户租来的机器上——这只是把应用从平台里搬了出来,身份暴露的程度却原封不动。这在控制权上是真实的提升,在暴露程度上却毫无改善。

而第四行则直白地说出了它自己的代价,因为本指南接下来的大部分内容,其实都是在讲怎么去承担这份代价。自建服务真正的失败模式不是被监视,而是丢失。平台会替你保留那些你从未想过的备份,而你的服务器,只会保留你亲自配置过的那些备份。步骤 06 不是结尾处可有可无的润色,它是让前面五个步骤都安全落地的那一步。

步骤 01 · 选择

你到底需要多完整的代码托管平台?诚实的答案往往是“不需要”。

从能解决你问题的最小方案开始,因为在它之上的每一层,都是一项你必须亲自运维的服务。通过 SSH 使用的裸仓库,在你已有的服务器上只需一条命令,就能给你一个具备身份验证、历史记录和推送权限的真正 Git 远程仓库。没有 Web 应用,没有数据库,没有没完没了的升级,除了 sshd 之外什么都不用打补丁。如果你只是一个人,只是想把提交从笔记本电脑和某个平台上搬出来,这就是一个完整的答案,你在读完步骤 02 之后就可以不用再往下看了。

# On the server, as an unprivileged user:
mkdir -p ~/git/project.git && cd ~/git/project.git
git init --bare

# On your laptop:
git remote add origin ssh://user@git.example.com/~/git/project.git
git push -u origin main

一旦有第二个人参与进来,或者你想要一堆裸仓库所在的目录完全做不到的那四件事——在浏览器里浏览历史记录、以拉取请求的形式审查改动、在代码旁边跟踪工单,以及把某一个仓库的访问权限单独授予某一个人——你就需要一个代码托管平台了。这在能力上是真正的提升,在运维负担上也是真正的提升。请慎重选择:

属性 通过 SSH 使用的裸仓库 Forgejo Gitea GitLab CE
Web 界面、拉取请求、工单无是是有,而且远不止这些
静态内存占用几乎为零几百 MB几百 MB还没算 CI 就已经好几 GB
内置 CI靠你自己写的 git hooksForgejo ActionsGitea ActionsGitLab CI,比较成熟的那个
由谁掌舵这个项目Git 项目本身Codeberg e.V.,一家非营利组织一家营利性公司,开源核心模式一家上市公司,开源核心模式
许可证GPLv2v9.0 起为 GPLv3+(此前为 MIT)MIT,附带专有插件MIT 核心,付费档位为专有软件
适合选择它的情形你只有一个人,想要零维护你想在一台小机器上跑一个真正的代码托管平台你已经在用它,而且运行良好你需要企业级功能集

Forgejo 和 Gitea 师出同源,日常使用起来也几乎一模一样。Forgejo 于 2024 年初硬分叉而来,由 Codeberg e.V. 持有,这是一家总部位于柏林的非营利注册社团;Gitea 则由一家营利性公司开发,该公司拥有其商标,并要求贡献者转让版权。两者都是优秀的软件。但只有其中一个的托管方,其利益动机与你阅读这篇文章的初衷相符。

本指南接下来的部分都使用 Forgejo。步骤 02 到步骤 06 中的几乎所有内容,原封不动地套用到 Gitea 上也同样成立——变化的只是容器名称和少数几个环境变量,整体结构不变。

步骤 02 · 配置

在代码托管平台替你决定端口之前,先自己决定好。其中有两个都是 SSH。

先下单这台机器。在控制面板中依次选择:Order → VPS → Sentinel,镜像选择 Debian 12,再选定你想让这段历史记录留在哪一座堡垒——Helsinki、Stockholm、Oslo 或 Reykjavík。开户无需提供邮箱地址,账单可以用门罗币、比特币、闪电网络或其他任何受支持的资产结算。这一点在这里比在大多数负载上都更重要:代码托管平台是这样一种服务,其全部价值主张的核心,就在于你工作的记录不会与任何名字绑定在一起。

然后执行首小时加固清单——把 SSH 换到新端口并只允许密钥登录、开启无人值守升级、配置默认拒绝的防火墙——因为下面的所有步骤,都假定这是一台还没有被别人染指过的机器。

你实际上是在把什么东西放到互联网上

代码托管平台不是一个带登录框的静态网站,而是一个挂载了代码执行引擎的公开 Web 应用。有两个默认设置,只要不管它们,就会把它变成陌生人的免费服务:一个是开放注册——证书一出现在透明度日志里,用不了几天,这个实例就会被垃圾账户灌满;另一个是任何能发起拉取请求的人都能触发运行的 CI 工作流——这正是自建运行器最终替别人挖矿的原因。这两者都不是什么隐蔽的攻击手段,也都不罕见。它们分别只需一个设置就能关闭,会在步骤 03 和步骤 05 中讲到——但现在就要下定决心去关闭它们,因为从“能用了”到“被人盯上”之间的窗口期,是以天计算的。

把一个主机名指向这台机器——一条 A 记录,如果想通过 IPv6 访问就再加一条 AAAA 记录——并在开始之前让它完成解析传播,因为反向代理会在第一次启动时就尝试为这个名称申请证书,验证失败可不是什么令人愉快的开局。然后,趁着现在还没有任何服务在监听,先把防火墙规则写好。

两个 SSH 的问题。 代码托管平台用 SSH 来处理 Git 通信,而你的服务器早就在用 SSH 做管理。它们不可能共享 22 端口,把两者混在一起,最终就会得到一个既要处理管理事务、又得理解 Git 的守护进程。把它们分开:管理用的 SSH 走加固指南里那个改过的端口,Git 则走一个映射进容器的独立公开端口。2222 是常见的约定选择,但它本身并没有什么特殊之处。

ufw default deny incoming
ufw allow 47222/tcp                  # administrative SSH, moved
ufw allow 80/tcp                     # ACME HTTP-01 challenge
ufw allow 443/tcp                    # the web interface
ufw allow 2222/tcp                   # git over SSH, into the container
ufw enable
ufw status numbered

# Nothing else is open. In particular: PostgreSQL stays on the Docker
# network and is never published to the host, let alone the internet.

从官方仓库安装 Docker,而不是使用发行版自带的软件包,这样才能确保带有 Compose v2 且版本足够新。然后为这套服务栈创建一个目录,并让它成为这台机器上唯一保存状态的地方——这也正是步骤 06 要备份的那个目录。

curl -fsSL https://get.docker.com | sh
docker compose version

install -d -m 0750 /srv/forgejo
cd /srv/forgejo
步骤 03 · 部署

三个容器。其中一个绝不能被外部访问到。

Forgejo、PostgreSQL 和 Caddy。Forgejo 保存仓库并提供界面服务,PostgreSQL 保存所有非 Git 对象的内容,Caddy 负责终结 TLS 并自动申请证书,无需你多说第二遍。PostgreSQL 被刻意设置为不对主机开放:它只能通过 Compose 网络、以服务名的方式,被那一个真正需要它的容器访问到。

/srv/forgejo/.env

FORGEJO_DOMAIN=git.example.com
FORGEJO_VERSION=15                             # the LTS line; 16 is current stable
POSTGRES_PASSWORD=$(openssl rand -base64 32)   # paste the value, do not leave the $( )

/srv/forgejo/docker-compose.yml

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:${FORGEJO_VERSION}
    container_name: forgejo
    environment:
      USER_UID: 1000
      USER_GID: 1000
      FORGEJO__database__DB_TYPE: postgres
      FORGEJO__database__HOST: db:5432
      FORGEJO__database__NAME: forgejo
      FORGEJO__database__USER: forgejo
      FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD}
      FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
      FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/
      FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN}
      FORGEJO__server__SSH_PORT: "2222"          # what clones will say
      FORGEJO__server__SSH_LISTEN_PORT: "22"     # what the container listens on
      FORGEJO__service__DISABLE_REGISTRATION: "true"
      FORGEJO__repository__DEFAULT_PRIVATE: private
      FORGEJO__security__INSTALL_LOCK: "true"
    restart: unless-stopped
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"    # web, reached only by Caddy
      - "2222:22"                # git over SSH, public
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    container_name: forgejo-db
    environment:
      POSTGRES_USER: forgejo
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: forgejo
    restart: unless-stopped
    volumes:
      - ./postgres:/var/lib/postgresql/data
    # No ports: block. This is the point.

  caddy:
    image: caddy:2-alpine
    container_name: forgejo-caddy
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy-data:/data
      - ./caddy-config:/config

/srv/forgejo/Caddyfile

git.example.com {
    encode zstd gzip
    request_body {
        max_size 512MB          # raise it if you push large repositories
    }
    reverse_proxy 127.0.0.1:3000
}

这两行 SSH 配置是最容易出错的地方,值得多读一遍。SSH_LISTEN_PORT 是容器内部的端口,也就是 Forgejo 内置 SSH 服务器绑定的端口。SSH_PORT 则是 Forgejo 在展示给你的克隆地址中打印出来的端口——它必须和端口映射对外的那一侧保持一致。一旦这两者对不上,其他一切都能正常工作,唯独用户复制粘贴的那个地址会出问题。

启动它,然后通过命令行创建管理员。 不要使用 Web 安装程序。在一个全新的实例上,任何找到这个主机名的人都能访问到它,而通过它创建的第一个账户就会成为管理员——这是一场你完全没必要参与的竞速。上面文件中的 INSTALL_LOCK,会在容器启动之前就把这条路堵死。

docker compose up -d
docker compose logs -f forgejo        # wait for "Starting new Web server"

docker compose exec -u git forgejo forgejo admin user create \
  --admin --username you --email you@example.com --random-password

在浏览器中打开这个主机名,用打印出来的密码登录,然后修改密码。接下来逐一检查那些能把一个“能用”的实例变成一个“封闭”实例的设置——大部分在上面的环境变量区块里已经设置好了,但每一项都值得亲自确认,而不是想当然地认为它已经生效:

注册

DISABLE_REGISTRATION = true。账户由你来创建,没有人可以自助注册。如果偶尔需要开放注册,用邀请流程,而不是把大门整个敞开。

默认可见性

DEFAULT_PRIVATE = private。新建仓库默认就是关闭状态。这样一来,公开发布就成了一个刻意的举动,而不是某个默认设置带来的后果。

匿名浏览

REQUIRE_SIGNIN_VIEW = true 会让未登录的访客连公开仓库都看不到。对于一个私有的团队代码托管平台来说这是对的;但如果你想公开发布任何内容,这样做就不对了。

双因素

在做任何其他事情之前,先在管理员账户上启用它。因为现在,这个代码托管平台已经变成了一台能够改写你所有分支的机器。

有一条路径值得记住:实际生效的配置文件,在主机上位于 ./data/gitea/conf/app.ini。Forgejo 保留了 gitea 这个目录名,是为了兼容已有的安装,这一点每个人都会被“惊到”一次,但也就那一次。形如 FORGEJO__section__KEY 的环境变量会在启动时被写入这个文件,所以你完全可以只通过 Compose 文件来管理整套配置——但如果你手动编辑了 app.ini,对应同一个键的环境变量会在下次重启时覆盖掉你的修改。这两种方式只能二选一,并保持前后一致。

步骤 04 · 推送

通过 SSH 推送。然后为它签名,让历史记录不再依赖服务器本身。

把你的公钥粘贴到个人资料设置中,然后告诉你的客户端这个代码托管平台在哪里。因为 Git 走的是非标准端口,花三十秒在你的 SSH 配置里加一条记录是值得的——这样端口就不用出现在你以后要输入的每一个网址里,远程仓库地址也因此更便于迁移。

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

# Then, in any repository:
git remote set-url origin git@git.example.com:you/project.git
git push -u origin main
ssh -T git@git.example.com        # expect a greeting, not a shell

最后那条命令,就是用来告诉你端口映射、容器里的 SSH 服务器和你的密钥是否都对上了。收到 Forgejo 的问候语,说明整条链路都是通的。如果弹出密码提示,说明你实际连上的是主机的管理用 sshd,端口映射有误。

现在,给你的提交签名。 这是大多数自建指南都会跳过的一步,而在一个自建的代码托管平台上,它比在平台上更加重要。提交的作者字段是自由文本——任何能推送代码的人,都能把你的名字写进去。而签名则证明了是某一把特定的密钥做出了这个特定的提交,而且即便它被推送到的那台服务器已经被攻破、迁移或者干脆报废,这个证明依然成立。Git 可以直接用你已经有的 SSH 密钥来签名,不再需要绕道去用 PGP:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true

git commit --allow-empty -m "signed"
git log --show-signature -1

然后在这个代码托管平台里再添加一次同一把公钥,这一次作为签名密钥,而不是身份验证密钥。Forgejo 会在服务器端验证签名,并把提交标记为“已验证”;如果没有这第二条记录,它就无法把签名和你的账户关联起来,即使提交签名完全正确,也会全部显示为“未验证”。

部署密钥,给那些只读取代码的机器用。 生产服务器、构建机器或镜像,都不需要在这个代码托管平台上拥有用户账户。给它一把部署密钥就够了:一对添加到某个仓库上的密钥,默认只读,除非你明确授予写入权限,除此之外对这个实例上的任何其他内容都没有访问权限。撤销它只需要删掉一条记录。这是让自建代码托管平台保持整洁最有效的一个习惯,因为用户账户会不断累积权限,而部署密钥不会。

从某个平台迁移过来的时候,正好是把这一切都做规范的好时机。Forgejo 的新建迁移功能,只需要一个仓库地址和一个访问令牌,就能把分支、标签、工单、评论、标记、里程碑、发布和拉取请求统统带过来。先迁移一个仓库看看结果是什么样子,大批量迁移要预料到会被 API 速率限制拖慢,并且把源仓库保留为只读状态几周,而不是当天就删掉。

步骤 05 · 构建

CI 运行器,就是一台运行陌生人代码的机器。要据此摆放它的位置。

Forgejo Actions 会从 .forgejo/workflows/ 中读取与 GitHub Actions 相同格式的工作流文件,并把它们交给一个叫做 Forgejo Runner 的独立程序去执行。这个运行器并不是代码托管平台的一部分,也不必和它运行在同一台机器上——这正是整个设计的用意所在,也是这一步要专门提出警告、而不是一带而过的原因。

在实例上启用 Actions,然后从管理面板中获取一个注册令牌——可以是整个实例通用的,也可以限定在某一个组织或某一个仓库范围内;如果你拿不准,后者是更稳妥的默认选择。运行器是主动向外连接到代码托管平台的,所以完全不需要任何入站端口,可以放在你喜欢的任何防火墙后面,哪怕是一台没有公网地址的机器上也没问题。

# In the Compose environment of the forge, or app.ini:
#   FORGEJO__actions__ENABLED: "true"
# Then: Site administration -> Actions -> Runners -> Create new runner

# On the runner machine:
forgejo-runner generate-config > config.yml

forgejo-runner register --no-interactive \
  --instance https://git.example.com \
  --token <REGISTRATION_TOKEN> \
  --name runner-01 \
  --labels docker:docker://node:22-bookworm,debian:docker://debian:12

forgejo-runner --config config.yml daemon

标签,就是工作流和运行器之间的契约:一个要求 runs-on: docker 的任务,会得到一个由右侧镜像构建出来的容器。只声明你真正准备好提供的那些标签,因为一个任务如果要求一个没人提供的标签,会永远等下去,而不是直接失败——在漫长的一天快结束时去排查这种问题,会让人相当抓狂。

.forgejo/workflows/ci.yml

on: [push, pull_request]

jobs:
  test:
    runs-on: docker
    container:
      image: node:22-bookworm
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
真正起决定作用的那条规则

运行器会执行工作流文件里写的任何内容,而工作流文件是随拉取请求一起送达的。只要团队之外的人也能发起拉取请求,运行器就应该放在一台与代码托管平台不同的机器上——以每月 $3.90 的价钱再租一台 Sentinel,不让它访问代码托管平台的数据目录,不给它数据库凭据,环境里也不放任何你不希望出现在构建日志里的东西。在代码托管平台本身,要求对首次贡献者的工作流运行进行审批。两种最常见的失败情形都很老套,也都代价不小:藏在拉取请求里的挖矿负载,以及某个人故意添加的步骤打印出来的密钥。

用同样的方式限定密钥的作用范围。Forgejo 在仓库、组织和实例三个层级都能存放密钥;一个只有某一个仓库需要用到的部署密钥,就应该只存在于那一个仓库上,这样一个不相关项目里被攻破的工作流才无法读到它。只要目标支持,就优先使用短期令牌而不是长期令牌,并且永远不要把密钥直接写进工作流文件——工作流文件是仓库的一部分,而仓库是有历史记录的。

如果你的构建每次都要拉取相同的基础镜像,就把它们缓存起来。一个本地镜像仓库缓存,或者一个持久化的运行器缓存,能把两分钟的镜像拉取变成两秒钟,对你正在高频请求的上游镜像仓库来说,这也是种礼貌。另外要注意,整个设计本身鼓励的这种模式,正是本指南与智能体运行时指南放在一起的原因:运行器本质上是一个从你的代码托管平台拉取任务的通用工作节点,所以定时维护任务、数据管道和智能体负载,同样也该放在这里。

步骤 06 · 留存

每一次克隆,都是代码的一份备份。但没有什么能替你备份那些“对话”。

在这一点上 Git 出奇地宽容,值得说清楚原因。每一次克隆都是历史记录的完整副本,所以只要有一个开发者手头有最新的工作副本,代码就能在服务器之外存活下来。这确实让人安心,但它只覆盖了你的代码托管平台所保存的两样东西中的一样。另一样——工单、拉取请求讨论、审查评论串、发布、wiki 页面、用户、团队、权限、CI 历史记录——只存在于数据库里,没有人持有它的克隆,而这往往正是花了三年时间才积累起来的部分。

首先,是推送镜像。它免费,而且自己就能运转起来。 在某个仓库中:Settings → Mirror settings → 添加一个推送镜像,填入目标地址和一个访问令牌。此后每次推送之后,Forgejo 都会把每一个分支和标签复制过去——可以是另一个 Forgejo,可以是 Codeberg,也可以是某个你决定只当“橱窗”而不当“家”的平台。有一点需要说明白:推送镜像会原样重新发布历史记录,包括你本想移除的密钥。

然后是一份真正的备份,而不是内置的归档功能。 Forgejo 自带一个 forgejo dump 命令,能把配置、仓库、数据和一份数据库转储打包进一个 zip 文件里。在报告 bug、需要交给维护者一份材料时,这确实很好用。但你不应该指望靠它起死回生:它生成的 SQL 在重新导入时存在由来已久的已知问题,而且这个归档每次运行都会重写整套仓库内容,在一个真实的实例上,这就意味着每天晚上都要从头写一个巨大的文件。换成那条无聊但可验证的路径——用数据库自带的工具转储数据库,再把数据目录当作普通文件备份:

#!/bin/sh
# /usr/local/bin/forgejo-backup — run from a systemd timer, nightly
set -eu
cd /srv/forgejo

# 1. Consistent database dump, written next to the data.
docker compose exec -T db pg_dump -U forgejo -Fc forgejo > ./db.dump

# 2. Everything that matters, to an encrypted offsite repository.
restic backup ./data ./db.dump ./docker-compose.yml ./.env ./Caddyfile

# 3. Retention, then prove the repository is still readable.
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
restic check

把它发送到一台与代码托管平台不在同一司法管辖区的机器上,通过一个仅追加的仓库来传输,这样即使代码托管平台被攻破,也无法抹去自己的历史记录——加密备份指南是上面这三行命令的完整版,其中包括 systemd 定时器和仅追加密钥限制。

然后,特意完整恢复一次。 下单一台临时 VPS,把快照恢复进去,用同一份 Compose 文件对着恢复出来的数据目录启动,导入数据库转储,在 hosts 文件里加一条记录指向它,然后登录看看。给整个过程计时。真正决定你恢复能力的,是这个数字,而不是“备份任务存在”这件事本身——每一个做过这项演练的人,第一次都会发现备份清单里漏掉了至少一样东西。

# restic keeps absolute paths, so the stack reappears under the target.
restic restore latest --target /srv/restore
cd /srv/restore/srv/forgejo

docker compose up -d db                # empty volume: the entrypoint creates the database
docker compose exec -T db pg_restore -U forgejo -d forgejo \
  --clean --if-exists --no-owner < db.dump

docker compose up -d                   # then sign in and walk the issue tracker

简单说说升级。 把镜像钉定在一个主版本号上,而不是 :latest,这样无人值守的拉取就不会在你睡觉的时候,把你带过一个不兼容的重大变更。跨越主版本边界之前先读发行说明,先打一个快照,并且记住数据库迁移会在新版本第一次启动时执行——这是一扇只能进不能退的门。Watchtower 那种自动更新的思路,对一个静态网站来说是个好主意,但对一台保存着你所有分支的机器来说,就不是了。

收工之前的四项检查。 退出登录,确认这个实例没有任何创建账户的入口。用一个没有会话的浏览器确认,一个私有仓库是彻底不可见,而不只是被禁止访问。确认运行器读不到代码托管平台的数据目录。再确认最近一次 restic check 是通过的——那种悄无声息就失败的备份任务,人人都有一个,直到真正需要它的那天才会现形。

诚实的部分 · 它做不到什么

你移除了一方当事人。但你没能移除那栋建筑。

自建代码托管平台,确实真真切切地改变了谁掌握着你的成果,但这并不等于隐形。做完上面的一切之后,有四件事依然成立,认清它们,才是“真正的防御”和“自己讲给自己听的故事”之间的区别。

磁盘,在别人的楼里。 一台租来的服务器,运行在某个数据中心里的硬件上,那里有门、也有工作人员。NordBastion 不会检查客户的工作负载,透明度报告和预警金丝雀说明了如果我们被要求配合会发生什么——但架构层面的说法才是最诚实的:改变的是,不再有针对你仓库的产品级索引,不再有分析你提交记录的数据管道,也不再有一份可以被随时修改的隐私政策。不变的是,这台机器依然是实体的,也依然不在你自己家里。

证书一签发,主机名就立刻公开。 每一张受公众信任的证书,都会被记录进任何人都能搜索的证书透明度日志,所以即便你从未链接过它,git.yourproject.com 依然是可以被发现的。这泄露的不是你的代码,而是“这个项目存在,而且大致是什么时候开始的”这一事实。可以使用一个你已经公开的域名下的子域名,或者干脆完全跳过公开证书,改用 WireGuard 或洋葱服务来访问这个代码托管平台。

现在,你要负责给一个 Web 应用打补丁了。 这个代码托管平台是一个登录页面、一条处理不受信任标记内容的渲染管线、一个 SSH 服务器,还是一个 CI 引擎,而且全都能从互联网访问到。这比步骤 01 里的裸仓库,攻击面要大得多,而且你必须让它保持最新。订阅发布公告,把安全更新当成“这周就得处理”的事,而不是“这个季度找时间弄”的事。

联邦化还没到来。 Forgejo 在代码托管平台的联邦化上确实在做实事,也一直在公布进展,但截至目前,你还无法像在共享平台上那样,从自己的实例上向别人的实例发起拉取请求。这在实践中意味着:外部贡献者要么仍需要一个你这边的账户,要么你改为通过邮件接收补丁,要么你保留一个公开镜像,专门接收路过式的贡献。提前为符合你项目情况的那一种做好打算,而不是等它发生了才感到意外。

你确切买到的到底是什么:这份记录了你在构建什么、和谁一起构建、以及何时开始的带日期的记录,不再由一家把分析它写进商业模式里的公司掌握。这个说法很有限,但它是真实的。而它只有在步骤 06 真正完成之后,才配得上你为此付出的代价——一个从未测试过恢复的自建代码托管平台,算不上什么隐私上的进步,它只是一个包装得更好看的单点故障。

选型 · 该订购什么

代码托管平台本身很小。CI 可不小。

套餐 按月 资源 能轻松承载的内容
Sentinel$3.902 vCPU · 4 GB · 120 GBForgejo + PostgreSQL,适合一个人或一个小团队。正确的起点。
第二台 Sentinel$3.90运行器,独立成机真正重要的那层隔离,只要一杯咖啡的价钱
Garrison$7.904 vCPU · 8 GB · 240 GB十人团队的代码托管平台,加上容器镜像仓库和构建产物
Ravelin$16.908 vCPU · 16 GB · 480 GB繁忙的 CI、体积庞大的仓库、多个并行任务

这张表里没有一项是为 Git 本身准备容量的。Forgejo 和 PostgreSQL 加在一起,空载时也就几百兆字节;真正把机器塞满的是 CI——容器镜像层、构建缓存、构建产物——以及仓库里那些不是源代码的内容。评估容量时,先看磁盘,再看核心数,最后才看内存。这里两台小机器胜过一台大机器,因为第二台机器提供的是安全边界,而不是容量。

实战笔记 · 六个陷阱

六种出问题的方式。其中两种会让你花钱。

陷阱 01 · 喧闹

开放注册,换来一个挤满陌生人的代码托管平台

证书一出现在透明度日志里,扫描器几天之内就会跟上,而一个开着注册功能的实例,很快就会被垃圾账户和垃圾仓库塞满。在第一次启动之前就设置好 DISABLE_REGISTRATION,而不是等第一次出事之后才想起来。

陷阱 02 · 烧钱

替别人挖矿的运行器

对任何拉取请求都运行工作流的 CI,对发现它的任何人来说都是免费算力。要求对首次贡献者的工作流进行审批,把运行器放在独立的机器上,并偶尔看一眼任务的运行时长。

陷阱 03 · 无声

ROOT_URL 配错,一切都“半吊子”能用

克隆地址指向了错误的主机,webhook 发到了错误的地址,OAuth 回调失败,头像也显示不出来。界面看起来一切正常,所以没人会怀疑是某一行配置的问题。把 DOMAIN、ROOT_URL、SSH_DOMAIN 和 SSH_PORT 一起设置好,并在界面里检查一下克隆地址。

陷阱 04 · 迷惑

两个 SSH 服务器,一个端口

容器想用 22 端口,但主机早就在用了,于是映射会悄无声息地落到一个意料之外的地方,git push 也开始要求输入密码。先把管理用的 SSH 挪走,再把容器发布到一个属于它自己的端口上,并用 ssh -T 验证一下。

陷阱 05 · 致命

一份从未被恢复过的转储

内置的归档功能是一份用于支持排障的材料,不是经过验证的恢复路径,而且它生成的 SQL 在重新导入时存在已知问题。用 pg_dump 转储数据库,把数据目录当作普通文件备份,并在一台临时机器上完整恢复一次。

陷阱 06 · 缓慢

塞满磁盘的是构建产生的镜像层,而不是代码

源代码体积很小;容器镜像层、构建产物和 CI 缓存可不小。一台 120 GB 的机器,配上一条“热情高涨”的流水线,几周之内就会被塞满。定时清理镜像,限制构建产物的保留时长,多盯着磁盘,而不是 CPU。

常见问题 · 私有 Git 托管

问题,已解答。

搭建过程中会遇到的十一个问题——从决定你到底要不要搭建它的那两个问题开始。

Forgejo 还是 Gitea——我到底该装哪一个?

对大多数人来说是 Forgejo,原因在于治理方式,而不是功能。这两个项目师出同源,用起来也几乎一模一样:同样的 Go 二进制程序,同样轻量的资源占用,同样的 Web 界面,CI 能力也不相上下。真正不同的是由谁来掌舵。Forgejo 由 Codeberg e.V. 持有,这是一家总部位于柏林的非营利注册社团,于 2024 年初硬分叉而来;它把一切都以自由软件的形式发布,也不要求贡献者转让版权。Gitea 则由一家营利性公司开发,该公司拥有商标和域名,要求贡献者转让版权,并在免费版本的基础上运行着一套开源核心模式,附带付费功能。这些事实都不影响 Gitea 作为软件本身的质量。但把仓库迁出某个平台,整件事的意义就在于不再受制于某家公司的路线图,所以选择由非营利组织托管的那个分支,才符合你来看这篇文章的初衷。给以后可能要切换的人一个实用提示:Forgejo 可以直接安装在已有的 Gitea 数据目录之上,反过来则不支持。

我真的需要一个代码托管平台吗?通过 SSH 使用裸仓库还不够吗?

对于只有一台笔记本电脑、并且习惯写出高质量提交信息的一个人来说,裸仓库真的就够用了,而且它是本指南中最皮实的方案:在服务器上执行一条 git init --bare,配上一个普通的 SSH 账户,没有 Web 应用需要打补丁,没有数据库需要备份,除了 sshd 之外也没有别的攻击面。这套方案永远不会有停止维护的一天。一旦有第二个人参与进来,或者你想要裸仓库完全无从谈起的那四样东西——可浏览的历史记录、带审查的拉取请求、与代码放在一起的工单系统,以及按仓库划分的访问控制——你就需要一个代码托管平台了。这不是一个小台阶,而是存储和协作之间的本质区别。要老实面对自己到底在解决哪个问题,因为代码托管平台是一项你现在要亲自运维的服务,而裸仓库只是一个目录。

我可以迁移我在 GitHub 上的仓库吗?包括工单和拉取请求?

可以,而且这是自建服务中体验较好的迁移方案之一。Forgejo 内置的迁移工具只需要一个仓库地址和一个个人访问令牌,就能拉取远不止代码本身的内容:分支和标签、带评论的工单、拉取请求、标记、里程碑和发布。给它一个具备只读权限的令牌,先迁移一个仓库看看效果,再处理剩下的。有三件事要提前预料到。规模大或数量多的迁移会碰到源平台的 API 速率限制,只是耗时更久而已——启动之后放着让它跑就行。工单上的附件是通过网络抓取的,所以如果实例的出站防火墙限制较严,产生的工单里图片就会失效。而且审查评论串并不总能原样落位,因为两边的数据模型并不完全相同。打算把源仓库保留为只读状态几周,而不是当天下午就删掉。

自建的代码托管平台真的更私密,还是只是不一样?

确实存在一个具体且重要的不同点:除了你之外,没有人掌握这份索引。在平台上,私有仓库只是对其他用户私有——对平台本身并不私有,平台会存储它、扫描它、从中计算出依赖关系图,并且可能被该平台所在司法管辖区的法院强制要求交出。即便代码本身平淡无奇,这份索引也异常能说明问题:提交时间戳描述了你的作息,共同作者描述了你的团队,分支名称描述了尚未发布的产品,第一次提交的时间则为一个尚未公开的项目标注了起点。把它搬到自己的服务器上,就把平台从这整幅图景中彻底移除了。但它做不到的是,让这台机器凭空消失。你依然是在某个数据中心里租用硬件,磁盘依然可以被那栋楼的所有者从物理上接触到,你选定的那个主机名也依然会出现在证书透明度日志里。老实说:你只是把一家有着合同隐私政策的公司,换成了一台唯一政策就是你自己配置的服务器,放在一个你刻意选定的司法管辖区里——而为它付款的那个账户,并不需要写着你的名字。

VPS 需要多大?

比 GitLab 的文档训练大家所以为的要小得多。Forgejo 和 Gitea 都是单个 Go 二进制程序,空载时只占用几百兆字节的内存;加上 PostgreSQL 和一个反向代理,一个独立开发者的代码托管平台放在一台 Sentinel 上——2 vCPU,4 GB,120 GB NVMe,$3.90/月——依然绰绰有余。真正的压力来自另外三个地方,每一个都值得专门评估容量。CI 是大头:一个用来构建容器的运行器会吃光你给它的每一个核心,并用镜像层把磁盘塞满。仓库体积是第二个:游戏素材、笔记本文件和随仓库一起提交的依赖库,增长速度远超源代码本身。构建产物和容器镜像仓库是第三个。实践中效果不错的一条经验法则——把代码托管平台放在一台 Sentinel 上,把运行器放在另一台独立的 Sentinel 上,只有在磁盘发话的时候才升级档位,而不是等 CPU 先喊累。

Forgejo Actions 真的能兼容我的 GitHub Actions 工作流吗?

兼容程度足以让大多数直白的工作流原封不动地跑起来,但还没兼容到可以让你不读一遍就迁移一条复杂流水线。工作流文件格式是一样的,都放在 .forgejo/workflows/ 目录下(.github/workflows/ 也同样会被读取),像检出代码、配置语言环境、缓存、矩阵构建这些常见构建模块,表现也和预期一致,因为运行器执行它们的方式同样是在容器里进行。真正会出问题的,是那些依赖“GitHub 这个平台”而不是“Actions 这套格式”的东西:钉死在 github.com 网址上的应用市场 Actions(你的实例必须被允许访问这些地址)、需要调用 GitHub REST API 获取发布或 PR 元数据的 Actions、预装了非常特定工具链的 GitHub 托管运行器镜像,以及基于 OIDC 的云端身份验证。行之有效的迁移方式是:先从一个只负责构建和测试的工作流开始,确保它能顺利跑通,再逐步把部署步骤一个个加回来。

如果这台 VPS 挂了,我的代码会怎样?

代码本身是唯一没问题的部分,这也是 Git 相较于几乎所有其他自建服务不动声色的优势:每一次克隆都是历史记录的完整副本,所以只要有一个开发者手头有当前的工作副本,仓库就不会丢失。随服务器一起消失的,是围绕代码的一切——工单、拉取请求讨论、审查评论、发布、wiki、用户、访问规则、CI 历史记录。这些内容只存在于数据库中,没有人持有它的克隆,而且往往正是那些花了好几年才积累起来的部分。所以要区别对待这两者。对于代码,配置一个指向第二地点的推送镜像,你就能几乎不费力气地获得持续、自动的异地保护。对于元数据,你需要一份真正的备份:一份 PostgreSQL 转储加上数据目录,加密后按计划发送到另一个司法管辖区的机器上。步骤 06 会同时讲到这两者,而末尾的恢复演练,正是把这些“应该没问题”变成“确实有保障”的关键一步。

我可以把 GitHub 保留为一个镜像,而不是彻底离开它吗?

可以,而且对很多项目来说,这是正确答案,而不是一种妥协。Forgejo 支持推送镜像,所以你的实例才是团队真正工作所依赖的源头,每一次推送都会自动复制到 GitHub、Codeberg 或另一个 Forgejo 上的仓库。公开可见性、路过式的贡献者、期望使用 GitHub 网址的软件包仓库,以及旧 README 里的工单链接,都能照常工作,而真正的权威版本——连同私有仓库、CI 日志,以及任何你没有公开发布的工单历史——都留在你自己的机器上。反过来也一样:拉取镜像能让你依赖的上游项目始终保留一份本地的最新副本,对于那些一旦哪天被删除你就会陷入被动的项目,这样做很值得。唯一需要谨慎对待的是你到底在镜像什么:如果一个仓库的历史记录中包含你原本想要移除的密钥,对它做推送镜像,就会立刻、自动地把那个密钥重新发布出去。

我该如何只给承包商访问一个仓库的权限,而不把其余仓库也一并开放?

选用与所需访问期限相匹配的那一层机制。对于需要阅读、提交并发起拉取请求的人,创建一个用户账户,把他们加为协作者,精确添加到他们需要的那些仓库上,并给出明确的权限级别——只读、写入或管理员——而不是把他们塞进一个会悄悄授予更多权限的组织团队里。对于只需要拉取代码的自动化系统——一个部署目标、一台构建机器、一个镜像——用只读的部署密钥来代替用户账户:它只作用于一个仓库,不授予任何其他权限,撤销它也只是删除一行配置。对于要调用 API 的脚本,签发一个限定范围的访问令牌,而不是直接交出密码。两个习惯能让整套机制真正可靠:保持注册关闭,让存在的账户都是你亲自创建的;并且在你授予临时访问权限时,在日历上记下一个日期,因为不会有任何代码托管平台主动提醒你合同已经到期。

这个代码托管平台需要域名吗?这会不会泄露我的身份?

它需要一个主机名供 Web 界面使用,因为 TLS 证书是签发给“名称”的,这里不应该让任何浏览器去信任一个 IP 地址。但这个名称并不需要能追溯到你本人。有三件事需要刻意留心。把域名注册在一个不会要求你交出身份信息的地方——毕竟你在其他地方已经小心处理过这份身份信息了——并保持 WHOIS 隐私保护开启:注册商是一个与主机商相互独立的信任决策,也是人们最容易忘记的一环。要预料到,证书一经签发,主机名就会立刻公开,因为每一张受公众信任的证书都会被记录进任何人都能搜索的证书透明度日志;在一个你已经在用的域名上使用通配符证书,可以避免再新增一个可被搜索到的名称。而如果你宁愿完全没有公开名称,也确实有替代方案:把代码托管平台架设在 Tor 洋葱服务上,或者只能通过 WireGuard 访问,彻底跳过公开证书,并接受“随处打开浏览器就能访问”的便利也将随之消失。

如果我在 VPS 上运行 Git 代码托管平台和 CI 运行器,NordBastion 会介意吗?

完全不会——代码托管平台和它的运行器都是很常规的负载,也是我们客户搭建得最多的东西之一。可接受使用政策里,对托管源代码或构建源代码没有任何限制。这里要提两点运维上的注意事项,而不是政策上的。第一,一个接受陌生人工作流的 CI 运行器,对任何发现它的人来说都是一份免费的算力服务:最常见的滥用方式就是在拉取请求里塞进加密货币挖矿程序,而且开放不了几天就会被人发现。保持注册关闭,并且要求对还不是协作者的人,在运行工作流之前先经过批准。第二,如果你的构建每次都要拉取体积很大的容器镜像,这属于正常流量,已经包含在套餐之内——但仍然值得把它们缓存到本地,因为这样能让你的流水线更快,对你所依赖的上游镜像仓库来说,这也是种礼貌。

获取这台机器

一个属于你自己的代码托管平台,架在一个不是你名字的账户上。无 KYC,加密货币付款。

Sentinel——2 vCPU 和 4 GB,$3.90/月,可选 Helsinki、Stockholm、Oslo 或 Reykjavík。足以运行 Forgejo、PostgreSQL 和反向代理,还留有余量给那些你一直想搬过来的仓库。再加一台给运行器用。注册无需邮箱,无需身份证件,你推送的任何内容也都不会被做成产品级索引。

最后审核 · 2026-10-12 · 来源 · Forgejo 管理员文档(安装、Actions、命令行)、Forgejo 与 Gitea 的对比、Codeberg e.V. 治理相关页面、PostgreSQL 与 Caddy 文档、git-config(1) · 周期 · 每年

私密托管,源自北欧。