如何搭建个人 CardDAV 通讯录服务器
这个想法主要受到 vCards 中国黄页 这个项目的启发,vCards 是由 metowolf 发起的黄页项目,维护了国内众多平台(机构、银行、医院等)的联系信息,本质上是维护了一大本通讯录,目前常用联系人有 300 个左右,每周还在持续更新。
开发者还部署并维护了一个公开的 CardDAV 服务 vcards.metowolf.com,支持 CardDAV 协议,通过手机直接订阅,如果有新的联系人信息,会自动同步到订阅者的通讯录中。
为什么需要自己的通讯录服务器
上面说的这个订阅服务我在 iPhone 上用了有两年多,非常实用。
平日里有一些陌生电话打进来,很难确定哪些是骚扰电话,哪些是正常电话。但又不敢轻易挂断,怕错过了重要消息,比如信用卡还款提醒、快递上门取件电话,错过了可能还要重新约时间,麻烦费力。
但是推销电话我是不想接的,比如阿里云、腾讯云的销售电话,不用接我就知道是日常 social,问一些关于域名、云服务器使用上的问题,电话那头的小哥为了 KPI 还会多跟你聊几句,聊到一半你又不好意思直接挂掉,尴尬又浪费时间。
这种情况下 vCards 就派上用场了,如果之前已经收录过电话号码,来电时看到联系人信息,就可以提前决定要不要接。
但现在销售部门也变得鸡贼了,开始用私人手机号或者虚拟号码来打电话,乍一看就是普通的电话号码,想着是什么快递到了或者有什么紧急事情,接了才发现是推销电话。 这也是 vCards 的局限性所在,毕竟联系人信息太多,且维护者精力有限,不可能面面俱到,很多号码收录相关的 pull request 也没有合并。
为了补齐这一点,我就有了自己搭建一个通讯录服务器的想法,收录一些我常用的联系方式,比如家人、朋友、同事的电话、邮箱、地址等,也收录一些不在 vCards 订阅中的保险、快递等机构的号码,方便日后查询。
我的使用场景
我的主要使用场景是,我在多台设备(iPad x 2,手机 x 2)上需要同步通讯录,但每台设备上 Apple 账号又不同,所以用 iCloud 同步不太可行,因此需要有一个集中的通讯录管理平台。未来不论更换或添加什么设备,都能做到一键同步。
另一方面是个人有点通讯录“洁癖”,希望保持联系人信息整洁。很多联系人都只在某一段时间内联系,比如中介、销售,时间长了忘了删除,就会长时间“霸占”通讯录。而自己搭建一个前台服务,就可以方便分组管理,日后集中清理。
前置要求
- 一台服务器(VPS 或本地,推荐 1GB 内存以上)
- Docker 环境
- 一个域名(用于 HTTPS 访问)
- Cloudflare 账号(可选,用于反向代理)
如何部署
Radicale
部署 CardDav 最方便的方式应该就是 Radicale 了,轻量、支持 CardDAV 协议、支持 Docker 部署,非常方便。
docker-compose 文件:
services:
radicale:
image: tomsquest/docker-radicale
container_name: radicale
ports:
- 5232:5232
init: true
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- CHOWN
- KILL
deploy:
resources:
limits:
memory: 512M
healthcheck:
test: curl -f http://127.0.0.1:5232 || exit 1
interval: 30s
retries: 3
restart: unless-stopped
volumes:
- ./data:/data
- ./config:/config:ro
配置文件
创建 config/config 文件(注意没有扩展名),添加以下内容:
[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = bcrypt
[storage]
filesystem_folder = /data/collections
[server]
hosts = 0.0.0.0:5232
创建用户密码文件(需要安装 htpasswd 工具):
# 在 config 目录下创建用户
htpasswd -Bc config/users yourname
如果没有 htpasswd 工具,可以使用 Docker 临时运行:
docker run --rm -it httpd:alpine htpasswd -Bnb yourname yourpassword > config/users
启动服务
docker-compose up -d
部署完服务可以通过 Cloudflare Tunnel 反代到公网,方便访问。也可以使用 Nginx 配合 Let’s Encrypt 证书。
创建地址簿
访问 https://vcards.yourdomain.com,使用创建的账号密码登录后,创建一个新的地址簿,命名为 yourname(与用户名相同),这个名称后面会用到。
之后就可以得到一个 URL,这个 URL 就是 CardDAV 服务的地址,可以用来同步到 iOS 设备。
操作平台
- 功能:主要用来创建、编辑、删除联系人。
- 技术栈:NextJS + drizzle + hono + tailwindcss
前端没什么好说的,用 Cursor vibe 一个展示页、搜索框及一个编辑界面,都是一些很基本的功能,总耗时也就不到一个小时。
数据同步
联系人信息存储在 PostgreSQL 数据库中,增删改操作都会同步到数据库中,主要字段如下:
interface Contact {
id: string; // uuid,唯一标识一个联系人
organization: string; // 组织
fullName: string; // 全名
givenName: string; // 名
familyName: string; // 姓
title: string; // 职位
phones: string[]; // 电话号码数组
email: string; // 邮箱
url: string; // 网站
imageUrl: string; // 头像 URL
address: string; // 地址
note: string; // 备注
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
}
一开始比较困惑的点在于,在网页上更改数据后,怎么同步到 Radicale 数据库中。
稍微观察了一下 Radicale 的存储方式,发现其实还挺简单,只需要把数据库里的信息包装成 Vcard 格式,然后通过 PUT 请求发送到 CardDAV 服务中。
Vcard 格式如下:
BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//iOS 18.6.2//EN
UID:5833faf1-774b-46bb-8369-c0686330c983
FN:张三
N:张三;张三;;;
PHOTO;VALUE=uri;X-ABCROP-RECTANGLE=ABClipRect_1&0&0&1024&1024&sMz40ONwnYKy407Yrz6veQ==:https://i.pinimg.com/1200x/bf/37/89/bf37890103f17074d4848ea14be6aea5.jpg
REV:2025-11-29T14:28:56Z
item1.URL;TYPE=pref:https://t.me/@i1234567
TEL;TYPE=CELL:18899887766
END:VCARD
以上联系人的 ID 为 5833faf1-774b-46bb-8369-c0686330c983,要同步这条联系人信息到 Radicale 服务器上,只需通过以下 API 进行更新:
# - yourdomain.com: 你的域名
# - yourname: 你的用户名
# - yourpassword: 你的密码
curl -X PUT -d @card.vcf \
https://vcards.yourdomain.com/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf \
-H "Content-Type: text/vcard" \
-H "Authorization: Basic $(echo -n "yourname:yourpassword" | base64)"
Radicale 服务器收到请求后,会把联系人信息以 5833faf1-774b-46bb-8369-c0686330c983.vcf 为文件名存储到 data/collections/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf 文件中。
这条数据会被实时同步到 Radicale 的 CardDAV 服务中,iPhone 设备会定时拉取最新数据,同步到本地。
更新联系人信息的操作本质上没有什么不同,添加新手机号、改完姓名后,用同样的方式把新的 card.vcf 发送过去后,旧的数据会被覆盖。
这里需要注意的一点是,在 Radicale 服务器里,vcf 叫什么名称不重要,这只是一个文件名,最重要的是里面的联系人信息。但为了避免联系人重复、冲突,我们需要一个唯一的 ID 来关联每一个联系人。身份证号是一个不错的选择,但我们没有,而用 UUID 也是可以的。
这个 ID 不仅是数据库需要一个主 key 那么简单,它还充当我们更新 Radicale 上联系人文件的唯一标识符。在后续更新 Radicale 上联系人时,增、删、改 API 调用时都要以这个 ID 作为文件名,这样才能避免联系人信息重复或者误删。
删除联系人
删除联系人也是类似的:
# 替换 yourname 和联系人 ID
curl -X DELETE \
https://vcards.yourdomain.com/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf \
-H "Authorization: Basic $(echo -n "yourname:yourpassword" | base64)"
而查询就简单多了,数据量不大的情况下,直接从数据库里读取数据就可以了。
query = query.where(
or(
sql`${vCards.fullName} ILIKE ${`%${search}%`}`,
sql`${vCards.organization} ILIKE ${`%${search}%`}`,
sql`${vCards.email} ILIKE ${`%${search}%`}`,
sql`${vCards.cellphone} ILIKE ${`%${search}%`}`
)
) as any;
这种部署方式相当于维护了两份数据,一份在数据库(例如 PostgreSQL)里,为了增删改查。另一份在 CardDAV 服务里,为了同步到 iOS 设备。
iOS 设备配置
配置好服务器后,需要在 iOS 设备上添加 CardDAV 账户才能同步通讯录。
添加 CardDAV 账户
- 打开 iPhone 的”设置”应用
- 找到”账户”->“添加账户”->选择”其他”
- 选择”添加 CardDAV 账户”
填写账户信息
在弹出的表单中填写以下信息:
- 服务器:
vcards.yourdomain.com(你的域名,不用带 https://) - 用户名:
yourname(你在 Radicale 中创建的用户名) - 密码:
yourpassword(你设置的密码) - 描述:
我的通讯录服务器(任意名称,方便识别)
点击”下一步”,系统会验证账户信息。验证成功后,点击”存储”完成配置。
使用体验
一段时间体验下来,非常方便,毕竟在电脑端管理联系人,比在手机上的小屏幕点来点去快捷太多了。
比较常用的联系人在逐步添加到网站后,多设备上同步非常便利。比如添加了新联系人(e.g., 快递小哥),改完之后,每个设备都会定时自动拉取最新数据,不用再自己手动逐个添加。
另外一个是头像配置丝滑多了,很多常用的联系人我都搭配了头像,这样来电时瞄一眼就知道是谁。 当然,很多图片都是网图,之前在手机上操作时需要先下载好图片,然后导到手机里,再去联系人那里修改。现在只需要在网站上更新一下图片链接,保存即可。后面设备上自动拉取完数据之后就是最新头像了。
通讯录里之前有很多冲突、重复的号码,一个联系人记了多次或者只有名字没有信息的,种种类似问题。改成中心化统一管理后,清爽了很多。
实在不想折腾怎么办?
当然,也不是每个人都愿意为了存个电话号码,特意去买台服务器、还去写代码。如果你只是想找个现成的、能用的方案,Google Contacts 其实是个不错的选择。
现在还在坚持原生支持 CardDAV 协议的大厂真的不多了,Google 算是一个。用法也简单:你在网页版上把联系人理顺,然后在手机设置里登录 Google 账号,勾选“通讯录同步”,就齐活了。
不过,既然把数据交给了别人,就得提前考虑清楚两件事:
- 众所周知,Google 有非常知名的“墓地”文化(Killed by Google),万一哪天它觉得这业务不赚钱,手起刀落,迁移数据又是一通麻烦。
- 二是国内这个网络环境,如果直连,数据能否成功同步全看运气,急用的时候如果拉取不到最新号码,也挺糟心。
最后碎碎念
说回自建这事儿。
可能有人会觉得,存个电话而已,至于这么大动干戈吗? 其实折腾到最后,早已不是为了“防骚扰电话”这么具体的功能了。更多的是一种数字生活的“洁癖”,和一种对数据的掌控欲。
在这个数据碎片化的时代,我们的社交关系被切碎在微信、抖音和小红书等各种 App 里。能亲手把属于自己的“人际关系网”从这些平台里收拢回来,洗干净,整整齐齐地码在自己的数据库里。
这种踏实感,可能才是每一个 Self-hosted 玩家最大的乐趣吧。


