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 配合独立 Runner | Actions 有自己的实现边界,迁移不能假定完全兼容 GitHub Actions |
| OneDev | 希望用较小单机统一仓库、评审、CI/CD、报告和包,并接受它自己的 Build Spec | Server 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 和构建记录。
| 阶段 | 输入 | 完成标志 |
|---|---|---|
| 托管代码 | 本地 Git 提交 | OneDev 项目页显示同一 commit,SSH/HTTPS 均可按权限读取 |
| 运行 CI | commit 与 .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/tcp | 仅 127.0.0.1 | Caddy 到 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_user、initial_password_file、initial_email、initial_server_url 或 initial_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 URL | https://code.example.com | 页面链接、Webhook 或回调生成错误地址 |
| SSH Root URL | ssh://code.example.com:6611 | 克隆地址缺端口或连到主机 SSH |
| 匿名访问 | 私有实例通常关闭 | 未登录用户可能看到不应公开的项目 |
| 自助注册 | 使用本机 Docker Executor 时关闭 | 未知用户可能获得提交或构建入口 |
| 邮件服务器 | 能向真实收件箱发送测试邮件 | 邀请、重置密码和通知无法送达 |
容器时区由 /opt/onedev/.env 中的 TZ 控制,不属于上表的管理界面设置。启动后分别检查 OneDev、PostgreSQL 和主机时间,三处都应使用同一时区约定;跨地域团队也可以统一使用 UTC。
进入 Site Administration → Job Executors,创建一个 Server Docker Executor,并核对这些值:
| 设置 | 建议值 | 作用 |
|---|---|---|
| Name | trusted-local-docker | Job 通过名称选择这个本机执行器 |
| Concurrency | 1 | 避免共享主机并发挤压 OneDev 与 PostgreSQL |
| Docker Builder | onedev | 固定 Buildx builder 名称 |
| Always Pull Image | 开启 | 运行前解析步骤镜像,不依赖陈旧本地 tag |
| Mount Docker Sock | 关闭 | 不把宿主 Docker socket 继续传进普通构建容器 |
| CPU Limit | 1.5 | 约束单个构建容器的 CPU |
| Memory Limit | 1536m | 给 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,依次通过 checkout、test and build 和 publish 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 deploy;restrict 会关闭端口转发、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_host | code.example.com | OneDev 的 HTTPS 域名 |
registry_repository | demo/hello/app | 项目路径加镜像仓库名 |
registry_username | ci-publisher | CI 写入账号 |
deploy_host | app.example.com | 生产服务器地址 |
deploy_user | hello-deploy | 受限部署账号 |
deploy_port | 22 | 部署 SSH 端口 |
按照 OneDev Access Token 文档(在新标签页打开) 给 ci-publisher 创建只允许写入目标项目包的令牌,并保存为 Job Secret registry_access_token。另外添加 deploy_ssh_key 和 deploy_known_hosts。deploy_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-file 和 containerimage.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 密码或 Token | Secret | 可推送、覆盖或读取制品 |
| SSH 私钥 | Secret | 可登录部署目标 |
known_hosts | Secret 或受保护配置 | 防止部署连接被替换;修改权应受控 |
| 应用数据库密码、第三方 API Key | Secret | 属于应用运行权限,不应进入镜像或构建日志 |
能够修改发布 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 目标,确认没有容器、数据目录、定时任务或旧符号链接,再执行:
- 校验四个文件和 SHA-256 摘要;对两个 tar 归档额外检查路径与条目类型,拒绝绝对路径、越界路径和符号链接;
- 从备份配置读取固定 OneDev/PostgreSQL 镜像,不使用
latest; - 恢复 site、仅 root 可读的配置和 Compose 文件;备份脚本与 systemd 单元从部署仓库的固定版本重新安装,不执行备份目录中的脚本;
- 只启动 PostgreSQL,使用同版本 OneDev 镜像的
restore-db.sh恢复database.zip; - OneDev 先只在回环和隔离网络启动,核对项目、用户、Issue、附件、构建记录与 Git 仓库;
- 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 恢复单元读取这个文件,并根据提交点继续执行:
| 日志阶段 | 已完成 | 重启后执行 |
|---|---|---|
fenced | HTTPS 已返回 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 URL | clone URL 连到了主机管理 SSH,而不是 OneDev Git SSH |
| OneDev 启动失败 | PostgreSQL health、容器日志、目录权限、镜像引用 | 数据库未 ready、密码不一致、持久化目录不可写或镜像 tag/digest 不匹配 |
| 重建后项目为空 | /opt/onedev 挂载、PostgreSQL 数据与恢复点 | 运行到了新空目录,或只恢复了 site/数据库其中一部分 |
| 构建一直 waiting | Executor 在线状态、资源需求、并发 | 没有可用节点,或任务申请的 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 如何做短停机发布;需要把失败检测和版本恢复放进同一发布脚本时,继续看自动部署失败后怎样恢复上一版本。