OneDev 可以在一个实例里管理 Git 仓库、代码评审、Issue、CI/CD、构建制品和软件包。需要把代码留在自有服务器,又不想分别维护 Git 服务、CI 和包仓库时,可以考虑使用 OneDev。它的单机资源要求也低于完整 GitLab。

部署环境是一台 Linux 服务器:OneDev 和 PostgreSQL 由 Docker Compose 运行,Caddy 提供 HTTPS。实例就绪后,再创建一个 Go 项目,依次完成代码推送、自动测试、镜像构建、生产发布、备份、恢复和升级。只看到容器处于 running 状态,还不能确认仓库、Git SSH、流水线和恢复功能可用。

OneDev 是什么,和普通 Git 服务有什么区别

普通 Git 服务负责远程保存和传输仓库。OneDev 还会记录哪些改动必须评审、哪个提交触发构建、构建使用了哪些参数、生成了什么制品,以及制品发布到了哪里。

能力OneDev 中的对象对项目的实际作用
Git 托管Project、Repository、Branch、Tag保存代码,控制读取、推送和分支权限
代码协作Pull Request、Review Rule、Code Comment把评审人、通过条件和 CI 结果绑定到改动
工作管理Issue、Board、Milestone让缺陷和需求能够关联提交、构建与版本
CI/CD.onedev-buildspec.yml、Job、Step、Trigger把测试、构建和发布过程随代码版本管理
构建结果Build、Artifact、Report保留日志、测试报告、代码问题和可下载制品
包管理Container、npm、Maven、PyPI 等 Registry使用项目权限管理软件包,并追溯到对应的构建、提交和 Issue

OneDev 的概念文档(在新标签页打开)把构建规范放在仓库根目录的 .onedev-buildspec.yml 中;Job 可以由分支更新、标签、Pull Request 或其他 Job 的结果触发。包管理文档(在新标签页打开)说明,经 CI/CD 发布的包会自动关联对应的构建、代码和 Issue。

选择自建后,系统升级、数据库、磁盘、TLS 证书、邮件、构建执行器、备份和故障恢复也要自己维护。如果只需托管公开仓库,不需要访问私有网络或控制发布环境,使用托管平台通常更省时间。

OneDev 适合谁,和 GitHub、GitLab、Gitea、Forgejo 怎么选

先确认代码是否必须保存在自有服务器、构建是否需要访问内网、现有流水线能否迁移,以及谁来维护平台。只比较是否免费或能否运行 Docker,容易漏掉工作流迁移和构建凭据保护的成本。

平台更合适的场景CI/CD 路径需要承担的维护
GitHub开源协作、生态集成、希望直接使用托管服务GitHub-hosted 或 self-hosted runner托管模式最省运维;私有网络和自托管 runner 仍需治理
GitLab Self-Managed需要完整企业 DevSecOps 能力并有专门平台团队内置 CI 模型配合 Runner功能广,单机基线和组件运维成本也更高
Gitea优先考虑轻量 Git 托管,并希望沿用接近 GitHub Actions 的工作流Gitea Actions 配合独立 Gitea Runner实例与 Runner 都要维护,兼容性要按真实 workflow 验证
Forgejo重视社区治理、自由软件路线和轻量自建Forgejo Actions 配合独立 RunnerActions 有自己的实现边界,迁移不能假定完全兼容 GitHub Actions
OneDev希望用较小单机统一仓库、评审、CI/CD、报告和包,并接受它自己的 Build SpecServer Docker Executor 或远程 Agent需要维护实例、数据库、执行器、备份和升级

GitLab 当前安装要求(在新标签页打开)把单节点基线列为 8 vCPU 和 16 GB 内存,内存受限环境最低仍为 8 GB。Gitea(在新标签页打开)Forgejo(在新标签页打开)都通过独立 Runner 执行 Actions;Forgejo 官方明确说明,它不承诺与 GitHub Actions 完全兼容。OneDev 的官方最低配置更低,发布的软件包还能自动关联构建、代码和 Issue。已有 GitHub Actions 或 GitLab CI 需要改写为 OneDev Build Spec,不能直接照搬。

适合使用 OneDev 的情况

  • 代码需要留在自有服务器或私有网络;
  • 团队规模不大,希望一套系统覆盖仓库、Issue、评审、构建和包;
  • 构建需要访问内网服务、自建镜像仓库或部署目标;
  • 能安排明确的维护人处理升级、备份和恢复演练。

不适合使用 OneDev 的情况

  • 团队没有人负责 Linux、Docker、数据库和备份;
  • 公开仓库会接收大量不受信任的 Pull Request,却准备让任务共享宿主 Docker socket;
  • 已有大量 GitHub Actions 或 GitLab CI,迁移成本高于自建带来的收益;
  • 需要跨区域高可用、大规模并发构建或严格合规控制;这些场景要单独设计集群、灾备和审计体系。

OneDev 部署完成后的代码发布流程

开发者先把示例项目推送到 OneDev。流水线随后测试代码、构建镜像,并把镜像 digest 交给生产服务器。生产容器使用的 digest 可以反查对应的 commit 和构建记录。

开发者把代码推送到 OneDev,CI 经过 Docker Executor 完成测试和镜像构建,把不可变镜像发布到 Registry,再部署到生产服务器;Caddy、PostgreSQL、site 数据和备份构成 OneDev 自身的运行基础
应用发布使用 commit 和镜像 digest 定位版本;OneDev 故障时使用数据库、site 数据和配置恢复平台。
阶段输入完成标志
托管代码本地 Git 提交OneDev 项目页显示同一 commit,SSH/HTTPS 均可按权限读取
运行 CIcommit 与 .onedev-buildspec.yml测试步骤通过,失败能定位到具体 Step 和日志
生成制品通过测试的源码二进制或 Artifact 可下载,容器镜像得到 sha256 digest
发布生产精确镜像 digest生产服务健康检查通过,运行容器引用与发布记录一致
失败恢复上一个健康制品入口重新返回健康响应,失败发布保留日志但不继续接流量
平台恢复同一时点的数据库、site 与配置仓库、Issue、附件、构建记录和公开入口都能重新验证

Web 请求走 443 → Caddy → 127.0.0.1:6610;Git SSH 单独使用 6611;OneDev 和 PostgreSQL 只在 Docker 私有网络通信。受信任的小型构建可以使用本机 Server Docker Executor,公开仓库和外部 Pull Request 应交给隔离的远程 Agent。生产应用使用自己的容器、数据卷和发布脚本,不与 OneDev 混用。

准备服务器、域名和端口

OneDev 官方 Docker 安装页(在新标签页打开)给出的起点是 2 核 CPU、2 GB 内存和 6610/6611 两个端口。这个配置适合体验,不应直接当成同时运行 PostgreSQL 和构建任务的生产容量。

准备一台可以 SSH 管理的 64 位 Linux 主机。操作过程中需要使用 Git、编辑 YAML,并检查 DNS 和端口。主机还没有 Docker Engine、Compose v2 或 Caddy 时,先按 Docker 的 Ubuntu 安装文档(在新标签页打开)Caddy 的 Debian/Ubuntu 安装文档(在新标签页打开)完成安装。已有业务主机还要先确认现有容器、端口、数据卷和入口配置,避免安装时覆盖其他服务。

个人或小团队可以从 4 核、8 GB 内存和 SSD 开始。给 OneDev 与 PostgreSQL 合计预留约 3.5 GB 内存,其余资源留给 Docker、Caddy、缓存和一次轻量构建。大型前端项目、多个 Docker 镜像并行构建或高内存测试应放到远程 Agent;增加 swap 不能代替足够的物理内存。

磁盘不能只按 Git 仓库大小估算。至少计算:

所需空间 ≈ site 数据 + PostgreSQL 数据 + 构建工作区峰值
         + 本地镜像与缓存 + 两份完整恢复点 + 30% 余量

如果 /opt/onedev/var/backups/onedev 在同一块盘,两份恢复点仍不能抵御整盘损坏。在把 OneDev 作为代码的唯一保存位置之前,还要准备另一台主机或对象存储作为异机备份目的地。

