密钥不该躺在服务器上:用 SOPS + age 把它们安全地放进 Git

让明文永不入库、迁移只需一把钥匙的密钥管理最佳实践

June 26, 2026·4 min read·Yimin
#SOPS#age#密钥管理#DevOps#最佳实践

你有没有想过:如果跑着所有服务的那台服务器今天硬盘挂了,你能多快把数据库密码、JWT secret、第三方 API key 全部找回来?如果答案是"得翻聊天记录和脑子",那这篇就是写给你的。

🎯 问题:密钥只活在服务器上

绝大多数小项目的密钥管理是这样的:每个服务一个 .env.production,里面是数据库连接串、各种密码和 token,文件被 .gitignore 忽略,只存在于生产服务器上

平时没事,但它埋了三颗雷:

场景后果
机器/磁盘挂了密钥跟着没了,靠记忆重建
换云厂商 / 开新机一个一个密钥手抄过去,漏一个服务就起不来
想知道某密钥啥时候改的没有任何记录,无从审计

根因就一句话:密钥没有一个可复现、可版本化的真相来源

那能不能把 .env 直接提交到 Git?当然不能——明文密钥进了仓库历史,等于公开。

我们要的是:密钥进 Git(可版本化、可复现),但内容是加密的。这正是 SOPS + age 干的事。

🧠 原理:age 负责加密,SOPS 负责"优雅地"加密

这俩是搭档,分工明确。

age:极简的非对称加密

age("actually good encryption")是一个现代、极简的加密工具。核心就一对钥匙:

公钥 (public key)  →  age1xxxxxxxx...   用来「加密」,可以公开
私钥 (secret key)  →  AGE-SECRET-KEY-1...  用来「解密」,必须保密

任何人拿公钥都能加密,但只有持私钥的人能解密。于是策略变得很干净:公钥写进仓库谁都能加密,私钥你自己保管好就行。

SOPS:让加密文件还能 review

如果只用 age,加密后整个文件变成一坨密文 blob,改一个值、看 diff 都很痛苦。

SOPS(Secrets OPerationS)的聪明之处是:对于结构化文件(dotenv、YAML、JSON),它只加密"值",保留"键"。一个加密后的 .env 长这样:

# 加密前
DATABASE_URL=postgres://user:<password>@db/app
API_TOKEN=<your-token-here>

# 加密后(SOPS dotenv 模式)
DATABASE_URL=ENC[AES256_GCM,data:<ciphertext>,iv:<iv>,tag:<tag>,type:str]
API_TOKEN=ENC[AES256_GCM,data:<ciphertext>,iv:<iv>,tag:<tag>,type:str]

好处立竿见影:

  • 能看出改了哪个键:Git diff 显示 DATABASE_URL 这一行变了,但看不到明文值
  • 键名仍可读:review 时知道有哪些配置项
  • 一个 .sops.yamlcreation_rules 声明"哪些路径用哪个公钥加密",团队统一规则

多 recipient:给多把钥匙开锁的能力

SOPS 支持把同一份密文加密给多个公钥。这意味着:

secrets 文件  ←  同时被「你的 age 公钥」和「新服务器的 age 公钥」加密
                 任意一把对应私钥都能解开

加一台机器、加一个团队成员,只要把他的公钥加进 recipients、重新加密一次即可,不用重发密钥。

🏛️ 为什么是最佳实践?横向对比

把常见的几种方案摆一起就清楚了:

方案可复现可审计(diff/历史)迁移成本额外依赖适合谁
裸放服务器 .env高(手抄)没人
GitHub Secrets⚠️ 半❌ 写入即不可读锁定 GitHubCI 少量密钥
Vault / 云 KMS重,需常驻服务 + 绑厂商中大团队
SOPS + age低(一把私钥)仅两个 CLI独立开发者 / 小团队

几个关键点:

  • GitHub Secrets 看似省事,但它是"只写不可读"的黑盒:你无法导出、无法 diff、无法离线复现,而且强绑定 GitHub。密钥一多就成了一团没人说得清的浆糊。
  • Vault / 云 KMS 很强,但要常驻一个高可用服务、要接 SDK,还把你绑死在某个厂商上——对一台小服务器是杀鸡用牛刀,而且和"将来换云厂商"的目标相悖
  • SOPS + age 没有任何常驻服务,密文就是 Git 里的普通文件,要守护的只有一把 age 私钥。换厂商时它毫无牵挂。

