本文记录了家用OpenBao的部署过程。

包子可爱捏

C 缘起

老朋友们都知道,我有个自己部署的HashiCorp Vault,用来玩证书什么的,后来烂尾了。真不是我懒, 他家被IBM收购了我就不想用了——其实是人家不给用了。但是赛博过家家还是要继续的。

尤其最近一年,各种AI智能体勃勃生机万物竞发。相关的服务多了以后,.env 文件也跟着多起来。API Token、数据库密码、私钥,分散在不同机器和项目目录里。

比如 Hermes 它自己有 Vault,但是只限浏览器调用,自己写的脚本是不给调用的。安全凭据这个东西您是越晚整理越痛苦,等到要查谁在用、要轮换哪一个,就不那么顺手了。

我打算给家里的实验环境搭一个统一管理秘密的地方,后面还想拿它做 PKI (不忘初心了属于是)。我选的平台是自建OpenBao。

别看名字叫“开包”,人家是个正经的。同门还有个开豆腐(OpenTofu)我也在用。

OpenBao是一个开源的、社区驱动的密钥管理器,是 Vault 的一个分支,由 Linux 基金会的 OpenSSF 管理。

以上翻译自OpenBao官网。

这次先把 OpenBao 本身装好:用 Docker Compose 运行,数据放到宿主机,用老演员 Caddy 和 Cloudflare 提供 HTTPS 入口,再完成初始化与第一次解封。

本文记录的是我在 2026 年 10 月初的部署过程。环境里已经有 Caddy、Cloudflare 和供容器共用的 Docker 网络;如果您照着操作,目录、域名和现有 Caddy 配置都要换成自己的。

建议:

  • 自有域名
  • Cloudflare解析上面域名
  • VPS
  • 基本的Shell操作水平

若不具备前三项,可由家里的虚拟机代替。

继续阅读本文即意味着您理解并同意您的不恰当的操作可能会给您带来损失,您的损失——无论直接的还是间接的——由您自己负责,与我无瓜。若不同意请及时关闭本网页,勿再继续

Dm 俺寻思

既然是过家家,先不追求什么高可用性。我用的是单节点 OpenBao,数据存在 Integrated Storage(Raft)里,采用 Shamir 3 份密钥、2 份达到解封门槛。单节点只是先把服务用起来,谈不上节点故障容灾。

外部请求的路径如下:

---
config:
  theme: base
  themeVariables:
    background: "#faf4ed"
    primaryColor: "#fffaf3"
    primaryTextColor: "#464261"
    primaryBorderColor: "#286983"
    secondaryColor: "#f2e9e1"
    secondaryTextColor: "#464261"
    tertiaryColor: "#faf4ed"
    tertiaryTextColor: "#464261"
    lineColor: "#286983"
    textColor: "#464261"
    edgeLabelBackground: "#faf4ed"
    clusterBkg: "#f2e9e1"
    clusterBorder: "#907aa9"
---
flowchart LR
    A["浏览器 / bao CLI"] --> B["Cloudflare"]
    B --> C["Caddy"]
    C -- "bao-edge 网络 · HTTP :8200" --> D["OpenBao"]
    D --> E["Raft 数据<br/>/srv/openbao/data"]

    classDef edge fill:#f2e9e1,stroke:#907aa9,color:#464261;
    classDef service fill:#fffaf3,stroke:#286983,color:#464261;
    classDef data fill:#faf4ed,stroke:#56949f,color:#464261;
    class A,B,C edge;
    class D service;
    class E data;

OpenBao 不发布宿主机的 8200 端口。Caddy 和它同在已有的 bao-edge 网络,通过容器名 openbao 访问;TLS 在 Caddy 处终止,所以 OpenBao 的监听在这个 Docker 网络里使用 HTTP。这个做法依赖我对 Docker 主机和共享网络的信任:不相关的容器不能随便加入,Caddy 到 OpenBao 之间也没有再加一层传输加密。如果您的容器网络并不满足这个前提,就不能照搬 tls_disable = true。

另外,不发布 8200 只关上了 OpenBao 的一个入口。Cloudflare 到 Caddy 的源站连接如何验证、别人能不能绕过 Cloudflare 直接访问 Caddy,以后再说。

文件夹结构