域名示例使用 code.example.com。先添加指向服务器公网 IPv4 的 A 记录;只有服务器具备可达的公网 IPv6 时才添加 AAAA,避免客户端优先走向一个不可用地址。随后检查解析结果:

dig +short code.example.com A
dig +short code.example.com AAAA
curl -4 https://ifconfig.me

需要放行的入口如下:

端口对外范围用途
22/tcp仅管理网络或固定来源主机管理 SSH;可改为其他端口
80/tcp公网ACME HTTP 验证和 HTTPS 跳转
443/tcp公网OneDev Web 与 Git HTTPS
6610/tcp127.0.0.1Caddy 到 OneDev 的回环上游
6611/tcp按实际开发者来源;公开 SSH 克隆时才开放公网OneDev Git SSH,不是主机管理 SSH

先检查 Docker 与 Compose 版本、可用内存、剩余磁盘和端口占用:

uname -a
docker version
docker compose version
free -h
df -hT / /opt /var/backups 2>/dev/null || true
sudo ss -lntp '( sport = :80 or sport = :443 or sport = :6610 or sport = :6611 )'

端口已被旧服务占用时,先查清进程、容器、Compose project 和挂载目录的归属。直接停止一个“不认识的容器”可能同时破坏其他站点,尤其是多业务共用 Caddy 的主机。

用 Docker Compose 部署 OneDev 和 PostgreSQL

运行文件、持久化数据与仅 root 可读的密钥使用不同目录:

/opt/onedev/
├── compose.yaml
├── compose.bootstrap.yaml
├── compose.executor.yaml
├── compose.quiesced.yaml
├── .env
├── data/
└── postgres_data/

/etc/onedev/
└── secrets/
    ├── postgres_password
    └── admin_password

/var/backups/onedev/

创建目录和随机密码:

sudo install -d -m 750 /opt/onedev/data /opt/onedev/postgres_data
sudo install -d -m 700 /etc/onedev/secrets /var/backups/onedev
openssl rand -hex 32 | sudo tee /etc/onedev/secrets/postgres_password >/dev/null
openssl rand -base64 36 | sudo tee /etc/onedev/secrets/admin_password >/dev/null
sudo chmod 600 /etc/onedev/secrets/postgres_password \
  /etc/onedev/secrets/admin_password

/opt/onedev/.env 只保存非秘密参数。镜像引用同时保留可读版本和不可变 OCI digest:

ONEDEV_IMAGE=1dev/server:16.4.2@sha256:ddb7e414e2e3038ab522ab4e517a95a5580cac0177ef5ee171fe37e4f209a34b
POSTGRES_IMAGE=postgres:16.14-alpine3.23@sha256:42b8b8b29c8a4e933d88943e5b03001a78794905cf786e6e7634e9f2abd5a0d3
ONEDEV_DOMAIN=code.example.com
ONEDEV_SSH_PORT=6611
ONEDEV_ADMIN_USER=admin
ONEDEV_ADMIN_EMAIL=admin@example.com
BACKUP_RETENTION_DAYS=14
TZ=Asia/Shanghai

这两个 digest 核验于 2026 年 9 月 3 日,对应 OneDev 16.4.2 与 PostgreSQL 16.14。升级前重新核对目标 tag 与 digest,并阅读跨版本升级说明。digest 可以防止 tag 被重新指向后自动拉到另一份镜像;漏洞扫描、签名验证和发布说明仍要单独检查。具体做法见 Docker 镜像为什么要固定 digest

/opt/onedev/compose.yaml

name: onedev

services:
  postgres:
    image: ${POSTGRES_IMAGE:?POSTGRES_IMAGE is required}
    container_name: onedev-postgres
    restart: unless-stopped
    cpus: 1.0
    mem_limit: 768m
    shm_size: 256m
    volumes:
      - ./postgres_data:/var/lib/postgresql/data
    secrets:
      - postgres_password
    environment:
      POSTGRES_USER: onedev
      POSTGRES_DB: onedev
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
      PGDATA: /var/lib/postgresql/data
      TZ: ${TZ:-Asia/Shanghai}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U onedev -d onedev"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks:
      - onedev
    logging:
      driver: local
      options:
        max-size: 50m
        max-file: "5"

  onedev:
    image: ${ONEDEV_IMAGE:?ONEDEV_IMAGE is required}
    container_name: onedev
    restart: always
    cpus: 2.0
    mem_limit: 2560m
    stop_grace_period: 2m
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    ports:
      - "127.0.0.1:6610:6610"
      - "${ONEDEV_SSH_BIND_HOST:-0.0.0.0}:${ONEDEV_SSH_PORT:-6611}:6611"
    volumes:
      - ./data:/opt/onedev
      - /var/backups/onedev:/var/backups/onedev
    secrets:
      - postgres_password
    environment:
      hibernate_dialect: io.onedev.server.persistence.PostgreSQLDialect
      hibernate_connection_driver_class: org.postgresql.Driver
      hibernate_connection_url: jdbc:postgresql://postgres:5432/onedev
      hibernate_connection_username: onedev
      hibernate_connection_password_file: /run/secrets/postgres_password
      TZ: ${TZ:-Asia/Shanghai}
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - onedev
    logging:
      driver: local
      options:
        max-size: 50m
        max-file: "5"

secrets:
  postgres_password:
    file: /etc/onedev/secrets/postgres_password

networks:
  onedev:
    driver: bridge

运行文件由 root 管理,普通用户不应修改镜像、端口或密钥挂载:

sudo chown root:root /opt/onedev/.env /opt/onedev/compose.yaml
sudo chmod 600 /opt/onedev/.env
sudo chmod 640 /opt/onedev/compose.yaml

本机要承载可信的轻量构建时,把 Docker socket 单独放进 /opt/onedev/compose.executor.yaml

services:
  onedev:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
sudo chown root:root /opt/onedev/compose.executor.yaml
sudo chmod 640 /opt/onedev/compose.executor.yaml

先展开 Compose 配置并拉取固定镜像:

cd /opt/onedev
sudo docker compose --env-file .env \
  -f compose.yaml \
  -f compose.executor.yaml \
  config --quiet
sudo docker compose --env-file .env \
  -f compose.yaml \
  -f compose.executor.yaml \
  pull

depends_on: service_healthy 只控制启动顺序。OneDev 运行期间如果 PostgreSQL 断开,Compose 不会判断正在执行的事务是否安全,也不会确认数据库恢复后 OneDev 已经可用,因此两项服务仍要分别监控。

