VPS 上用 Vaultwarden 自建密码管理器
在 VPS 上用 Docker 部署与 Bitwarden 兼容的 Vaultwarden 密码管理器:HTTPS 优先、管理员令牌、Fail2ban 防护、经过验证的备份。
您要搭建的东西
一个完全归您所有的密码管理器:Vaultwarden 运行在一个小巧的容器里,前面由一个终止 HTTPS 的反向代理挡着,手机、笔记本电脑和浏览器上的官方 Bitwarden 应用都指向它。Vaultwarden 用 Rust 重新实现了 Bitwarden 服务器 API,使用与 bitwarden.com 相同的协议,因此每一个官方客户端都能原封不动地连接它,而它只需要约 100 MB 内存,而不是官方那套多容器方案。
安装本身只有十几行 Compose 配置。真正要紧、也真正会出问题的是这三件事:在您首次打开 Web 保管库之前,TLS 就必须已经就位;在您自己的账户创建好的那一刻,就必须关闭公开注册;数据卷必须备份并做过恢复测试,因为那一个目录里存着您所有的密码。
前提条件与需要坦白的坑
- 一台装有 Docker Engine 和 Compose 插件的 VPS,运行在全新的 Ubuntu 24.04 KVM 机器上,拥有 root 或 sudo 权限。512 MB 内存确实就够用,1 GB 会更从容。这是您能运行的最轻量的服务之一,在值得自建的服务短名单中名列前茅。
- 一个域名,配置一条 A 记录(如果有 IPv6 还要配 AAAA 记录)把
vault.example.com指向该 VPS。TLS 证书正是为这个确切的名称签发的,所以在您开始之前 DNS 必须能够解析。 - 80 和 443 端口对互联网开放,由您的反向代理来终止,绝不直接由 Vaultwarden 终止。80 端口仅用于 ACME 证书验证以及从 HTTP 到 HTTPS 的重定向。
- 一开始最大的坑:Bitwarden 客户端拒绝与非 HTTPS 的服务器通信。不存在"先用 http 测一下"这种做法,这条路走不通,具体原因下一节会讲。
为什么选 Vaultwarden,而不是官方 Bitwarden 方案
一样的客户端,重量却只有零头。官方自建版 Bitwarden 以一整组容器(MSSQL、Nginx、Identity、Api、Admin 等等)的形式发布,需要大约 2 GB 内存。Vaultwarden 是单个二进制文件,默认把所有数据存放在一个 SQLite 数据库里,空闲时只占用几十兆内存。对个人、家庭或小团队来说,它是显而易见的选择;而且因为它忠实地实现了 Bitwarden API,您的数据可以在它和 bitwarden.com 之间自由迁移。
您放弃的是大部分面向企业的功能:没有 SCIM 账户预配(不过实验性的 OpenID Connect 单点登录已在 1.35.0 版本中加入),而且您就是运维人员,所以打补丁、HTTPS 和备份都是您的活儿。本指南讲的就是这三件活儿。
为什么 HTTPS 不是可选项
Bitwarden 的 Web 保管库和浏览器扩展会在浏览器里用 Web Crypto API(window.crypto.subtle)推导您的加密密钥。浏览器只在安全上下文中暴露 crypto.subtle,也就是 HTTPS,或者 http://localhost 这个特例。在普通的 http://vault.example.com 上它是 undefined,所以应用一旦要推导密钥就会抛出异常,控制台会显示:
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'importKey')页面会卡住或显示一个笼统的加密错误,谁也登不进去。桌面端、移动端和浏览器客户端会对自建 URL 各自做一次检查,面对 http(或无法访问)的端点时,它们会拒绝并提示:
This is not a recognized Bitwarden server. You may need to check with your provider or update your server.两者的原因是同一个:没有有效的 HTTPS。所以我们先把 TLS 立起来,绝不通过 http 打开保管库,哪怕只是想快速看一眼也不行。
步骤 1 — DNS 与反向代理(TLS 优先)
把记录指向您的 VPS,并确认它解析到正确的地址:
dig +short vault.example.com它打印出的那一行必须是您的 VPS IP。如果为空或不对,就修正 DNS 并等待 TTL 过期,因为证书签发会在无法解析的名称上失败。
在 HTTPS 前端方面,本指南使用 Traefik,它会自动签发和续期 Let's Encrypt 证书,并且能直接嵌入 Compose。如果您还没有在运行它,请先按照Traefik 反向代理与自动 TLS 配置操作;它会创建一个外部 Docker 网络(下面的 proxy)和一个 ACME 解析器(letsencrypt),供 Vaultwarden 服务挂接。从 Vaultwarden 这一侧看,用手动签发证书的普通 nginx 效果完全一样。
更想用 nginx 和 Certbot,而不是 Traefik? 把 Vaultwarden 放在 127.0.0.1:8080 上(给该服务加上 ports: ["127.0.0.1:8080:80"] 并去掉 Traefik 标签),然后签发一张证书并把请求代理过去。证书这一半在用 Certbot 和 nginx 签发 Let's Encrypt 证书中有讲。关键的额外一步,是通知路径上的 WebSocket 升级:
server {
listen 443 ssl;
server_name vault.example.com;
client_max_body_size 525M;
location / {
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}请注意 X-Real-IP 那一行,正是它让后面的 Fail2ban 能看到真正的攻击者,而不是 127.0.0.1。无论前面是 Traefik 还是 nginx,本指南其余部分都完全相同。
步骤 2 — Compose 文件
先创建项目目录。本指南使用 /opt/vaultwarden,这样 Compose 项目名称(进而数据卷 vaultwarden_vw-data 的名称)就是可预期的;下面的 Fail2ban 和备份步骤都依赖这个确切的名称。
sudo mkdir -p /opt/vaultwarden
cd /opt/vaultwarden在该目录中为管理员密钥创建一个 .env,并在其中创建 Compose 文件。
# .env
ADMIN_TOKEN=paste-a-strong-token-here用 openssl rand -base64 48 生成这个令牌并粘贴进去。(更强的哈希形式下一节会讲;一开始用一长串随机字符串就够了。)
# docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "true" # closed in Step 4, keep true just to register
ADMIN_TOKEN: "${ADMIN_TOKEN}"
IP_HEADER: "X-Forwarded-For" # X-Real-IP if your proxy sends that instead
LOG_FILE: "/data/vaultwarden.log"
LOG_LEVEL: "warn"
volumes:
- vw-data:/data
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.vw.rule=Host(`vault.example.com`)"
- "traefik.http.routers.vw.entrypoints=websecure"
- "traefik.http.routers.vw.tls.certresolver=letsencrypt"
- "traefik.http.services.vw.loadbalancer.server.port=80"
volumes:
vw-data:
networks:
proxy:
external: true关于这个文件,有两点承载了整个设计。这里没有 ports: 映射,所以 Vaultwarden 只能通过 Traefik 及其 TLS 访问;在主机上发布它的端口,正是人们不小心用 http 提供保管库服务的原因。而 DOMAIN 必须是完整的公开 HTTPS URL:它会被写入附件链接、WebAuthn 双因素认证和通知端点,所以哪怕站点能打开,一个错误的或 http 的值也会让这些功能失效。latest 标签是对"永远不要用 latest"这条常规规则的有意例外:Vaultwarden 以单个滚动镜像的形式发布稳定版,而 :testing 是单独的预发布通道;所以请有意识地更新,并在拉取之前浏览一下发行说明。
把它启动起来,并查看日志:
docker compose up -d
docker compose logs -f vaultwarden正确启动会以类似 Rocket has launched from http://0.0.0.0:80 的一行结束。给 Traefik 几秒钟去获取证书,然后打开 https://vault.example.com,您应该会看到 Bitwarden 的 Web 保管库,带有有效的锁形图标,且没有证书警告。
步骤 3 — 强 ADMIN_TOKEN,以及 $$ 陷阱
ADMIN_TOKEN 守护着 /admin,这个面板能读取您实例上的每一个用户和设置,所以请把它当作 root 密码来对待。有两种可用的形式。
简单的形式,就是您已经用 openssl rand -base64 48 生成的那串随机字符串。因为 base64 里绝不会包含 $,所以它可以直接放进 .env,无需转义。
加固的形式是 Argon2 PHC 哈希,这样明文令牌就绝不会存到磁盘上。用同一个镜像生成一个:
docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp它会提示两次,然后打印一串以 $argon2id$v=19$... 开头的字符串。这里有个能让人耗掉一小时的陷阱:Docker Compose 把 $ 当作变量插值,所以当您把哈希粘贴进 Compose 文件时,必须把每个 $ 都写成 $$。把它直接放在 environment: 下面,而不是通过 .env,也不要用引号包起来:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$c29tZXNhbHQ$$RdescudvJCsgt3ub+b+dWRWJTmaaJObG如果您保留单个 $ 符号,Compose 会警告 The "argon2id" variable is not set 并把令牌清空,于是 /admin 会拒绝您正确的密码。运行 docker compose up -d,并把您在提示符处输入的明文保存到您自己的密码库里。
步骤 4 — 注册您的账户,然后锁上门
在 SIGNUPS_ALLOWED: "true" 的情况下,打开 https://vault.example.com,点击 Create account,用您的邮箱和一个强主密码注册。这个主密码永远无法找回,没有重置一说,所以请先把它保存在一个可靠的地方。
现在锁上门。编辑 Compose 文件,把注册关掉:
SIGNUPS_ALLOWED: "false"用 docker compose up -d 重新应用。这不是可以拖延的加固步骤。如果一直开着,任何找到这个 URL 的人(爬虫就会找到)都能在您的服务器上创建账户。他们读不到您的保管库,但会消耗资源,把您的私有实例变成一个开放服务。判断您忘了关的信号:/admin 里列出了一些您从未创建过的账户。
若想以后添加家人或队友,又不重新打开公开注册,请使用 /admin 里的 Invite User 按钮;这条路需要配置好 SMTP,被邀请者才能收到他们的链接。
步骤 5 — 访问 /admin
浏览到 https://vault.example.com/admin,输入明文管理员令牌(那串随机字符串,或您做过哈希的那个密码,而不是哈希本身)。进去之后,您可以列出用户、调整设置、发送测试邮件以及创建数据库快照。
如果页面返回 404 Not Found,说明 ADMIN_TOKEN 为空或未设置,这会完全禁用该面板;如果您从不需要它,这本身也是一个合理的选择。如果面板能加载却拒绝您的令牌,请看下面故障清单里的 $$ 转义陷阱。忘了令牌?这里没有找回提示;编辑 .env 或 Compose 文件,设一个新的,然后 docker compose up -d。
步骤 6 — 连接 Bitwarden 客户端
每一个官方客户端都能指向自建服务器,所以从常规的应用商店安装 Bitwarden 桌面端、移动端或浏览器客户端即可,您不需要什么专门的 Vaultwarden 版本。
登录之前,先打开登录界面上的设置齿轮(标着 Self-hosted 或 Region → Self-hosted),把 Server URL 设为 https://vault.example.com,然后保存。接着用您注册时的邮箱和主密码登录;客户端应该会立即连上,并主动提出为您填充和保存凭据。
如果某个客户端显示 This is not a recognized Bitwarden server. You may need to check with your provider or update your server.,那就是 URL 错了、用了 http,或者证书不受信任;请先在浏览器里重新确认 https://vault.example.com 能干净地打开。其他设备上更新缓慢是 WebSocket 推送的问题,下面会讲。
步骤 7 — 为登录端点设置一个 Fail2ban jail
Vaultwarden 会把每一次失败的登录都记录到 LOG_FILE 指定的文件里,这正是暴力破解防护所需要的。如果您还没有在运行 Fail2ban,安装和基础知识在Fail2ban SSH 加固指南里;这里我们为保管库添加一个 jail。
先找到这个命名卷在主机上的位置,好让 Fail2ban 能读取日志:
docker volume inspect vaultwarden_vw-data --format '{{ .Mountpoint }}'它会打印出类似 /var/lib/docker/volumes/vaultwarden_vw-data/_data 的路径;日志就是里面的 vaultwarden.log。创建过滤器:
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =再创建 jail:
# /etc/fail2ban/jail.d/vaultwarden.local
[vaultwarden]
enabled = true
filter = vaultwarden
logpath = /var/lib/docker/volumes/vaultwarden_vw-data/_data/vaultwarden.log
banaction = iptables-allports
chain = DOCKER-USER
maxretry = 5
findtime = 600
bantime = 3600用 sudo systemctl restart fail2ban 重新加载,并用 sudo fail2ban-client status vaultwarden 确认。
有三个 Docker 细节决定了这道防护到底管不管用。第一,如果日志在每次失败尝试时都显示 IP: 127.0.0.1 或您代理的地址,那说明 Vaultwarden 封的是代理本身;请把 IP_HEADER 设为您代理真正发送的那个头(Traefik 用 X-Forwarded-For,上面那段 nginx 用 X-Real-IP,在 Cloudflare 后面则用 CF-Connecting-IP)。第二,正确的 iptables 链取决于您的代理:如果 Traefik 作为一个发布了端口的容器运行,流量会走 Docker 的 FORWARD 路径,所以封禁必须像上面那样放在 DOCKER-USER 里;但如果您在步骤 1 选择了主机 nginx 方案,连接会在主机的 INPUT 链上由 nginx 终止,放在 DOCKER-USER 里的封禁根本看不到它们,这种情况下请删掉 chain = DOCKER-USER 那一行,让 Fail2ban 使用默认的 INPUT 链。第三,使用 banaction = iptables-allports,而不是基于端口的默认值,因为这个 jail 没有定义端口,而 DOCKER-USER 里的全端口封禁能干净利落地把攻击者挡在这台机器上每一个已发布的服务之外。
步骤 8 — 备份保管库,然后真的去恢复它
vw-data 卷就是您的密码管理器。它存放着 db.sqlite3(每一条记录)、attachments/ 和 sends/ 目录、给登录会话签名的 rsa_key.* 文件,以及来自管理面板的 config.json。漏掉其中任何一样的备份,都会在您需要它的时候失效。
在 Vaultwarden 正在写入时复制 db.sqlite3,可能会抓到一个写了一半、损坏的文件,所以要做冷快照,停机时间只有几秒钟:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
DEST=/root/vw-backups
VOL=$(docker volume inspect vaultwarden_vw-data --format '{{ .Mountpoint }}')
mkdir -p "$DEST"
docker compose -f /opt/vaultwarden/docker-compose.yml stop vaultwarden
tar czf "$DEST/vw-$STAMP.tgz" -C "$VOL" .
docker compose -f /opt/vaultwarden/docker-compose.yml start vaultwarden用 cron 每晚运行它,并把 .tgz 复制到这台机器之外,因为只存放在您所要保护的那台服务器上的备份,算不上备份。把它送出去的干净做法是每晚用 restic 备份到另一台服务器或对象存储,它会为您加密归档并对重复的快照做去重。管理面板的 Backup Database 按钮是一个方便的、只针对 SQLite 文件的热快照,但它不包含附件和密钥。
现在来做那个把真备份和只是抱着希望的备份区分开来的仪式:恢复一次,证明它确实管用:
mkdir -p /tmp/vw-restore
tar xzf /root/vw-backups/vw-2026-07-15.tgz -C /tmp/vw-restore
docker run --rm -p 127.0.0.1:8888:80 -v /tmp/vw-restore:/data vaultwarden/server在您的笔记本电脑上,用 ssh -L 8888:127.0.0.1:8888 you@your-vps 建立隧道,然后打开 http://localhost:8888。因为 localhost 是安全上下文,crypto.subtle 是可用的,保管库在这里能通过普通 http 解密,这是唯一被允许的地方。用您的主密码登录,确认您的记录都还在:如果都在,说明您的数据库、RSA 密钥和主密码都能完整地往返一遭,您就能在几分钟内在一台全新的 VPS 上重建。用 Ctrl-C 停止容器,并删除 /tmp/vw-restore。
故障模式,以及您会看到的字符串
浏览器控制台里的 Cannot read properties of undefined (reading 'importKey')。 保管库是通过 http 打开的,所以 crypto.subtle 是 undefined;只通过 https:// 访问它,并在代理处添加从 HTTP 到 HTTPS 的重定向。
某个客户端里的 This is not a recognized Bitwarden server...。 Server URL 用了 http、打错了,或者证书不受信任;确认 https://vault.example.com 显示有效的锁形图标,然后在客户端的自建设置里重新输入它。
/admin 拒绝正确的密码。 Argon2 哈希丢了转义(在 Compose 里每个 $ 都必须是 $$),或者您输入的是哈希,而不是它所代表的明文。
跨设备同步缓慢;控制台显示 WebSocket connection to 'wss://vault.example.com/notifications/hub' failed。 代理没有转发 Upgrade/Connection 头;Traefik 会自动处理,nginx 则需要步骤 1 里那两行升级配置。保管库仍然能用,只是打开时才同步。旧的专用端口 3012 自 v1.31.0 起已经取消,所以不需要单独的 WebSocket 路由。
Fail2ban 报告有封禁,但攻击者还在不停连接。 它封的是 127.0.0.1,因为 IP_HEADER 设错了,或者封禁放在了错误的 iptables 链上;请设置 chain = DOCKER-USER 和 banaction = iptables-allports。
升级
拉取新镜像并重新创建;命名卷和您所有的数据都会保留:
docker compose pull
docker compose up -dVaultwarden 发布很频繁。请关注项目的发行说明,而不是锁定某个补丁版本,因为有些版本带有迁移说明。在任何大版本升级之前都先做一次新备份;您可以通过把 tar 包恢复到一个新卷里来回滚。
FAQ
Vaultwarden 和 Bitwarden 是一回事吗?
它是一个兼容的、独立的服务器,不是官方那个。Vaultwarden 用 Rust 重新实现了 Bitwarden 服务器 API,所以官方的桌面端、移动端、浏览器和 CLI 客户端都能连它,而占用的资源只是官方那套方案的零头。保管库格式是一样的,所以您可以通过导出和导入向任意方向迁移。
我真的需要 HTTPS 吗,还是可以在局域网里用 http 运行它?
除了 localhost 测试之外,任何情况都需要 HTTPS。Bitwarden 的 Web 保管库和扩展使用浏览器的 Web Crypto API,它只在安全上下文中可用,所以在普通 http 上客户端会抛出 Cannot read properties of undefined 并且永远登不进去。唯一能用的 http 地址是 http://localhost,这也是步骤 8 里的恢复测试要用 SSH 隧道的原因。
我怎样阻止陌生人在我的服务器上注册?
在创建好您自己的账户之后,立即在 Compose 文件里设置 SIGNUPS_ALLOWED: "false" 并运行 docker compose up -d。从那以后,通过 /admin 里的 Invite User 按钮来添加新成员,这需要配置好 SMTP,他们才能收到邀请链接。偶尔检查一下管理员用户列表,确认没有冒出意料之外的账户。
我怎样备份我的 Vaultwarden 保管库?
短暂停止容器,把整个 vw-data 卷打包归档(db.sqlite3、attachments/、sends/、config.json 以及 rsa_key.* 文件),然后把归档复制到服务器之外,最好用每晚的 cron。在服务器运行时复制活动的 SQLite 文件有抓到损坏快照的风险,所以要冷备。最重要的是,把它恢复到一个用完即弃的容器里并登录一次,这样在您依赖这个备份之前,就知道它是真的可用。
自建保管密码真的安全吗?
安全,只要您做到本指南讲的这三件事:真正的 HTTPS、关闭注册加上一个强管理员令牌,以及经过测试的备份。您的保管库是用您的主密码在客户端加密的,所以连服务器都从不会看到您的明文密码;被偷走的 db.sqlite3 没有主密码就毫无用处。代价是打补丁和备份现在都是您的责任,这也是这里 Fail2ban 和那套恢复仪式并非可选的原因。