适用场景
Vaultwarden 密码库加固这件事,通常是在团队把 Bitwarden 官方云换成自建之后才被认真对待的。典型场景有三类:一是数据合规要求凭据不能出境或不能放在第三方;二是运维团队需要在内网隔离环境里共享服务器密码、数据库口令、API Key;三是小团队想省掉按人订阅的费用,用一台 2C2G 的机器自建。
自建之后暴露面立刻变了。公网云服务商替你扛掉的部分——注册风控、暴力破解防护、审计留痕、备份——全都要自己补。默认配置的 Vaultwarden 允许任何人注册,管理后台只有一个弱口令,数据库就是一个 SQLite 文件躺在磁盘上。下面按「注册收敛、后台加固、审计留痕、一致性备份」四条线给出一套可以照着复制的配置。
前置条件
- 一台 2 核 2G 以上 Linux 主机,Docker 与 Compose v2 已安装;
- 一个已解析到该主机的域名,并配好 TLS 证书。浏览器 WebCrypto 只在 HTTPS 或 localhost 下可用,纯 HTTP 访问会直接报错;
- Nginx 或 Caddy 作为反向代理,负责 443 终止与限速;
- 一块独立备份空间,本地目录加对象存储各一份;
- 管理员能拿到
docker与sqlite3的执行权限。
原理说明
先明确一件事:Vaultwarden 服务端看不到你的明文密码。客户端用主密码经 KDF 派生出对称密钥,条目在浏览器或 App 里加密后才上传,服务端存的只是密文和主密码的验证哈希。这意味着加固重点不在「防服务端解密」,而在另外四件事:防止陌生人注册占用资源、防止账号被接管、防止数据库被整库拖走后离线爆破、以及出事后能说清谁在什么时候登录过。
数据库被拖走的风险常被低估。攻击者拿到 db.sqlite3 后,可以对每个账号的主密码做离线字典攻击,成本只取决于 KDF 迭代次数和主密码强度。Vaultwarden 的 PASSWORD_ITERATIONS 决定新账号默认的 PBKDF2 迭代次数,默认值约 60 万次,调到 60 万以上(或客户端选 Argon2id)能显著抬高爆破成本。注意服务端只能影响新建账号,老账号需要在客户端里手动调整 KDF 设置。
备份的口径也要说清。SQLite 默认开 WAL 模式,直接 cp db.sqlite3 可能拿到一个缺了最新事务的半成品,恢复时表现为「部分条目丢失」或直接报损坏。正确做法是用 SQLite 自带的备份 API,也就是 sqlite3 db ".backup 目标文件",它会加锁并产出一致性快照。
操作步骤
第一步:用 compose 固定版本并收敛监听
镜像必须写死版本号,不要用 latest。密码库这类服务一旦自动升级踩到不兼容变更,回滚成本很高。端口只绑回环,对外由 Nginx 终止 TLS。
# /opt/vaultwarden/docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:1.32.7
container_name: vaultwarden
restart: unless-stopped
env_file: /opt/vaultwarden/vaultwarden.env
volumes:
- /opt/vaultwarden/data:/data
ports:
- "127.0.0.1:8080:80"
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
第二步:生成 argon2 管理令牌并写环境变量
ADMIN_TOKEN 支持明文,也支持 argon2 哈希串。用哈希的好处是配置文件泄露后攻击者拿不到后台口令。用官方镜像自带的 hash 子命令生成:
docker run --rm -it vaultwarden/server:1.32.7 /vaultwarden hash
# 按提示输入两次管理员密码,输出形如:
# $argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
# 把整行原样写入 ADMIN_TOKEN
环境变量文件内容如下。注意 $ 在 env_file 中不会被 Compose 插值,可以直接写;如果写在 compose 的 environment: 块里,则必须写成 $$,否则哈希串会被截断成空值,表现为后台一直提示令牌错误。
# /opt/vaultwarden/vaultwarden.env
DOMAIN=https://vault.example.com
SIGNUPS_ALLOWED=false
INVITATIONS_ALLOWED=true
SHOW_PASSWORD_HINT=false
PASSWORD_ITERATIONS=600000
ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
LOG_LEVEL=warn
LOG_FILE=/data/vaultwarden.log
EXTENDED_LOGGING=true
IP_HEADER=X-Real-IP
SENDS_ALLOWED=false
WEB_VAULT_ENABLED=true
逐项说明几个关键开关。SIGNUPS_ALLOWED=false 关注册,这是自建密码库最重要的一条,开着等于给全网提供免费密码托管;INVITATIONS_ALLOWED=true 保留邀请通道,新同事仍能加入;SHOW_PASSWORD_HINT=false 关掉主密码提示,提示本身就是情报;IP_HEADER=X-Real-IP 让日志记录真实客户端 IP 而不是反代地址,审计时没有这一条等于白记。
第三步:Nginx 反代与后台路径限制
管理后台只需要内网访问,不要在公网开放 /admin。用两条 location 把边界划清,同时补上安全响应头并关掉 WebSocket 之外的无关方法。
server {
listen 443 ssl http2;
server_name vault.example.com;
ssl_certificate /etc/nginx/ssl/vault.crt;
ssl_certificate_key /etc/nginx/ssl/vault.key;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
client_max_body_size 525m; # 与附件上限保持一致
# 管理后台仅内网可访问
location /admin {
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 登录接口单独限速,压制撞库
location /identity/connect/token {
limit_req zone=vw_login burst=5 nodelay;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
limit_req 依赖 http 块里的共享内存声明,没有它 Nginx 启动会报 unknown limit_req zone:
# /etc/nginx/nginx.conf 的 http 块内
limit_req_zone $binary_remote_addr zone=vw_login:10m rate=10r/m;
第四步:操作审计
审计分两头看:应用侧看谁在什么时候登录、新设备接入;代理侧看登录接口的请求来源分布。应用侧最直接的是查询数据库,新设备接入会写进 devices 表:
sqlite3 /opt/vaultwarden/data/db.sqlite3 \
"select u.email,
datetime(d.created_at,'localtime') as login_time,
d.name,
d.atype
from devices d join users u on u.uuid = d.user_uuid
order by d.created_at desc
limit 30;"
账号与 KDF 现状也一并查出来,用来找出迭代次数偏低的老账号:
sqlite3 /opt/vaultwarden/data/db.sqlite3 \
"select email,
datetime(created_at,'localtime') as created,
client_kdf_iter
from users
order by created_at desc;"
代理侧统计登录来源,突然冒出的境外 IP 或单一 IP 的高频失败就是信号:
# 成功登录的来源分布
awk '$7 ~ /identity\/connect\/token/ && $9 == 200 {print $1}' \
/var/log/nginx/vault.access.log | sort | uniq -c | sort -rn | head -20
# 401 次数最多的来源,撞库特征
awk '$7 ~ /identity\/connect\/token/ && $9 == 401 {print $1}' \
/var/log/nginx/vault.access.log | sort | uniq -c | sort -rn | head -20
新设备邮件通知也建议开启,Vaultwarden 在检测到新设备登录时会给账号邮箱发提醒。前提是配好 SMTP,否则通知发不出去,等于没开。
第五步:一致性备份与恢复演练
备份三件套:数据库用备份 API、附件用归档、产物同步到对象存储。附件目录与数据库必须同一次快照,否则恢复后会出现条目存在但附件 404。
#!/usr/bin/env bash
set -euo pipefail
D=/opt/vaultwarden/data
B=/backup/vaultwarden
TS=$(date +%F)
mkdir -p "$B"
sqlite3 "$D/db.sqlite3" ".backup '$B/db-$TS.sqlite3'"
tar czf "$B/attachments-$TS.tar.gz" -C "$D" attachments sends 2>/dev/null || true
find "$B" -name 'db-*.sqlite3' -mtime +30 -delete
rclone copy "$B" remote:vaultwarden-backup --include "*-$TS.*"
echo "backup done: $TS"
备份做完必须演练。把备份文件拷到一台测试机,起一个临时容器挂上去,能正常登录并看到条目才算合格:
mkdir -p /tmp/vwtest/data
cp /backup/vaultwarden/db-2026-11-05.sqlite3 /tmp/vwtest/data/db.sqlite3
docker run --rm -d --name vwtest -p 127.0.0.1:8081:80 \
-v /tmp/vwtest/data:/data vaultwarden/server:1.32.7
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/
# 期望 200,再用浏览器登录确认条目数量与最近修改时间对得上
配置验证
# 1. 容器状态与版本
docker compose -f /opt/vaultwarden/docker-compose.yml ps
# 2. 注册已被关闭:应返回 400 或明确提示注册关闭
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
https://vault.example.com/identity/accounts/register
# 3. 管理后台从公网应 403
curl -s -o /dev/null -w "%{http_code}\n" https://vault.example.com/admin/
# 4. 安全响应头齐全
curl -sI https://vault.example.com/ | grep -Ei 'strict-transport|x-content-type|x-frame|referrer'
# 5. 真实 IP 已透传:日志里不应全是 127.0.0.1
tail -5 /opt/vaultwarden/data/vaultwarden.log
常见问题
Q1:自建之后,服务端管理员能看到用户的密码吗?
看不到。条目在客户端用主密码派生的密钥加密后才上传,服务端只存密文。但有三类元数据是可见的:账号邮箱、条目数量与类型、登录 IP 与设备信息。另外如果启用了 Send 的文本类型并选择「不加密」,那段文本会以明文落库,敏感内容不要走这条路。
Q2:数据库被整库拖走,风险到底有多大?
取决于主密码强度和 KDF 迭代次数,两者是乘法关系。迭代 60 万次的 PBKDF2 下,单个候选口令的验证成本约零点几秒,弱口令几小时内就会被跑出来,12 位以上的随机口令则基本不可行。所以三件事要同时做:PASSWORD_ITERATIONS 不低于 60 万、强制用户开二次验证、数据库文件权限收紧到 600 并禁止任何公网可达路径。
Q3:为什么不能直接复制 db.sqlite3 做备份?
SQLite 默认开启 WAL 模式,最新写入先落在 db.sqlite3-wal 里。文件拷贝的过程中如果有写入,拷到的可能是缺事务的中间状态,恢复后表现为部分条目丢失或直接报 malformed。用 .backup 子命令或备份 API,它会加读锁并把 WAL 合并进快照,产出的文件才是自洽的。
Q4:关掉注册后,新同事怎么开账号?
管理员在后台生成邀请,或者直接发邀请链接。这要求配好 SMTP,否则邀请邮件发不出去。临时开放注册时不要只改 SIGNUPS_ALLOWED,同时设置 SIGNUPS_DOMAINS_WHITELIST=yourcompany.com,把范围压到企业邮箱域,开完立刻改回来。千万别长期挂着开放注册,扫描器对这类实例的探测是持续性的。
总结
自建密码库的安全水位不由软件决定,而由四件运营动作决定:关注册、护后台、留审计、做可恢复备份。落地顺序建议是先关注册再改后台令牌,最后补备份与演练,因为前两步几分钟就能完成,收益最大。版本号务必写死,升级前先在测试实例上验证客户端兼容性。最后一句提醒:备份文件本身就是最高价值目标,它和数据库一样需要加密存放与访问控制,别把备份放在同一台机器同一个磁盘上。