Compose 和配置文件仍放在我平时存 Docker 项目的位置,运行数据则单独放到 /srv:

~/docker/openbao/
            ├── compose.yaml
            └── config/
                  └── bao.hcl

/srv/openbao/
        └── data/

先建文件夹:

mkdir -p ~/docker/openbao/config
sudo mkdir -p /srv/openbao/data
cd ~/docker/openbao

拉个 latest 看版本:

docker pull openbao/openbao:latest
docker run --rm openbao/openbao:latest version

当时的输出是 OpenBao v2.7.1。于是 Compose 里固定用 openbao/openbao:2.7.1,免得哪天重新拉镜像,服务悄悄换了一个版本。这里的版本只是当时部署时的记录,不代表您读到文章时的最新版——我的主意是:要是没安全更新什么的就一直用固定版本。

Em Cloudflare

为了保护我们的服务,有请赛博菩萨——Cloudflare。

此处需要先将您的域名解析(NS)委托给Cloudflare,请自行完成。

先去Cloudflare的Web控制台给您的OpenBao网站建好CNAME记录,并开启代理(打开橙色的云)。

然后去创建一个配置规则(Configuration Rule)。该规则的核心目的是针对 ACME HTTP-01 挑战(自动申请您的SSL证书)禁用“自动HTTPS重写”,以确保验证过程不被强制跳转干扰。

  1. 基础信息设置
  • 规则名称: 输入一个易于识别的名称,例如 allow acme http-01。
  1. 匹配条件设置 (If incoming requests match…)