一句话:SOPS 把"密钥管理"退化成了"管好一个文件 + 一把钥匙",复杂度和你的项目规模匹配,这就是它对小团队最优的根本原因。

⚙️ 落地实践:几个让它真正好用的工程决策

光知道原理不够,下面是把它用顺手的几个关键决策。

1. 密文集中放,别散在服务目录里

直觉上你会把 services/api/.env.production.sops 放在服务旁边。但很多 CI 的镜像构建是按路径触发的(比如改动 services/api/** 就重新构建 api)。密文文件一改、一提交,就会误触发一堆构建和部署。

更好的做法:所有密文集中到一个独立目录,路径镜像原位置:

secrets/
├── services/api/.env.production.sops
├── services/worker/.env.production.sops
└── infrastructure/db/.env.sops
        ↓  解密时还原到  ↓
services/api/.env.production
services/worker/.env.production
infrastructure/db/.env

secrets/ 不匹配任何服务的构建路径,改密钥再也不会误触发 CI 风暴

2. 在 CI runner 里解密,私钥永不落到服务器

部署时怎么把密文变成服务器上的明文 .env?两种思路:

在服务器上解密:要把私钥传到服务器、服务器还得装 SOPS——等于又给私钥多开了一个落脚点。

在 CI runner 里解密,再 scp 明文过去

GitHub Actions runner:
  1. 用 GH secret 里的 age 私钥  →  sops 解密  →  得到明文 .env.production
  2. scp 明文到服务器对应目录
服务器:
  3. docker compose up -d   (直接用现成的 .env)

私钥只活在 GitHub Secrets 和你的密码管理器里,生产服务器从头到尾不碰私钥、也不用装 SOPS。爆炸半径更小。

小技巧:解密后加一句 test -s .env.production解出来是空的就让这步失败、不下发,避免坏密钥覆盖掉服务器上正常的配置。

3. 日常操作:你只跟明文打交道

把命令包成 Makefile,平时根本感觉不到加密的存在:

# 改密钥:编辑器里打开的是明文,存盘自动重新加密
make secrets-edit FILE=secrets/services/api/.env.production.sops

# 新机器 / 灾备:一条命令把所有密文还原成明文
make secrets-decrypt-all

sops 在你保存时透明地重新加密,Git 里永远是密文,你眼里永远是明文

🔁 迁移 / 灾备 Runbook

这才是这套方案的高光时刻。新服务器从零恢复全部密钥,只要三步:

git clone <your-repo> && cd <your-repo>
# 从密码管理器取出 age 私钥(切勿提交、截图或发到聊天)
mkdir -p ~/.config/sops/age
chmod 700 ~/.config/sops/age
# 将私钥写入 keys.txt,权限 600
make secrets-decrypt-all      # 所有 .env 按原路径就位

换云厂商、机器重装、新同事入职——统统退化成"clone 仓库 + 有那把钥匙"。再也没有"密码在哪台机器上"的灵魂拷问。

⚠️ 几个别踩的坑

说明
私钥进了截图/聊天age 私钥是总钥匙,一旦泄露要立刻 age-keygen 换新 + sops updatekeys 重新加密
明文忘了 gitignore加密前先确认 .env / .env.production 都在 .gitignore 里,别让明文溜进历史
dotenv 的注释不保留SOPS dotenv 模式只保证 KEY=VALUE 无损,注释/空行可能丢,别在 .env 里写重要注释
私钥只存一处私钥丢了等于所有密文作废。密码管理器 + 一处离线备份,至少两份

📝 总结

把密钥管理做对,本质是回答一个问题:这台机器没了,我多久能满血复活?

  • age:一对钥匙,公钥加密、私钥解密,极简
  • SOPS:只加密值、保留键,让密文能 diff、能 review,按规则统一加密
  • 最佳实践:密文集中 secrets/ 不触发 CI、CI runner 内解密让私钥不落服务器、Makefile 包装日常操作
  • 最大收益:密钥脱离单台服务器、可版本化可审计、迁移只需一把私钥

对独立开发者和小团队来说,SOPS + age 的复杂度恰好卡在"够用且不过度"的甜点上——没有常驻服务、不绑厂商、迁移无负担。把那把 age 私钥保管好,剩下的就交给 Git。