Caddy 独占 80/443,OneDev 的 6610 不暴露到公网。主 Caddyfile 需要包含 import /etc/caddy/sites/*.caddy;确认导入规则后,把站点片段保存为 /etc/caddy/sites/onedev.caddy

code.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:6610
}
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

先不要 reload Caddy。管理员账号、匿名访问和自助注册策略确认完成后,再开放公网入口。

完成首次初始化和基础设置

initial_* 参数只用于空数据目录的第一次启动。首启完成后,这些参数不再起作用,却会继续出现在 docker inspect 的输出中。把它们单独放进 /opt/onedev/compose.bootstrap.yaml

services:
  onedev:
    secrets:
      - admin_password
    environment:
      initial_user: ${ONEDEV_ADMIN_USER:?ONEDEV_ADMIN_USER is required}
      initial_password_file: /run/secrets/admin_password
      initial_email: ${ONEDEV_ADMIN_EMAIL:?ONEDEV_ADMIN_EMAIL is required}
      initial_server_url: https://${ONEDEV_DOMAIN:?ONEDEV_DOMAIN is required}
      initial_ssh_root_url: ssh://${ONEDEV_DOMAIN:?ONEDEV_DOMAIN is required}:${ONEDEV_SSH_PORT:-6611}

secrets:
  admin_password:
    file: /etc/onedev/secrets/admin_password
sudo chown root:root /opt/onedev/compose.bootstrap.yaml
sudo chmod 640 /opt/onedev/compose.bootstrap.yaml

空实例第一次启动时加载基础、首启和执行器三个 Compose 文件:

cd /opt/onedev
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
  -f compose.yaml \
  -f compose.bootstrap.yaml \
  -f compose.executor.yaml \
  up -d

curl --fail --retry 36 --retry-delay 5 --retry-all-errors \
  http://127.0.0.1:6610/ >/dev/null
sudo docker compose --env-file .env \
  -f compose.yaml \
  -f compose.bootstrap.yaml \
  -f compose.executor.yaml \
  logs --tail 100 onedev

日志出现服务器 ready 信息,回环地址也返回成功后,继续保持 Git SSH 的回环绑定,只移除首启管理员覆盖:

sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
  -f compose.yaml \
  -f compose.executor.yaml \
  up -d --no-deps onedev
curl --fail --retry 36 --retry-delay 5 --retry-all-errors \
  http://127.0.0.1:6610/ >/dev/null

此时 docker inspect onedev 的环境变量不应再出现 initial_userinitial_password_fileinitial_emailinitial_server_urlinitial_ssh_root_url。管理员密码继续保存在仅 root 可读的文件和密码管理器中,供登录、Settings API 与备份前的凭据校验使用,但不再挂回日常容器。

在管理员电脑建立 SSH 隧道,再打开 http://127.0.0.1:16610 登录管理界面:

ssh_user=ubuntu
server_ip=203.0.113.20 # 替换为真实服务器地址
ssh -L 16610:127.0.0.1:6610 "${ssh_user}@${server_ip}"

登录后核对这些设置:

设置示例值或判断配错后的表现
System URLhttps://code.example.com页面链接、Webhook 或回调生成错误地址
SSH Root URLssh://code.example.com:6611克隆地址缺端口或连到主机 SSH
匿名访问私有实例通常关闭未登录用户可能看到不应公开的项目
自助注册使用本机 Docker Executor 时关闭未知用户可能获得提交或构建入口
邮件服务器能向真实收件箱发送测试邮件邀请、重置密码和通知无法送达

容器时区由 /opt/onedev/.env 中的 TZ 控制,不属于上表的管理界面设置。启动后分别检查 OneDev、PostgreSQL 和主机时间,三处都应使用同一时区约定;跨地域团队也可以统一使用 UTC。

进入 Site Administration → Job Executors,创建一个 Server Docker Executor,并核对这些值:

设置建议值作用
Nametrusted-local-dockerJob 通过名称选择这个本机执行器
Concurrency1避免共享主机并发挤压 OneDev 与 PostgreSQL
Docker Builderonedev固定 Buildx builder 名称
Always Pull Image开启运行前解析步骤镜像,不依赖陈旧本地 tag
Mount Docker Sock关闭不把宿主 Docker socket 继续传进普通构建容器
CPU Limit1.5约束单个构建容器的 CPU
Memory Limit1536m给 OneDev 和 PostgreSQL 保留内存
Site/HTML Publish关闭当前流水线不发布静态站点或 HTML 报告

需要用 Settings API 重复维护这些设置时,先读取现有对象,只修改目标字段,写回完整对象后再读取一次确认结果。不要用一份缺少字段的 JSON 覆盖服务端对象。手工设置完成后,再开放正常入口:

sudo systemctl reload caddy
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
developer_cidr='203.0.113.0/24' # 替换为真实开发者出口 CIDR
sudo ufw allow from "${developer_cidr}" to any port 6611 proto tcp
sudo ufw status numbered

cd /opt/onedev
sudo docker compose --env-file .env \
  -f compose.yaml \
  -f compose.executor.yaml \
  up -d --no-deps onedev

开发者来源无法固定且确实需要公开 SSH 克隆时,才把 6611 规则改为 sudo ufw allow 6611/tcp;不需要 Git SSH 则不发布 6611,只使用 443 上的 Git HTTPS。云安全组使用同样的来源范围。

Docker 的 Ubuntu 安装文档(在新标签页打开)提醒,发布容器端口可能绕过 UFW 或 firewalld 的常规规则。限制来源时要同时检查云安全组和 Docker 转发规则,并从另一张网络测试:

curl -fsS https://code.example.com/ >/dev/null
ssh -T -p 6611 git@code.example.com

Git SSH 测试不一定打开交互 shell。返回 OneDev 的握手响应而不是连接超时、拒绝或主机系统 SSH banner,才说明 6611 到达了正确服务。

创建第一个项目并推送代码

开发机先生成一把专用于 OneDev 的 SSH Key;已有独立密钥可以直接使用:

ssh-keygen -t ed25519 -C "onedev-code" -f ~/.ssh/id_ed25519_onedev
cat ~/.ssh/id_ed25519_onedev.pub

在 OneDev 的个人设置中添加公钥,再创建私有项目 demo/hello。项目路径会进入 Git URL;以后修改路径时,Git remote、镜像地址和部署脚本也要一起修改。

准备一个最小 Go 服务:

mkdir hello && cd hello
go mod init example.com/hello

main.go

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	log.Fatal(http.ListenAndServe(":8080", newHandler()))
}

func newHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.NotFound(w, r)
			return
		}
		_, _ = fmt.Fprintln(w, "hello from OneDev")
	})
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = fmt.Fprintln(w, "ok")
	})
	return mux
}

main_test.go

package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestHealthResponse(t *testing.T) {
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
	newHandler().ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != "ok" {
		t.Fatalf("unexpected response: code=%d body=%q", recorder.Code, recorder.Body.String())
	}
}

Dockerfile

FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS build
WORKDIR /src
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go test ./... \
    && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/hello .

FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/hello /usr/local/bin/hello
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/hello"]

提交并推送:

git init
git add .
git commit -m "Add hello service"
git branch -M main
git remote add origin ssh://git@code.example.com:6611/demo/hello.git
GIT_SSH_COMMAND='ssh -i ~/.ssh/id_ed25519_onedev -o IdentitiesOnly=yes' \
  git push -u origin main

项目页应显示刚才的 commit、仓库文件和 main 分支。出现 Permission denied (publickey) 时,检查 OneDev 账号中保存的公钥;连接超时时,检查 6611 的端口映射、防火墙和安全组;看到主机登录提示时,连接的是管理 SSH,不是 OneDev Git SSH。

不开放 6611 时,可以改用 OneDev 页面提供的 HTTPS URL。启用 2FA 后不要把账户密码写进 Git 配置,应使用权限受限的访问令牌或凭据管理器。

配置第一条 OneDev CI/CD 流水线

OneDev 的构建配置随仓库提交保存。项目根目录新建 .onedev-buildspec.yml,先只完成检出、测试、构建和 Artifact 发布:

version: 52

jobs:
  - name: CI
    jobExecutor: trusted-local-docker
    steps:
      - type: CheckoutStep
        name: checkout
        cloneCredential:
          type: DefaultCredential
        withLfs: false
        withSubmodules: false
        condition: SUCCESSFUL
        optional: false

      - type: CommandStep
        name: test and build
        runInContainer: true
        image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
        interpreter:
          type: PosixInterpreter
          shell: sh
          commands: |
            set -eu
            go test ./...
            mkdir -p dist
            CGO_ENABLED=0 GOOS=linux go build \
              -trimpath -ldflags="-s -w" -o dist/hello .
        useTTY: false
        runAs: 0:0
        condition: SUCCESSFUL
        optional: false

      - type: PublishArtifactStep
        name: publish binary
        artifacts: dist/**
        condition: SUCCESSFUL
        optional: false

    triggers:
      - type: BranchUpdateTrigger
        branches: main
        userMatch: anyone
    retryCondition: never
    maxRetries: 0
    timeout: 600

Build Spec 的版本号属于 OneDev 配置格式,不是项目版本。编辑器保存时如果提示升级格式,以当前实例生成的结果为准;不要为了照抄示例把新实例导出的版本手工降回 52。使用远程 Agent 时,也要把 jobExecutor 改成对应的 Remote Docker Executor 名称。

提交文件后推送:

git add .onedev-buildspec.yml
git commit -m "Add OneDev CI"
git push

项目的 Builds 页面应出现 CI,依次通过 checkouttest and buildpublish binary。Artifact 中应能下载 dist/hello。任务一直停在 waiting 时,先检查是否有在线 Executor,以及 Job 申请的资源是否超过节点上限;checkout 成功而测试失败时,直接打开失败 Step 的日志,不要先重启 OneDev。

OneDev 的可视化编辑器可以生成 Build Spec,保存后的 .onedev-buildspec.yml 仍要随代码评审。构建脚本可以读取 Secret 和触发部署,因此只有指定维护者批准后,相关改动才能合并到 main

从构建镜像到自动部署生产服务器

二进制 Artifact 只能确认 CI 已经执行。要查清生产环境运行的是哪个提交,还要把 commit、镜像 tag、镜像 digest 和部署记录对应起来。

生产服务器先创建 root 管理的发布目录,再写入 /opt/hello/compose.yaml

sudo install -d -m 750 -o root -g root /opt/hello
name: hello

services:
  app:
    image: ${APP_IMAGE:?APP_IMAGE is required}
    container_name: hello
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    logging:
      driver: local
      options:
        max-size: 20m
        max-file: "5"

镜像发布到 OneDev 自带的 Container Registry。OneDev 容器镜像文档(在新标签页打开)使用 <OneDev 域名>/<项目路径>/<镜像仓库> 命名,所以 demo/hello 项目中的 app 镜像地址是 code.example.com/demo/hello/app。使用 ACR、Harbor 或其他 Registry 时,仍按 digest 部署。

/opt/hello/release.env 只保存当前镜像引用。首次发布前保留空值,发布成功后由部署脚本写入准确 digest:

APP_IMAGE=

先在 demo/hello 的 General Setting 中启用 Package Management。然后创建两个受限身份:ci-publisher 只对该项目拥有包写入权限,deploy-reader 只有包读取权限。部署机以 root 保存读取凭据,Registry 写入凭据只留在 CI:

printf '%s' '<PACKAGE_READ_TOKEN>' | \
  sudo docker login code.example.com \
    --username deploy-reader \
    --password-stdin

再为流水线生成一把只用于 hello 发布的 SSH Key。先在管理员电脑执行:

ssh-keygen -t ed25519 -f ./hello-deploy-key -C 'onedev hello deploy'

然后在生产机创建受限账号。把 hello-deploy-key.pub 的完整一行放在 restrict 后面写入 authorized_keys,例如 restrict ssh-ed25519 AAAA... onedev hello deployrestrict 会关闭端口转发、agent forwarding、X11 forwarding 和 PTY:

id -u hello-deploy >/dev/null 2>&1 || \
  sudo useradd --create-home --user-group --shell /bin/bash hello-deploy
sudo passwd -l hello-deploy
sudo install -d -m 700 -o hello-deploy -g hello-deploy /home/hello-deploy/.ssh
sudoedit /home/hello-deploy/.ssh/authorized_keys
sudo chown hello-deploy:hello-deploy /home/hello-deploy/.ssh/authorized_keys
sudo chmod 600 /home/hello-deploy/.ssh/authorized_keys

私钥保存为 OneDev Job Secret deploy_ssh_key,不上传到服务器,也不提交进仓库。

安装 /usr/local/sbin/deploy-hello

#!/usr/bin/env bash
set -Eeuo pipefail

if [ "$#" -ne 1 ]; then
  echo "usage: deploy-hello <image@sha256:digest>" >&2
  exit 2
fi

release_ref="$1"
allowed_repo=code.example.com/demo/hello/app
install_root=/opt/hello
env_file="${install_root}/release.env"
lock_file=/run/lock/hello-deploy.lock

digest="${release_ref#"${allowed_repo}@sha256:"}"
if [ "${release_ref}" != "${allowed_repo}@sha256:${digest}" ] \
  || ! [[ "${digest}" =~ ^[a-f0-9]{64}$ ]]; then
  echo "release image must use an exact sha256 digest" >&2
  exit 3
fi

exec 9>"${lock_file}"
flock -n 9 || {
  echo "another deployment is running" >&2
  exit 4
}

write_release() {
  local ref="$1" next
  next="$(mktemp "${install_root}/.release.env.XXXXXX")"
  printf 'APP_IMAGE=%s\n' "${ref}" >"${next}"
  chmod 600 "${next}"
  mv "${next}" "${env_file}"
}

wait_for_health() {
  local attempt
  for attempt in $(seq 1 30); do
    if curl -fsS http://127.0.0.1:8080/healthz >/dev/null; then
      return 0
    fi
    sleep 2
  done
  return 1
}

cd "${install_root}"
if ! current_ref="$(awk -F= '
  $1 == "APP_IMAGE" {
    count++
    value = substr($0, index($0, "=") + 1)
  }
  END {
    if (count != 1) exit 1
    print value
  }
' "${env_file}")"; then
  echo "release.env must contain exactly one APP_IMAGE entry" >&2
  exit 5
fi

previous_available=0
if docker inspect hello >/dev/null 2>&1; then
  current_digest="${current_ref#"${allowed_repo}@sha256:"}"
  if [ "${current_ref}" != "${allowed_repo}@sha256:${current_digest}" ] \
    || ! [[ "${current_digest}" =~ ^[a-f0-9]{64}$ ]]; then
    echo "current release is not recoverable" >&2
    exit 6
  fi
  if ! wait_for_health; then
    echo "current release is not healthy; deployment refused" >&2
    exit 7
  fi
  previous_available=1
elif [ -n "${current_ref}" ]; then
  echo "release.env declares an image but the application container is absent" >&2
  exit 8
fi

rollback_required=0
rollback() {
  local exit_code=$?
  trap - EXIT
  if [ "${rollback_required}" -eq 1 ]; then
    if [ "${previous_available}" -eq 1 ]; then
      write_release "${current_ref}"
      docker compose --env-file "${env_file}" up -d --no-deps app \
        || exit_code=1
      wait_for_health || exit_code=1
    else
      docker compose --env-file "${env_file}" down --remove-orphans \
        || exit_code=1
      write_release ""
    fi
  fi
  exit "${exit_code}"
}
trap rollback EXIT

write_release "${release_ref}"
rollback_required=1
docker compose --env-file "${env_file}" pull app
docker compose --env-file "${env_file}" up -d --no-deps app
wait_for_health

running_ref="$(docker inspect hello --format '{{.Config.Image}}')"
test "${running_ref}" = "${release_ref}"
rollback_required=0
trap - EXIT
echo "deployed ${release_ref}"
sudo chown root:root /usr/local/sbin/deploy-hello
sudo chmod 750 /usr/local/sbin/deploy-hello
sudo chown root:root /opt/hello/compose.yaml /opt/hello/release.env
sudo chmod 640 /opt/hello/compose.yaml
sudo chmod 600 /opt/hello/release.env
printf '%s\n' \
  'hello-deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-hello *' \
  | sudo tee /etc/sudoers.d/hello-deploy >/dev/null
sudo chmod 440 /etc/sudoers.d/hello-deploy
sudo visudo -cf /etc/sudoers.d/hello-deploy
sudo /usr/local/sbin/deploy-hello \
  'code.example.com/demo/hello/app@sha256:<64位摘要>'

hello-deploy 只能以 root 运行这个固定脚本,脚本也只接受 code.example.com/demo/hello/app@sha256:...,因此不能借部署入口启动其他镜像。第一次部署失败时,脚本会删除未完成的容器并把 release.env 恢复为空;成功运行过一个健康版本后,新的部署如果拉取失败或健康检查不通过,脚本会重新启动上一版本。

生产域名指向这台服务器后,把应用站点保存为 /etc/caddy/sites/hello.caddy

app.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:8080
}
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
curl -fsS https://app.example.com/ >/dev/null
curl -fsS https://app.example.com/healthz

这套脚本只适用于没有数据库迁移的单服务。发布包含表结构变更时,按数据库迁移与应用发布顺序处理;旧程序未必能读取新结构,不能只回滚镜像。

OneDev 项目 Build Setting 中添加这些 Property:

Property示例作用
registry_hostcode.example.comOneDev 的 HTTPS 域名
registry_repositorydemo/hello/app项目路径加镜像仓库名
registry_usernameci-publisherCI 写入账号
deploy_hostapp.example.com生产服务器地址
deploy_userhello-deploy受限部署账号
deploy_port22部署 SSH 端口

按照 OneDev Access Token 文档(在新标签页打开)ci-publisher 创建只允许写入目标项目包的令牌,并保存为 Job Secret registry_access_token。另外添加 deploy_ssh_keydeploy_known_hostsdeploy_known_hosts 必须来自管理员核对过的主机公钥,不要使用 StrictHostKeyChecking=no

先从生产机的可信控制台读取主机密钥指纹,再在管理员电脑采集公钥并比较指纹;一致后才把 hello-known-hosts 的内容保存为 deploy_known_hosts

# 生产机可信控制台
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

# 管理员电脑
ssh-keyscan -H -p 22 app.example.com >hello-known-hosts
ssh-keygen -lf hello-known-hosts

发布 Job 会在执行器环境中调用 bash、Docker Buildx、jq 和 OpenSSH。先在实际承载 Job 的 Server 或 Agent 上检查这些命令;缺少任意一项时先安装依赖,不要关闭 digest 或主机密钥校验:

command -v bash docker jq ssh
docker buildx version

OneDev 也提供 Build/Publish Docker Image Step。只需构建并推送镜像时,可以在可视化编辑器中选择该 Step,再保存实例生成的 Build Spec。部署脚本还需要接收 digest,因此流水线直接从 Buildx 元数据读取 containerimage.digest,并将它作为部署参数。

在 Build Spec 中增加生产 Job。它等待 CI 成功,构建并推送 commit tag,从 Buildx 元数据读取 digest,再把精确镜像引用交给部署机:

  - name: Deploy Production
    jobExecutor: trusted-local-docker
    steps:
      - type: CheckoutStep
        name: checkout
        cloneCredential:
          type: DefaultCredential
        withLfs: false
        withSubmodules: false
        condition: SUCCESSFUL
        optional: false

      - type: CommandStep
        name: build image and deploy digest
        runInContainer: false
        interpreter:
          type: DefaultInterpreter
          commands: |
            bash <<'BASH'
            set -Eeuo pipefail

            REGISTRY='@property:registry_host@'
            REPOSITORY='@property:registry_repository@'
            USERNAME='@property:registry_username@'
            ACCESS_TOKEN="$(cat <<'ONEDEV_SECRET'
            @secret:registry_access_token@
            ONEDEV_SECRET
            )"
            DEPLOY_HOST='@property:deploy_host@'
            DEPLOY_USER='@property:deploy_user@'
            DEPLOY_PORT='@property:deploy_port@'
            COMMIT='@commit_hash@'
            IMAGE="${REGISTRY}/${REPOSITORY}"

            docker_config="$(mktemp -d)"
            builder_name="onedev-${COMMIT:0:12}-$$"
            key_file="$(mktemp)"
            known_hosts_file="$(mktemp)"
            metadata_file="$(mktemp)"
            cleanup() {
              docker buildx rm "${builder_name}" >/dev/null 2>&1 || true
              rm -rf -- "${docker_config}"
              rm -f "${key_file}" "${known_hosts_file}" "${metadata_file}"
            }
            trap cleanup EXIT
            trap 'exit 129' HUP
            trap 'exit 130' INT
            trap 'exit 143' TERM
            export DOCKER_CONFIG="${docker_config}"

            cat >"${key_file}" <<'ONEDEV_SSH_KEY'
            @secret:deploy_ssh_key@
            ONEDEV_SSH_KEY
            cat >"${known_hosts_file}" <<'ONEDEV_KNOWN_HOSTS'
            @secret:deploy_known_hosts@
            ONEDEV_KNOWN_HOSTS
            chmod 600 "${key_file}" "${known_hosts_file}"

            printf '%s' "${ACCESS_TOKEN}" | docker login "${REGISTRY}" \
              -u "${USERNAME}" --password-stdin
            unset ACCESS_TOKEN
            docker buildx create \
              --name "${builder_name}" \
              --driver docker-container \
              --use >/dev/null
            docker buildx inspect --bootstrap >/dev/null
            docker buildx build \
              --platform linux/amd64 \
              --push \
              --tag "${IMAGE}:${COMMIT}" \
              --metadata-file "${metadata_file}" \
              .

            DIGEST="$(jq -er '."containerimage.digest"' "${metadata_file}")"
            RELEASE_REF="${IMAGE}@${DIGEST}"
            printf '%s\n' "${RELEASE_REF}" >release-ref.txt
            printf 'deploying %s\n' "${RELEASE_REF}"
            REMOTE="${DEPLOY_USER}@@${DEPLOY_HOST}"
            release_ref_q="$(printf '%q' "${RELEASE_REF}")"

            ssh -i "${key_file}" \
              -o "UserKnownHostsFile=${known_hosts_file}" \
              -o StrictHostKeyChecking=yes \
              -p "${DEPLOY_PORT}" \
              "${REMOTE}" \
              "sudo /usr/local/sbin/deploy-hello ${release_ref_q}"
            BASH
        useTTY: false
        condition: SUCCESSFUL
        optional: false

      - type: PublishArtifactStep
        name: publish release identity
        artifacts: release-ref.txt
        condition: SUCCESSFUL
        optional: false

    jobDependencies:
      - jobName: CI
        requireSuccessful: true
        artifacts: "**"
    triggers:
      - type: DependencyFinishedTrigger
    retryCondition: never
    maxRetries: 0
    timeout: 1800

OneDev 在 Job 命令中使用 @...@ 插入变量,因此 DEPLOY_USER@@DEPLOY_HOST 中的双 @@ 用来产生 SSH 地址需要的单个 @。这不是 Bash 语法。

Docker Buildx 文档(在新标签页打开)定义了 --metadata-filecontainerimage.digest。部署记录使用 digest。commit tag 只方便查找;如果 Registry 允许覆盖同名 tag,它就不能标识固定版本。

流水线完成后检查三处:OneDev Build 指向本次 commit,Registry 中存在本次 digest,生产容器的 .Config.Image 等于同一个 repository@sha256:...。三者一致后,才能确定构建记录、镜像和运行版本属于同一次发布。

管理权限、密钥和 Docker Executor

Registry Token、SSH 私钥等可以读写资源或登录服务器,必须保存为 Secret。域名、仓库路径和端口本身不授予访问权限,可以保存为 Property。

配置类型原因
Registry 域名、仓库路径Property公开后不授予读写权限
部署主机、端口、目录Property这些值本身不授予访问权限;真实生产值仍不应写进公开仓库示例
Registry 密码或 TokenSecret可推送、覆盖或读取制品
SSH 私钥Secret可登录部署目标
known_hostsSecret 或受保护配置防止部署连接被替换;修改权应受控
应用数据库密码、第三方 API KeySecret属于应用运行权限,不应进入镜像或构建日志

能够修改发布 Job 的人,也能改变读取 Secret 和连接生产服务器的命令。为 main 配置分支保护,要求 Pull Request、CI 通过和指定维护者批准;Registry 写入凭据与生产 SSH Key 只提供给允许发布的 Job。外部 Pull Request 不应自动进入能读取生产 Secret 的流程。

compose.executor.yaml 把 Docker socket 挂载到 OneDev Server 容器。普通 Job 使用 runInContainer: true,并保持 Mount Docker Sock: 关闭;只有受保护发布 Job 中的 runInContainer: false 步骤可以调用执行器主机上的 Docker。

能调用 Docker daemon 的任务可以创建高权限容器、挂载宿主目录并访问其他容器。Docker Engine 安全文档(在新标签页打开)要求只允许受信任用户控制 daemon。本机 Server Docker Executor 只用于私有、受信任仓库,并受以下限制:

  • Server Docker Executor 只保留一个实例;
  • 小型共享主机并发设为 1,并限制总 CPU 与内存;
  • 未知用户不能自助注册并提交构建;
  • 构建容器本身不再额外挂载 Docker socket;
  • 不可信分支、公开 fork 和外部贡献者代码不进入本机发布 Job。

4 核 8 GB 主机可把本机执行器限制为单并发、约 1.5 CPU 和 1536 MiB 内存,只运行文档或小型服务构建。大型镜像、前端全量编译和并行测试使用远程 Agent。OneDev Agent Farm(在新标签页打开)会按 Agent 的资源能力分配 Job;没有节点满足要求时,构建会保持等待状态。

如果 OneDev 只负责仓库和流水线调度,在 Job Executors 中删除 Server Docker Executor,把 compose.executor.yaml 改为 services: { onedev: {} },再重建 OneDev。远程 Agent 使用独立主机、独立 Docker daemon、环境专用凭据,并只开放构建所需网络;生产发布 Agent 不运行公共 Pull Request。

备份、恢复和升级 OneDev

OneDev 的关系数据在数据库中,Git 仓库、附件和其他项目文件位于 data 下的 site 目录。官方备份恢复文档(在新标签页打开)分别说明了数据库脚本与 site 数据;两部分必须来自同一个停写窗口。

先创建 /opt/onedev/compose.quiesced.yaml。恢复和升级时使用这个文件,把 OneDev 留在隔离网络中;运行时不叠加 compose.executor.yaml,Docker socket 不会进入容器。Git SSH 通过 ONEDEV_SSH_BIND_HOST=127.0.0.1 改绑回环地址。

networks:
  onedev:
    internal: true
sudo chown root:root /opt/onedev/compose.quiesced.yaml
sudo chmod 640 /opt/onedev/compose.quiesced.yaml

cd /opt/onedev
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    config --quiet

生成同一时点的完整恢复点

把下面的脚本保存为 /usr/local/sbin/onedev-backup。运行时,flock 会阻止备份与升级同时执行;OneDev 停止写入后,PostgreSQL 继续运行,数据库由当前 OneDev 镜像中的官方脚本导出:

#!/usr/bin/env bash
set -Eeuo pipefail

install_root=/opt/onedev
backup_root=/var/backups/onedev
env_file="${install_root}/.env"
compose_file="${install_root}/compose.yaml"
executor_file="${install_root}/compose.executor.yaml"
upgrade_journal=/var/lib/onedev-upgrade/active.env
admin_password_file=/etc/onedev/secrets/admin_password

for command in awk base64 curl docker find flock install mv seq sha256sum stat sync tar tr; do
  command -v "${command}" >/dev/null 2>&1 || {
    echo "missing command: ${command}" >&2
    exit 2
  }
done
docker compose version >/dev/null
for file in \
  "${env_file}" \
  "${compose_file}" \
  "${executor_file}" \
  /opt/onedev/compose.bootstrap.yaml \
  /opt/onedev/compose.quiesced.yaml; do
  test -f "${file}"
  test ! -L "${file}"
done

compose() {
  docker compose --env-file "${env_file}" \
    -f "${compose_file}" \
    -f "${executor_file}" "$@"
}

wait_for_onedev() {
  local attempt
  for attempt in $(seq 1 60); do
    if curl -fsS http://127.0.0.1:6610/ >/dev/null; then
      return 0
    fi
    sleep 3
  done
  return 1
}

exec 9>/run/lock/onedev-lifecycle.lock
flock -n 9 || {
  echo "another OneDev lifecycle operation is running" >&2
  exit 3
}

if [ -e "${upgrade_journal}" ] || [ -L "${upgrade_journal}" ]; then
  echo "an upgrade transaction is active" >&2
  exit 4
fi

retention_days="$(awk -F= '
  $1 == "BACKUP_RETENTION_DAYS" {
    count++
    value = substr($0, index($0, "=") + 1)
  }
  END {
    if (count != 1) exit 1
    print value
  }
' "${env_file}")"
if ! [[ "${retention_days}" =~ ^[0-9]+$ ]] \
  || [ "${retention_days}" -lt 1 ] \
  || [ "${retention_days}" -gt 365 ]; then
  echo "BACKUP_RETENTION_DAYS must be between 1 and 365" >&2
  exit 5
fi

admin_user="$(awk -F= '
  $1 == "ONEDEV_ADMIN_USER" {
    count++
    value = substr($0, index($0, "=") + 1)
  }
  END {
    if (count != 1) exit 1
    print value
  }
' "${env_file}")"
if [ -z "${admin_user}" ] \
  || [ ! -f "${admin_password_file}" ] \
  || [ -L "${admin_password_file}" ] \
  || [ ! -s "${admin_password_file}" ] \
  || [ "$(stat -c '%u:%a' "${admin_password_file}")" != "0:600" ]; then
  echo "managed OneDev administrator credential is missing" >&2
  exit 6
fi
basic_token="$(
  printf '%s:%s' "${admin_user}" "$(<"${admin_password_file}")" \
    | base64 | tr -d '\n'
)"
if ! printf '%s\n' \
  'silent' \
  'show-error' \
  'fail-with-body' \
  'connect-timeout = 5' \
  'max-time = 30' \
  "header = \"Authorization: Basic ${basic_token}\"" \
  'header = "Accept: application/json"' \
  | curl --config - --output /dev/null \
      http://127.0.0.1:6610/~api/settings/system; then
  unset basic_token
  echo "managed OneDev administrator credential is invalid" >&2
  exit 7
fi
unset basic_token

timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
incomplete="${backup_root}/.${timestamp}.incomplete"
final="${backup_root}/${timestamp}"
[[ "${timestamp}" =~ ^20[0-9]{6}T[0-9]{6}Z$ ]]
if [ -e "${incomplete}" ] || [ -L "${incomplete}" ] \
  || [ -e "${final}" ] || [ -L "${final}" ]; then
  echo "backup target already exists: ${timestamp}" >&2
  exit 8
fi

restart_required=0
cleanup_backup() {
  local exit_code=$?
  trap - EXIT
  if [ "${restart_required}" -eq 1 ]; then
    compose up -d onedev || exit_code=1
    wait_for_onedev || exit_code=1
  fi
  if [[ "${incomplete}" =~ ^/var/backups/onedev/\.20[0-9]{6}T[0-9]{6}Z\.incomplete$ ]] \
    && [ -d "${incomplete}" ]; then
    rm -rf -- "${incomplete}"
  fi
  exit "${exit_code}"
}
trap cleanup_backup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

install -d -m 700 "${incomplete}"
cd "${install_root}"

restart_required=1
compose stop --timeout 120 onedev
compose run --rm --no-deps \
  --entrypoint /opt/onedev/bin/backup-db.sh \
  onedev "/var/backups/onedev/$(basename "${incomplete}")/database.zip"

tar --numeric-owner -C "${install_root}/data" \
  -czf "${incomplete}/site.tar.gz" site
tar --numeric-owner -C / -czf "${incomplete}/configuration.tar.gz" \
  etc/onedev \
  opt/onedev/compose.yaml \
  opt/onedev/compose.bootstrap.yaml \
  opt/onedev/compose.executor.yaml \
  opt/onedev/compose.quiesced.yaml \
  opt/onedev/.env

(
  cd "${incomplete}"
  sha256sum database.zip site.tar.gz configuration.tar.gz >SHA256SUMS
  sha256sum -c SHA256SUMS
)
sync -f "${incomplete}"

compose up -d onedev
wait_for_onedev
restart_required=0

mv "${incomplete}" "${final}"
sync -f "${final}"
sync -f "${backup_root}"
trap - EXIT
printf 'backup complete: %s\n' "${final}"

while IFS= read -r expired; do
  name="$(basename "${expired}")"
  if [[ "${name}" =~ ^20[0-9]{6}T[0-9]{6}Z$ ]]; then
    rm -rf -- "${expired}"
  fi
done < <(
  find "${backup_root}" \
    -mindepth 1 \
    -maxdepth 1 \
    -type d \
    -name '20??????T??????Z' \
    -mtime "+${retention_days}" \
    -print
)

安装脚本后,先手动运行一次;备份成功且 OneDev 恢复健康,再交给 systemd 定时执行:

sudo chown root:root /usr/local/sbin/onedev-backup
sudo chmod 750 /usr/local/sbin/onedev-backup
sudo /usr/local/sbin/onedev-backup

/etc/systemd/system/onedev-backup.service

[Unit]
Description=OneDev database and site backup
After=docker.service
Requires=docker.service
ConditionPathExists=!/var/lib/onedev-upgrade/active.env

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/onedev-backup
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

/etc/systemd/system/onedev-backup.timer

[Unit]
Description=Daily OneDev backup

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=10m
Unit=onedev-backup.service

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now onedev-backup.timer
sudo systemctl start onedev-backup.service
sudo systemctl status onedev-backup.service --no-pager
sudo systemctl list-timers onedev-backup.timer --no-pager

脚本失败或收到中断信号时会重新启动 OneDev,并删除未完成的备份目录。只有三个归档通过摘要校验、写入磁盘,而且 OneDev 已恢复健康,隐藏目录才会改成正式的时间戳目录。目录中包含:

database.zip
site.tar.gz
configuration.tar.gz
SHA256SUMS

本机目录用于快速恢复。随后把整个时间戳目录加密复制到另一台主机或对象存储,并在目标端再次运行 sha256sum -c SHA256SUMS。不要只上传其中一个压缩包,否则数据库、site 数据和配置无法按同一时点恢复。

在空目标验证恢复

恢复不要覆盖仍在运行的实例。准备一台隔离主机或完全空的 OneDev 目标,确认没有容器、数据目录、定时任务或旧符号链接,再执行:

  1. 校验四个文件和 SHA-256 摘要;对两个 tar 归档额外检查路径与条目类型,拒绝绝对路径、越界路径和符号链接;
  2. 从备份配置读取固定 OneDev/PostgreSQL 镜像,不使用 latest
  3. 恢复 site、仅 root 可读的配置和 Compose 文件;备份脚本与 systemd 单元从部署仓库的固定版本重新安装,不执行备份目录中的脚本;
  4. 只启动 PostgreSQL,使用同版本 OneDev 镜像的 restore-db.sh 恢复 database.zip
  5. OneDev 先只在回环和隔离网络启动,核对项目、用户、Issue、附件、构建记录与 Git 仓库;
  6. Git 读写、公开 HTTPS、Settings、Executor 策略和备份 timer 全部通过后,再恢复入口。

数据库恢复继续使用隔离用的 Compose 覆盖文件。数据验收前不要挂回 Docker socket,也不要开放 Git SSH。先在空目标校验并展开恢复点:

sudo bash <<'BASH'
set -Eeuo pipefail

backup_id=20260903T033000Z # 替换为准备恢复的 UTC 时间戳
backup_root=/var/backups/onedev
backup_dir="${backup_root}/${backup_id}"

if ! [[ "${backup_id}" =~ ^20[0-9]{6}T[0-9]{6}Z$ ]] \
  || [ ! -d "${backup_dir}" ] \
  || [ -L "${backup_dir}" ]; then
  echo "invalid backup directory" >&2
  exit 2
fi

for file in database.zip site.tar.gz configuration.tar.gz SHA256SUMS; do
  test -f "${backup_dir}/${file}"
  test ! -L "${backup_dir}/${file}"
done

awk '
  NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-f]+$/ { exit 1 }
  $2 != "database.zip" && $2 != "site.tar.gz" && $2 != "configuration.tar.gz" { exit 1 }
  seen[$2]++ { exit 1 }
  END {
    if (NR != 3 || !seen["database.zip"] || !seen["site.tar.gz"] || !seen["configuration.tar.gz"])
      exit 1
  }
' "${backup_dir}/SHA256SUMS"

(
  cd "${backup_dir}"
  sha256sum -c SHA256SUMS
)

validate_archive() {
  local archive="$1" allowed="$2"
  tar -tvzf "${archive}" | awk '
    substr($1, 1, 1) != "d" && substr($1, 1, 1) != "-" { exit 1 }
  '
  while IFS= read -r entry; do
    if [[ "${entry}" = /* ]] || [[ "/${entry}/" = *"/../"* ]] \
      || ! [[ "${entry}" =~ ${allowed} ]]; then
      echo "archive contains an unexpected path: ${entry}" >&2
      return 1
    fi
  done < <(tar -tzf "${archive}")
}

validate_archive \
  "${backup_dir}/site.tar.gz" \
  '^site(/.*)?$'
validate_archive \
  "${backup_dir}/configuration.tar.gz" \
  '^etc/onedev(/.*)?$|^opt/onedev/(compose(\.(bootstrap|executor|quiesced))?\.yaml|\.env)$'

for target in \
  /etc/onedev \
  /opt/onedev \
  /var/lib/onedev-upgrade \
  /usr/local/sbin/onedev-backup \
  /etc/systemd/system/onedev-backup.service \
  /etc/systemd/system/onedev-backup.timer \
  /etc/caddy/sites/onedev.caddy; do
  if [ -e "${target}" ] || [ -L "${target}" ]; then
    echo "restore target is not empty: ${target}" >&2
    exit 3
  fi
done
if docker inspect onedev >/dev/null 2>&1 \
  || docker inspect onedev-postgres >/dev/null 2>&1; then
  echo "OneDev containers already exist" >&2
  exit 4
fi

install -d -m 750 /opt/onedev/data /opt/onedev/postgres_data
tar --numeric-owner -xzf "${backup_dir}/configuration.tar.gz" -C /
tar --numeric-owner -xzf "${backup_dir}/site.tar.gz" -C /opt/onedev/data
chmod 600 /opt/onedev/.env /etc/onedev/secrets/*
chmod 640 \
  /opt/onedev/compose.yaml \
  /opt/onedev/compose.bootstrap.yaml \
  /opt/onedev/compose.executor.yaml \
  /opt/onedev/compose.quiesced.yaml
BASH

确认固定镜像已经拉取或从可信离线副本载入,再恢复数据库并启动隔离实例:

cd /opt/onedev
backup_id=20260903T033000Z # 替换为已经校验的恢复点
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    up -d postgres
for attempt in $(seq 1 30); do
  if sudo docker exec onedev-postgres \
    pg_isready -U onedev -d onedev >/dev/null 2>&1; then
    break
  fi
  if [ "${attempt}" -eq 30 ]; then
    echo "PostgreSQL did not become ready" >&2
    exit 1
  fi
  sleep 2
done
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    run --rm --no-deps \
      --entrypoint /bin/bash \
      onedev -ceu '
        case "$(uname -m)" in
          aarch64|arm64) cpu_arch=arm-64 ;;
          x86_64|amd64) cpu_arch=x86-64 ;;
          *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
        esac
        "/app/boot/wrapper-linux-${cpu_arch}" \
          /app/conf/wrapper.conf \
          -- upgrade /opt/onedev
        touch /opt/onedev/IN_DOCKER
        test -x /opt/onedev/bin/restore-db.sh
      '
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    run --rm --no-deps \
      --entrypoint /opt/onedev/bin/restore-db.sh \
      onedev "/var/backups/onedev/${backup_id}/database.zip"
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    up -d onedev
curl --fail --retry 60 --retry-delay 3 --retry-all-errors \
  http://127.0.0.1:6610/ >/dev/null

恢复完成后,空目标中应出现原来的仓库、commit、Issue 和附件;Git clone 能读到预期 commit,构建记录可以打开,公开入口与 System URL 一致。压缩包能够解开,只能说明归档格式可读,不能证明 OneDev 已经恢复。

仓库、Issue、附件和构建记录检查通过后,从部署仓库的固定 commit 重新安装备份脚本与 systemd 单元,再恢复 Caddy、云安全组和 UFW 配置。然后停止隔离容器,使用正常的执行器配置启动。执行 down 时不要加 -v;以下命令不会删除 bind mount 中的数据:

cd /opt/onedev
sudo env ONEDEV_SSH_BIND_HOST=127.0.0.1 \
  docker compose --env-file .env \
    -f compose.yaml \
    -f compose.quiesced.yaml \
    down --remove-orphans
sudo docker compose --env-file .env \
  -f compose.yaml \
  -f compose.executor.yaml \
  up -d
sudo systemctl enable --now onedev-backup.timer
sudo systemctl reload caddy
curl -fsS https://code.example.com/ >/dev/null
ssh -T -p 6611 git@code.example.com

可恢复地升级 OneDev

OneDev 官方 Docker 升级页(在新标签页打开)要求停止旧容器、拉取新镜像,再使用原数据目录启动。生产环境还要先停止新写入,并处理数据库迁移、入口流量、SSH 中断和主机重启。

按下面的顺序升级:

核对当前实例与目标 tag/digest
→ 确认没有 waiting、pending、running 构建
→ 检查磁盘足够保存目标镜像和至少两份恢复点
→ HTTPS 返回维护响应,Git SSH 与本机构建停止接收新写入
→ 使用当前版本生成并校验完整恢复点
→ 在无公网端口、无 Docker socket、internal 网络中切换目标镜像
→ 从容器内部验证目标版本、数据库和数据目录
→ 生成升级后的完整恢复点
→ 持久提交新版本
→ 重建正常网络,恢复 Git SSH 与 HTTPS
→ 重新检查 HTTPS、Git SSH、构建、备份和运行镜像

把升级编号、旧/新镜像、恢复点和当前阶段写入仅 root 可读的状态文件,例如 /var/lib/onedev-upgrade/active.env。SSH 中断或主机重启后,systemd 恢复单元读取这个文件,并根据提交点继续执行:

日志阶段已完成重启后执行
fencedHTTPS 已返回 503,Git SSH 与构建入口已经关闭,升级前恢复点有效继续切换目标镜像;无法继续时恢复旧镜像、数据库和 site
target_verified新镜像已在隔离网络内通过应用、数据库、数据目录和执行器隔离检查生成升级后恢复点并提交新版本
committed新版本和升级后恢复点已持久提交只重建正常网络并恢复入口,不再把已迁移数据回滚到旧版本

提交点之前发生故障时,停止所有引用新数据目录的容器,再用已缓存的旧镜像和升级前恢复点离线恢复。提交点之后继续完成新版本的入口开放,不再回滚已迁移的数据。定时备份单元还应带有 ConditionPathExists=!/var/lib/onedev-upgrade/active.env,避免普通备份混入未完成的升级事务。

直接把 .env 改回旧镜像只适用于能够证明新版本没有迁移任何持久数据的情况。无法证明时,数据库、site 与镜像必须一起回到升级前恢复点。docker compose down -v 会删除命名卷,不应出现在升级或日常重启命令中。

按症状排查 OneDev 常见故障

先确认报错发生在 DNS、Caddy、Git SSH、数据库、Executor、镜像发布还是备份阶段,再检查对应服务。

症状首先检查常见原因与处理
HTTPS 打不开DNS、80/443、Caddy、127.0.0.1:6610解析未生效、证书申请失败、站点片段未加载或 OneDev 尚未 ready
页面能开,SSH clone 超时6611 映射、UFW、云安全组只开放了主机 SSH,或 OneDev SSH 端口没有从外部放行
SSH 出现系统登录提示连接端口和 SSH Root URLclone URL 连到了主机管理 SSH,而不是 OneDev Git SSH
OneDev 启动失败PostgreSQL health、容器日志、目录权限、镜像引用数据库未 ready、密码不一致、持久化目录不可写或镜像 tag/digest 不匹配
重建后项目为空/opt/onedev 挂载、PostgreSQL 数据与恢复点运行到了新空目录,或只恢复了 site/数据库其中一部分
构建一直 waitingExecutor 在线状态、资源需求、并发没有可用节点,或任务申请的 CPU/内存超过所有 Executor 能力
构建成功但生产没更新Build commit、Registry digest、生产 .Config.Image发布 Job 未运行、部署仍引用旧 tag,或入口缓存指向旧服务
部署健康检查失败新容器日志、回环健康地址、旧镜像恢复结果应用启动失败、配置缺失或迁移不兼容;先恢复旧制品再分析
定时备份失败lifecycle lock、管理员 API 凭据、磁盘空间、SHA256SUMS正在升级、凭据漂移、空间不足或恢复点没有完整落盘
升级后无法启动升级阶段日志、目标镜像 ID、数据库迁移、升级前恢复点不要反复重建同一数据目录;按提交点继续完成升级或恢复完整备份

依次查看数据库、应用、入口、SSH、镜像和备份状态:

cd /opt/onedev
sudo docker compose --env-file .env \
  -f compose.yaml -f compose.executor.yaml ps
sudo docker compose --env-file .env \
  -f compose.yaml -f compose.executor.yaml \
  logs --tail 200 postgres onedev
sudo docker inspect onedev --format 'ref={{.Config.Image}} id={{.Image}}'
sudo docker inspect onedev-postgres --format '{{json .State.Health}}'
sudo ss -lntp '( sport = :80 or sport = :443 or sport = :6610 or sport = :6611 )'
curl -fsS http://127.0.0.1:6610/ >/dev/null
curl -fsS https://code.example.com/ >/dev/null
ssh -T -p 6611 git@code.example.com
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl status caddy
sudo systemctl status onedev-backup.timer

OneDev 恢复正常后,再核对示例项目。CI 对应的 commit、镜像 digest、部署记录、生产容器引用和公开健康响应必须属于同一次发布。任意一项不一致时,停止新的发布,并查清错误的版本引用。

常见问题

OneDev 和 GitLab、Gitea、Forgejo 的主要区别是什么?

OneDev 把 Git 托管、代码评审、Issue、CI/CD、构建制品和包管理放在同一个项目模型里,官方最低配置低于完整 GitLab。Gitea 和 Forgejo 同样轻量,但 Actions 由独立 Runner 执行。现有工作流兼容性、构建模型和日常维护成本会直接影响迁移工作量。

OneDev 生产环境可以使用内置数据库吗?

临时体验可以使用默认配置。长期运行更适合独立 PostgreSQL,并分别持久化数据库目录和 OneDev site 数据,备份和恢复时再把两部分组成同一恢复点。

OneDev 的 6610 和 6611 端口都要开放到公网吗?

不需要。6610 是 Web 入口,通常只绑定 127.0.0.1 并由 Caddy 或 Nginx 提供 HTTPS;6611 用于 Git SSH,确实需要 SSH 克隆时才通过主机防火墙和云安全组开放。

OneDev 为什么需要 Docker socket?

Server Docker Executor 通过 Docker daemon 创建构建容器。能使用宿主 Docker socket 的任务接近拥有宿主机 root 权限,只应运行受信任仓库和受保护分支的构建;公开仓库或外部合并请求应使用隔离的远程 Agent。

备份 OneDev 只复制数据目录可以吗?

不够。完整恢复点至少包括官方数据库备份、site 目录、运行配置、密钥文件、固定镜像信息和校验和。数据库与 site 必须来自同一个停写窗口,并把备份复制到另一台主机或对象存储。

OneDev 升级失败后可以直接换回旧镜像吗?

不能把换回旧镜像当成可靠回滚。新版本可能已经迁移数据库和 site 数据。升级前先阻断写入并生成完整恢复点;失败时用旧镜像、数据库备份和 site 归档一起恢复。

生产应用包含流量切换或多服务依赖时,继续看单机 Docker Compose 如何做短停机发布;需要把失败检测和版本恢复放进同一发布脚本时,继续看自动部署失败后怎样恢复上一版本