我们需要筛选出所有指向证书验证路径的请求:

  • 选择模式: 选中 Custom filter expression。
  • 字段 (Field): 下拉选择 URI Path。
  • 运算符 (Operator): 选择 contains(包含)。
  • 值 (Value): 输入 /.well-known/acme-challenge/*。

注意: 预览框中显示的表达式应为:(http.request.uri.path contains "/.well-known/acme-challenge/*")。

  1. 操作设置 (Then the settings are…)

在下方找到具体的设置选项,调整 Cloudflare 对匹配请求的处理方式:

  • Automatic HTTPS Rewrites (自动 HTTPS 重写): 将开关拨至 关闭 (Off) 状态。

如图:

这样就可以避免基于HTTP 80端口的挑战流量被Cloudflare重定向到HTTPS 443端口。

F Caddy

文件夹

由于上面决定了 Caddy 也使用 Docker 运行,所以还得给 Caddy 也准备一套文件夹。

~/docker/caddy/
            ├── compose.yaml
            └── conf/
                └── Caddyfile

/srv/caddy/
        ├── data/
        └── config/

建立它们

cd ~/docker/caddy

mkdir -p conf
sudo mkdir -p /srv/caddy/{data,config,log}

这两个 config :

  • ~/docker/caddy/config/Caddyfile:手工(或用什么自动化方法)维护的配置文件
  • /srv/caddy/config/:Caddy 容器的运行时状态文件夹,对应官方镜像里的 /config

网络

我的想法是不要编织一个巨大的反向代理网络,而是每个APP有自己的网络。

docker network create bao-edge

这样 Caddy 是唯一跨多个网络的入口,各应用彼此看不见。假如以后还有 app1 app2,那么它们会有 app1-edge app2-edge。

编写容器文件

文件是~/docker/caddy/compose.yaml,内容为:

services:
  caddy:
    image: caddy:2.11.6-alpine
    container_name: caddy
    restart: unless-stopped

    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"

    volumes:
      - ./conf:/etc/caddy:ro
      - /srv/caddy/data:/data
      - /srv/caddy/config:/config
      - /srv/caddy/log:/var/log/caddy
    networks:
      - bao-edge

networks:
  bao-edge:
    external: true

我这里特意用了当前官方的 2.11.6-alpine,而不是 latest;目前 Docker 官方镜像列出的当前 2.11 patch 就是 2.11.6。感兴趣可以去Docker Hub看

443/udp 是给 HTTP/3/QUIC 留的。

编写配置文件

# --- 1. 通用 Snippet 定义 ---
(cf_only) {
        @no_cf {
                not header Cf-Ray *
        }
        handle @no_cf 403 {
                abort
        }
}

# --- 2. 具体业务域名配置 ---

# OpenBao;以下域名为脱敏示例,使用时换成自己的域名
openbao.miyunda.com {
        reverse_proxy openbao:8200
        log {
                output file /var/log/caddy/openbao_access.log {
                        roll_keep 10

                        roll_keep_for 168h
                }
        }
}

# --- 3. 全局未定义域名/恶意扫描拦截(必须放最底部) ---

# 拦截所有未定义的 HTTP 请求(防止自动跳转或通过 IP 访问)
http:// {
        abort
}

# 拦截所有未定义的 HTTPS 请求(防止未匹配域名通过 443 访问)
:443 {
        abort
}

启动咧

先检查一下

cd ~/docker/caddy

docker compose run --rm caddy \
  caddy validate --config /etc/caddy/Caddyfile

没毛病的话就启动了

docker compose up -d

docker compose ps

docker compose logs --tail=100 caddy

现在可以用浏览器访问网站了,就是会报502错误,正常的。

F 配置 OpenBao

~/docker/openbao/config/bao.hcl 的内容如下:

ui = true

storage "raft" {
  path    = "/openbao/data"
  node_id = "bao-01"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = true
}

api_addr     = "http://openbao:8200"
cluster_addr = "https://openbao:8201"

disable_mlock = true

ui 打开网页界面,Raft 的路径对应后面挂载的宿主机目录。tls_disable 只服务于上面那段受控的容器内部连接,绝不能一边关闭 TLS,一边把这个监听直接发布到公网。

disable_mlock = true 是这次容器部署的取舍。我没有在这里配置内存锁,因此仍需考虑敏感数据被交换到磁盘(swap)的风险,别把这份配置当成生产环境的安全基线——再说一次:我这是过家家。

两个URL:api_addr 目前保留初次部署时的容器内部地址;不影响以后接入外部客户端或 OIDC 。cluster_addr 是节点间通信地址,和 Caddy 的外部 HTTPS 入口无关;当前只有一个节点,未来真要扩容,还得重新规划节点间的寻址。OpenBao 的官方文档对这两个参数有更完整的说明。

编写 Compose 文件

我的 ~/docker/openbao/compose.yaml:

services:
  openbao:
    image: openbao/openbao:2.7.1
    container_name: openbao
    restart: unless-stopped
    command: server
    environment:
      SKIP_CHOWN: "true"
    volumes:
      - ./config:/openbao/config:ro
      - /srv/openbao/data:/openbao/data
    networks:
      - bao-edge

networks:
  bao-edge:
    external: true
    name: bao-edge

bao-edge 已经在上一节由我的 Caddy 部署使用,所以这里声明为外部网络。配置目录只读,数据目录可写。Compose 没有 ports:即使 docker compose ps 里出现 8200/tcp,那也只是容器端口,不等于宿主机开了 0.0.0.0:8200->8200/tcp。

第一次启动,容器陷入了循环重启:

docker compose up -d
docker compose ps
docker compose logs --tail=100 openbao

日志里有三条相对醒目:

chown: /openbao/config/bao.hcl: Read-only file system
WARNING: ignoring duplicate configuration found in directory: /openbao/config/bao.hcl
error initializing storage of type raft: failed to create fsm: failed to open bolt file: open /openbao/data/vault.db: permission denied

看了下,觉得最后一条有用。

第一条是镜像入口脚本想给只读的配置文件改所有权;已经给它指示 SKIP_CHOWN: "true",别碰。第二条是我曾给 server 又加了一次 -config=/openbao/config/bao.hcl,第6行;而镜像的入口脚本在执行 server 时已经会加载 /openbao/config,因此得改成 command: server。不过那些都不重要,真正的错误是是最后的 permission denied。

数据目录是宿主机 bind mount,容器里的 openbao 用户没有写权限。先查镜像内实际的 UID/GID`:

docker run --rm openbao/openbao:2.7.1 \
  sh -c 'id openbao'

用查到的数值替换下面的占位符,再改宿主机文件夹。<UID>、<GID> :

sudo chown -R <UID>:<GID> /srv/openbao/data
sudo chmod 700 /srv/openbao/data

秘密数据的目录,别轻易 chmod 777 来“修复”权限。改好后重新启动:

cd ~/docker/openbao
docker compose down
docker compose up -d
docker compose logs --tail=100 openbao

这次日志出现了:

Storage: raft (HA available)
Version: OpenBao v2.7.1
==> OpenBao server started!

这里的 HA available 说的是 Raft 存储后端具备 HA 能力,并没有把我的单节点变成高可用集群。server started 也只是进程启动,不代表已经初始化或解封。

再看下运行状态、数据文件和网络:

docker compose ps
sudo ls -lah /srv/openbao/data
docker inspect openbao \
  --format '{{json .NetworkSettings.Networks}}'
docker port openbao

这里要确认的是:容器稳定运行,Raft 目录有文件,接入的是预期网络,而且 docker port openbao 没有宿主机端口映射。不要为了重来一遍就删除 /srv/openbao/data;一旦完成初始化,这里面就不再是可以随手清掉的练习文件了。

成功

Caddy那边什么都不用动。

接下来就可以用浏览器访问啦。

也可以用命令行:

curl -s https://openbao.miyunda.com/v1/sys/health | jq

返回:

{
  "initialized": false,
  "sealed": true,
  "standby": false
}

G 初始化和解封

服务刚启动时还不能拿来存秘密。初始化只做一次,会产生解封用的 share 和一个 Initial Root Token;采用手动 Shamir seal 时,进程重启后通常还要重新解封。这两件事的材料和用途不同。

这次我选 3 份 share、阈值 2。计划把三份分别保管在 1Password、macOS Keychain 和纸质离线备份中。听起来是三个地方,但其实我的 1Password 和 Keychain 依赖同一套同步和生物识别(Touch ID 和 Face ID),风险未必真的独立。假如您有与苹果无关的谷歌系生态那么可以放一个。这份分配方案还是见仁见智吧。

起初我在容器里跑 bao operator init,碰到一个看着很像服务端故障的错误:

Get "https://127.0.0.1:8200/v1/sys/seal-status":
http: server gave HTTP response to HTTPS client

原因很直接:CLI 尝试用 HTTPS 连本机 8200,而前面的 OpenBao 侦听在容器网络里使用 HTTP。若坚持在容器内操作,需要给 CLI 设置 BAO_ADDR=http://127.0.0.1:8200。所以之后就别在服务器上费劲了,在您常用的机器上安装 bao ,直接走正式的 HTTPS 入口:

# 苹果用户
brew install bao
# APT 用户
sudo apt install openbao

bao --version
export BAO_ADDR="https://openbao.miyunda.com"
bao status
bao operator init \
  -key-shares=3 \
  -key-threshold=2

operator init 的输出包含三份解封 share 和 Initial Root Token。这里我就不放输出了。初始化前先想好保存方式;别等终端吐出密钥了才开始找聊天窗口、笔记或截图暂存。OpenBao 的初始化命令文档也说明了 share 数量和阈值的作用。

初始 Root Token 不参与“2 份 share 解封”的计算。前者是最高权限的管理凭据,后者用于让服务从 sealed 状态恢复。初始 Root Token 我会单独保存;等后续完成日常管理员认证、权限策略和恢复流程,再决定如何处理它。

初始化后先看 bao status,应能看到 Initialized 为 true、Sealed 为 true。第一次解封时,不要把 share 写在命令行参数里:

bao operator unseal

按提示输入第一份 share,进度应到 1/2,此时仍然 sealed。再执行一次同样的命令,输入另一份 share:

bao operator unseal
bao status

达到阈值后,验收时应看到的核心状态是:

Initialized    true
Sealed         false
Storage Type   raft

第三份 share 是备用材料,不需要为了凑数在这次解封里继续输入。OpenBao 的解封命令文档也建议交互输入,以免 share 留在命令历史中。

以上也可以使用浏览器图形界面完成,不赘述了。

Am 小结

这篇文章记录了 OpenBao 安装、初始化、解封的过程,它通过 Caddy 和 Cloudflare 提供互联网访问和保护。

下一步我将接入现有的 Okta/OIDC,给日常管理员和应用分别配置身份与权限;再做 Raft snapshot、异机加密备份和恢复演练。源站访问限制、Cloudflare 到 Caddy 的 TLS 配置也要进一步安全加固。