Manage My Damn Life Docker Compose 部署指南
Ubuntu 24.04 上 Docker Compose 部署 Manage My Damn Life(MMDL)v0.10.0:MMDL+MySQL 8.4+Radicale CalDAV 三服务编排、Web 初始化、注册登录、CalDAV 读写端到端验证、数据持久化、等价 docker run、运维命令与 8 条实测排障实录(2026-11-02 全部实测)
Manage My Damn Life Docker Compose 部署指南
目标:在一台 Ubuntu 24.04 服务器上,用 Docker Compose 从零部署 Manage My Damn Life(MMDL)v0.10.0 的完整可用栈(MMDL 应用 + MySQL 元数据库 + Radicale CalDAV 后端),并完成安装初始化、首个用户注册、CalDAV 账号绑定与任务读写的全链路验证。本文全部命令与输出均在真实环境实测(2026-11-02,Ubuntu 24.04.5 + Docker 29.9.2 + Compose v5.6.1),排障实录完整收录于「常见问题」。软件背景与选型见 《Manage My Damn Life 介绍与选型指南》。
一、环境信息
本节给出本次部署实测宿主机与全部组件的版本、端口、路径清单,作为可复现基准。
# 宿主机与内核
grep -E '^(NAME|VERSION)=' /etc/os-release
uname -m && uname -r
# Docker 与 Compose
docker --version && docker compose version
实测回显:
NAME="Ubuntu"
VERSION="24.04.5 LTS (Noble Numbat)"
x86_64
7.0.0-38-generic
Docker version 29.9.2, build a82e528
Docker Compose version v5.6.1
栈内组件与端口规划:
| 组件 | 镜像 | 实测版本 | 宿主端口 | 容器端口 | 数据目录(宿主) |
|---|---|---|---|---|---|
| MMDL 应用 | intriin/mmdl:latest | 0.10.0(Next.js 15.5.25 / Node v20.10.0) | 8151 | 3000 | 无状态 |
| 元数据库 | mysql:8.4 | 8.4.13 | 不映射(仅内网 3306) | 3306 | ./data/mysql |
| CalDAV 后端 | tomsquest/docker-radicale:3.7.6.0 | Radicale 3.7.6 | 8153 | 5232 | ./data/radicale |
- 部署目录:
/opt/mmdl/stack(本文示例路径,实测环境为等价目录) - 自定义网络:
manage-my-damn-life-0100-stack_app-tier,子网10.89.56.0/24 - 演示账号:MMDL 用户
admin/q1w2e3r4(邮箱admin@example.com);MySQL 用户mmdl/q1w2e3r4(库名mmdl);Radicale 用户admin/q1w2e3r4
💡 说明:MMDL 自身不存储任务数据,任务全部落在 CalDAV 服务器上;MySQL 只存用户、CalDAV 账号(密码 AES 加密)、日历索引与同步 token。因此一个"能干活"的 MMDL 部署至少需要 MMDL + 数据库 + 一台 CalDAV 服务器三件套。官方 compose 样例只含前两件,本文把 Radicale 一并编排进来,方便从零验证端到端链路。
💡 技术拓展:CalDAV 服务器常见选择对比——
服务器 特点 适用场景 Radicale 单进程 Python,htpasswd 鉴权,文件存储,资源占用极低 个人/小团队纯 CalDAV Nextcloud + Tasks 全家桶(文件/日历/任务),CalDAV 只是其协议端 已自建 Nextcloud Baikal PHP/Symfony,轻量,带 Web 管理界面 小团队日历+通讯录 MMDL 经 tsdav 库兼容以上三者(官方 README 点名 Nextcloud/Radicale/Baikal)。本文采用 Radicale,理由:依赖最少、可用 htpasswd 文件直接演示账号创建。
二、镜像选型说明
MMDL 官方发布 Docker 镜像 intriin/mmdl(Docker Hub,由仓库 intri-in/manage-my-damn-life-nextjs 的 Dockerfile 构建)。该镜像只提供 latest 标签,没有版本标签,因此"选定版本"必须靠拉取后核验内部版本号,而不是靠 tag。
# 1) 拉取官方镜像
docker pull intriin/mmdl:latest
实测回显:
latest: Pulling from intriin/mmdl
Digest: sha256:37ac03af97c0412519df56a48942f7bbde181cca0291bbcac54d5048d9e81143
Status: Image is up to date for intriin/mmdl:latest
docker.io/intriin/mmdl:latest
# 2) 查看镜像基本配置
docker image inspect intriin/mmdl:latest \
--format '{{.Id}} | created={{.Created}} | config.User={{.Config.User}} | Config.Cmd={{.Config.Cmd}} | ExposedPorts={{.Config.ExposedPorts}}'
实测回显:
sha256:37ac03af97c0412519df56a48942f7bbde181cca0291bbcac54d5048d9e81143 | created=2026-09-13T06:01:24.51059826Z | config.User=nextjs:nodejs | Config.Cmd=[node server.js] | ExposedPorts=map[3000/tcp:map[]]
# 3) 核验镜像内部应用版本(关键步骤:latest 标签不可信,读 package.json)
docker run --rm --entrypoint cat intriin/mmdl:latest /app/package.json | head -5
实测回显:
{
"name": "manage-my-damn-life-nextjs",
"version": "0.10.0",
"private": true,
"scripts": {
# 4) 查看镜像体积
docker image ls intriin/mmdl
实测回显:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
intriin/mmdl:latest 37ac03af97c0 367MB 78.4MB U
解读:镜像构建于 2026-09-13,与官方 releases.atom 中 v0.10.0 的发布日期(2026-09-13)一致;容器内以非 root 用户 nextjs:nodejs 运行 node server.js,监听 3000 端口。
配套组件镜像选型:
| 用途 | 镜像 | 选型理由 |
|---|---|---|
| 元数据库 | mysql:8.4(实测 8.4.13) | 官方 compose 样例用 mysql(latest);固定到 8.4 LTS 避免大版本漂移。⚠️ 官方样例的 --default-authentication-plugin=mysql_native_password 启动参数在 8.4 上会直接崩溃,必须去掉(见「十一、常见问题」11.1) |
| CalDAV | tomsquest/docker-radicale:3.7.6.0 | 社区主流 Radicale 镜像,固定具体版本 |
💡 技术拓展:MySQL 8.x 默认认证插件与旧插件对比——
插件 特点 现状 caching_sha2_password MySQL 8.0+ 默认,SHA-256+缓存,性能与安全更好 MySQL 8.0 起默认;mysql2/Sequelize 直接支持 mysql_native_password 旧版兼容插件 8.0 保留、8.4 标记废弃、9.x 移除; --default-authentication-plugin参数在 8.4 已不是合法 mysqld 选项MMDL 经 Sequelize + mysql2 驱动连库,caching_sha2_password 完全可用,因此无需切回旧插件;本文方案不设置任何
command:认证参数。
三、前置条件
部署前确认三件事:Docker 引擎版本、目标端口空闲、CalDAV 后端账号就绪。
# 1) Docker/Compose 版本(要求 Docker 20.10+ / Compose v2+,实测 29.9.2 / v5.6.1)
docker --version && docker compose version
# 2) 端口占用检查:8151(MMDL Web)与 8153(Radicale)应无输出,即空闲
ss -tlnp | grep -E ':8151|:8153'
部署完成后同一命令的回显(用于对照"空闲→监听"的验证):
LISTEN 0.0.0.0:8151 users:(("docker-proxy",pid=1490947,fd=8))
LISTEN 0.0.0.0:8153 users:(("docker-proxy",pid=1477532,fd=8))
LISTEN [::]:8151 users:(("docker-proxy",pid=1490953,fd=8))
LISTEN [::]:8153 users:(("docker-proxy",pid=1477540,fd=8))
# 3) 磁盘余量(栈全部数据 < 1GB,预留 5GB 足够)
df -h /opt
⚠️ 注意:宿主机内核/内存无特殊要求(实测 15GiB 内存、x86_64)。MMDL 应用容器无状态,重启不丢数据;真正需要备份的是
./data/mysql与./data/radicale两个目录(见「五、数据持久化说明」)。
四、部署步骤
本节从零拉起到全链路可用:目录骨架 → 三份配置 → 启动 → 验证 → Web 初始化 → 注册登录 → 绑定 CalDAV。
4.1 准备目录骨架
mkdir -p /opt/mmdl/stack/{data/mysql,data/radicale,radicale/config}
cd /opt/mmdl/stack
目录约定:data/ 为运行期数据(bind mount 进容器),radicale/config/ 为 Radicale 的只读配置目录。
4.2 编写 docker-compose.yml
/opt/mmdl/stack/docker-compose.yml 全文(实测使用版本):
name: manage-my-damn-life-0.10.0-stack
networks:
app-tier:
driver: bridge
ipam:
config:
- subnet: 10.89.56.0/24
volumes:
mysql-data:
services:
app:
image: intriin/mmdl:latest
ports:
- 8151:3000
depends_on:
- db
networks:
- app-tier
restart: always
environment:
DB_HOST: db
env_file:
- .env.local
extra_hosts:
- "host.docker.internal:host-gateway"
db:
image: mysql:8.4
restart: always
expose:
- 3306
networks:
- app-tier
volumes:
- ./data/mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: mmdl
MYSQL_USER: mmdl
MYSQL_PASSWORD: q1w2e3r4
MYSQL_ROOT_PASSWORD: q1w2e3r4
MYSQL_ROOT_HOST: '%'
caldav:
image: tomsquest/docker-radicale:3.7.6.0
restart: always
ports:
- 8153:5232
networks:
- app-tier
volumes:
- ./radicale/config:/config:ro
- ./data/radicale:/data
⚠️ 注意:与官方
docker-compose.yml.sample相比,本文做了三处必要修正——①db服务删除command: --default-authentication-plugin=mysql_native_password(8.4 下该参数导致 mysqld 启动即崩,见 11.1);② 删除MYSQL_ALLOW_EMPTY_PASSWORD: ok(与MYSQL_ROOT_PASSWORD同时设置会让 mysql 8.x 初始化脚本报错);③ MySQL 镜像从latest固定到8.4。官方样例全文(原样,含坑):
networks:
app-tier:
driver: bridge
services:
app:
image: intriin/mmdl:latest
ports:
- 3000:3000
depends_on:
- db
networks:
- app-tier
restart: always
environment:
DB_HOST: db
env_file:
- .env.local
extra_hosts:
- "host.docker.internal:host-gateway"
db:
image: mysql
command: --default-authentication-plugin=mysql_native_password
restart: always
expose:
- 3306
networks:
- app-tier
environment:
MYSQL_DATABASE: sample_install_mmdm
MYSQL_USER: myuser
MYSQL_PASSWORD: mypassword
MYSQL_ROOT_PASSWORD: root
MYSQL_ALLOW_EMPTY_PASSWORD: ok
MYSQL_ROOT_HOST: '%'
4.3 编写应用环境变量 .env.local
/opt/mmdl/stack/.env.local(compose 经 env_file 注入 app 容器)。最小可用集如下,全参数行为级详解见本专栏《配置参数详解》篇:
NEXT_BASE_URL=http://localhost:8151/
DB_HOST=db
DB_PORT=3306
DB_USER=mmdl
DB_PASS=q1w2e3r4
DB_NAME=mmdl
DB_DIALECT=mysql
DB_CHARSET=utf8mb4
DB_COLLATE=utf8mb4_general_ci
AES_PASSWORD=Kx9mT2vLp7qR4wZc8bNf1jHd3sGy6uXe0aQl5tYv
ADDITIONAL_VALID_CALDAV_URL_LIST=["http://caldav:5232"]
DISABLE_USER_REGISTRATION=false
MAX_CONCURRENT_LOGINS_ALLOWED=3
MAX_OTP_VALIDITY=1800
MAX_SESSION_LENGTH=2592000
ENFORCE_SESSION_TIMEOUT=true
DOCKER_INSTALL=true
关键项解读:
NEXT_BASE_URL:应用对外访问根地址,须与浏览器实际访问的地址一致(本文http://localhost:8151/)。DB_*六项:Sequelize 连接参数,DB_DIALECT取mysql(另支持postgres、sqlite)。DB_HOST=db即 compose 服务名。AES_PASSWORD:加密存储 CalDAV 账号密码的主密钥。一旦有数据就不可再改,改错会导致所有已绑定的 CalDAV 账号解密失败(实测表现见 11.8 关联的配置篇)。ADDITIONAL_VALID_CALDAV_URL_LIST:允许绑定的 CalDAV 服务器 URL 白名单(JSON 数组)。内网地址默认被拒,必须显式加入。DOCKER_INSTALL=true:允许通过 Web/install初始化数据库迁移(Docker 场景官方推荐开启)。
4.4 编写 Radicale 配置与账号
/opt/mmdl/stack/radicale/config/config:
[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /config/users
[rights]
type = authenticated
[storage]
filesystem_folder = /data/collections
生成 htpasswd 账号文件(Apache md5crypt 格式):
printf 'admin:%s\n' "$(openssl passwd -apr1 q1w2e3r4)" > /opt/mmdl/stack/radicale/config/users
cat /opt/mmdl/stack/radicale/config/users
实测回显(每次执行 salt 随机,hash 值不同属正常):
admin:$apr1$Ag1dcx3B$6WL8TJa9AJq6s9lNiqEGi0
⚠️ 注意:
tomsquest/docker-radicale镜像默认读/config/config,若把配置挂到别的路径(如/app/config)会静默不生效,退化为镜像内置的type = none(不鉴权)配置——实测空密码 PROPFIND 直接返回 207,属于"看似正常实则裸奔"的隐蔽状态(见 11.3)。另外 Radicale 3.7 已移除htpasswd_algorithm选项,写了会启动即退(见 11.4)。
4.5 启动栈
cd /opt/mmdl/stack
docker compose up -d
实测回显(首次,含网络与三个服务):
Network manage-my-damn-life-0100-stack_app-tier Creating
Network manage-my-damn-life-0100-stack_app-tier Created
Container manage-my-damn-life-0100-stack-db-1 Creating
Container manage-my-damn-life-0100-stack-caldav-1 Creating
Container manage-my-damn-life-0100-stack-caldav-1 Created
Container manage-my-damn-life-0100-stack-db-1 Created
Container manage-my-damn-life-0100-stack-app-1 Creating
Container manage-my-damn-life-0100-stack-app-1 Created
Container manage-my-damn-life-0100-stack-caldav-1 Starting
Container manage-my-damn-life-0100-stack-db-1 Starting
Container manage-my-damn-life-0100-stack-caldav-1 Started
Container manage-my-damn-life-0100-stack-db-1 Started
Container manage-my-damn-life-0100-stack-app-1 Starting
Container manage-my-damn-life-0100-stack-app-1 Started
docker compose ps
实测回显:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
manage-my-damn-life-0100-stack-app-1 intriin/mmdl:latest "docker-entrypoint.s…" app 6 seconds ago Up 5 seconds 0.0.0.0:8151->3000/tcp, [::]:8151->3000/tcp
manage-my-damn-life-0100-stack-caldav-1 tomsquest/docker-radicale:3.7.6.0 "docker-entrypoint.s…" caldav 6 seconds ago Up 5 seconds (health: starting) 0.0.0.0:8153->5232/tcp, [::]:8153->5232/tcp
manage-my-damn-life-0100-stack-db-1 mysql:8.4 "docker-entrypoint.s…" db 6 seconds ago Up 5 seconds 3306/tcp, 33060/tcp
4.6 启动验证(日志与页面)
# MMDL 应用日志(Next.js 就绪标记)
docker compose logs app
实测回显:
app-1 | ▲ Next.js 15.5.25
app-1 | - Local: http://d047047adda1:3000
app-1 | - Network: http://d047047adda1:3000
app-1 |
app-1 | ✓ Starting...
app-1 | ✓ Ready in 194ms
# MySQL 首次初始化完成标记(全新数据目录约需 20-30 秒)
docker compose logs db | grep 'ready for connections'
实测回显:
db-1 | 2026-11-01T17:39:06.572002Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.13' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.
# Web 页面可达性
curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:8151/
curl -s http://localhost:8151/login | grep -oE '<title[^>]*>[^<]*</title>'
实测回显:
HTTP 200
<title data-next-head="">MMDL - Login</title>
💡 说明:本文页面验收口径为 HTTP 状态码 + HTML
<title>+ API 全链路 curl(实验环境无图形浏览器)。<title>标签带data-next-head属性,用grep '<title>'精确匹配会漏,需按上面写法匹配。
4.7 Web 初始化(数据库迁移)
MMDL 首次启动时数据库为空表结构,需触发安装流程(等价于浏览器访问 /install 点按钮,背后就是这两个接口):
# 1) 检查安装状态:未安装时返回 NOT_INSTALLED
curl -s http://localhost:8151/api/install/check
实测回显:
{"success":false,"data":{"message":"NOT_INSTALLED"}}
# 2) 触发安装(跑 Sequelize 迁移建表)
curl -s http://localhost:8151/api/install/go
实测回显:
{"success":true,"data":{"message":"INSTALL_OK","details":true}}
# 3) 复查:已安装
curl -s http://localhost:8151/api/install/check
实测回显:
{"success":true,"data":{"message":""}}
4.8 注册首个用户并登录
# 注册(email 必须是合法邮箱格式,否则被服务端校验拒绝)
curl -s -X POST http://localhost:8151/api/users/register \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"q1w2e3r4","email":"admin@example.com"}'
实测回显:
{"success":true,"data":{"message":"USER_INSERT_OK"}}
# 登录:返回 userhash + ssid 两个令牌,后续 API 鉴权用
curl -s -X POST http://localhost:8151/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"q1w2e3r4"}'
实测回显(节选,userhash/ssid 为 128 位十六进制串):
{"success":"true","data":{"message":{"userhash":"c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec","ssid":"afc1bfbf9eeb6784650b4131506af8e57f91b02ddc0dd29dff6d40420c33472abaa176eff374084e9e2b6d3d7593d04c92a31776217c1c62a74f2e5ebbbee460"}}}
⚠️ 注意:MMDL 的 API 鉴权头格式为
Authorization: Basic base64(userhash:ssid)——把登录返回的两个令牌用冒号拼接后整体 base64。直接把userhash:ssid明文塞进 Authorization 头会被拒(返回PLEASE_LOGIN,见 11.6)。生成方式:
AUTH="<userhash>:<ssid>" # 用登录响应里的实际值
B=$(printf '%s' "$AUTH" | base64 -w0)
4.9 绑定 CalDAV 账号并验证读写
# 绑定 CalDAV 服务器(URL 必须在 ADDITIONAL_VALID_CALDAV_URL_LIST 白名单内)
curl -s -X POST http://localhost:8151/api/v2/caldav/register \
-H "Authorization: Basic $B" -H 'Content-Type: application/json' \
-d '{"url":"http://caldav:5232","username":"admin","accountname":"My CalDAV","password":"q1w2e3r4","auth_method":"Basic"}'
实测回显(节选,保留首尾结构):
{"version":2,"success":true,"data":{"name":"My CalDAV","username":"admin","caldav_accounts_id":1,"url":"http://caldav:5232","calendars":[{"displayName":"admin/calendars","url":"http://caldav:5232/admin/calendars/","ctag":"\"6ac82e7319fa592d19a84c183456bb814b40d37c7df2388fce2cd26096e32ae2\"","description":"","calendarColor":"","syncToken":"http://radicale.org/ns/sync/1c6595839c1fd73dff2a5c96ed3f4f74e295f012d44a2ebb4e6df1995b7f947a","resourcetype":"calendar,collection","timezone":""}],"authMethod":"Basic"}}
回显中的 caldav_accounts_id=1 与日历的 ctag/syncToken/url 是后续事件读写接口的必需参数。
# 刷新日历索引
curl -s -X GET "http://localhost:8151/api/v2/calendars/refresh" -H "Authorization: Basic $B"
实测回显(节选):
{"version":2,"success":true,"data":{"message":"REFRESH_OK","details":[{"username":"admin","url":"http://caldav:5232","name":"My CalDAV","status":"ok", ... ,"caldav_accounts_id":1,"calendars":[{"displayName":"admin/calendars","url":"http://caldav:5232/admin/calendars/", ... ]}]}}
# 创建一条任务(VTODO)。注意 calendar_id 必须传日历对象(url/ctag/syncToken),
# 漏传会导致服务端未处理异常、请求挂起(见 11.8)
curl -s -X POST "http://localhost:8151/api/v2/calendars/events/add" \
-H "Authorization: Basic $B" -H 'Content-Type: application/json' \
-d @addbody.json
addbody.json 实测请求体:
{
"etag": "b2testetag0002",
"data": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mmdl//EN\r\nBEGIN:VTODO\r\nUID:mmdl-created-0002\r\nSUMMARY:Buy groceries\r\nDUE;VALUE=DATE:20261105\r\nSTATUS:NEEDS-ACTION\r\nPRIORITY:1\r\nEND:VTODO\r\nEND:VCALENDAR",
"updated": 1762047600,
"type": "VTODO",
"caldav_accounts_id": 1,
"calendar_id": {"url": "http://caldav:5232/admin/calendars/", "ctag": "6ac82e7319fa592d19a84c183456bb814b40d37c7df2388fce2cd26096e32ae2", "syncToken": "http://radicale.org/ns/sync/1c6595839c1fd73dff2a5c96ed3f4f74e295f012d44a2ebb4e6df1995b7f947a"},
"ctag": "6ac82e7319fa592d19a84c183456bb814b40d37c7df2388fce2cd26096e32ae2",
"syncToken": "http://radicale.org/ns/sync/1c6595839c1fd73dff2a5c96ed3f4f74e295f012d44a2ebb4e6df1995b7f947a",
"url": "http://caldav:5232/admin/calendars/",
"fileName": "mmdl-created-0002.ics"
}
实测回显:
{"success":true,"data":{"message":{},"details":{"url":"http://caldav:5232/admin/calendars/mmdl-created-0002.ics","etag":"\"8f1a63c43a67470b5445b0db54141b61f2a4d24a8b1b9bc6602445152ba6c9e1\"","data":"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mmdl//EN\r\nBEGIN:VTODO\r\nDTSTAMP:20261101T174643Z\r\nDUE;VALUE=DATE:20261105\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\nSUMMARY:Buy groceries\r\nUID:mmdl-created-0002\r\nEND:VTODO\r\nEND:VCALENDAR"}}}
# 端到端验证:直接从 Radicale 拉取该任务文件
curl -s -u admin:q1w2e3r4 http://localhost:8153/admin/calendars/mmdl-created-0002.ics
实测回显:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//mmdl//EN
BEGIN:VTODO
DTSTAMP:20261101T174643Z
DUE;VALUE=DATE:20261105
PRIORITY:1
STATUS:NEEDS-ACTION
SUMMARY:Buy groceries
UID:mmdl-created-0002
END:VTODO
END:VCALENDAR
# 反向验证:经 MMDL 接口拉取日历全部事件(add 之后执行,应含新建任务)
curl -s -X GET "http://localhost:8151/api/v2/calendars/events/fetch?caldav_accounts_id=1&ctag=6ac82e7319fa592d19a84c183456bb814b40d37c7df2388fce2cd26096e32ae2&syncToken=http://radicale.org/ns/sync/1c6595839c1fd73dff2a5c96ed3f4f74e295f012d44a2ebb4e6df1995b7f947a&url=http%3A%2F%2Fcaldav%3A5232%2Fadmin%2Fcalendars%2F" \
-H "Authorization: Basic $B" | grep -o 'SUMMARY:[^\\]*'
实测回显(三条:历史 seed 任务、此前实测创建的 0001、本次新建的 0002):
SUMMARY:seed task
SUMMARY:Buy groceries
SUMMARY:Buy groceries
至此部署完成:浏览器访问 http://<服务器>:8151/ 用 admin / q1w2e3r4 登录即可使用。
五、数据持久化说明
本栈有两处必须持久化的数据,均采用 bind mount 落在宿主 ./data/ 下:
| 数据 | 宿主路径 | 容器路径 | 内容 |
|---|---|---|---|
| MySQL | ./data/mysql | /var/lib/mysql | MMDL 元数据(用户/会话/日历索引/加密后的 CalDAV 密码) |
| Radicale | ./data/radicale | /data | 任务/日历 ICS 文件集合(业务数据本体) |
# 查看 MySQL 库表(16 张,由 Sequelize 迁移创建)
docker compose exec db mysql -ummdl -pq1w2e3r4 -e "SHOW TABLES;" mmdl
实测回显:
Tables_in_mmdl
SequelizeMeta
accounts
caldav_accounts
calendar_events
calendars
custom_filters
labels
otp_table
sessions
settings
ssid_table
templates
users
verification_tokens
webcal_accounts
webcal_events
持久化验证(重启应用容器后数据仍在):
docker compose restart app
# 重新登录 + 拉取事件
curl -s -X POST http://localhost:8151/api/login -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"q1w2e3r4"}' | head -c 120
实测回显(节选):
{"success":"true","data":{"message":{"userhash":"c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd472634dfac71cd34ebc35d16ab7fb8a90c81f975113d6c7538dc69dd8de9077ec","ssid":"30e142472d0a0
重启后 events/fetch 仍返回全部三条任务(seed task 与两条 Buy groceries),确认应用层无状态、数据全部在挂载目录。
⚠️ 注意:
docker compose down不会删除 bind mount 数据,但docker compose down -v会删除命名卷;本栈的./data/是宿主目录,任何 down 变体都不丢,备份直接打包这两个目录即可。官方样例未给 MySQL 配置任何持久化卷——照抄官方样例重启即丢库,本文已补上,这是必须修正的第三个坑。
六、环境变量表
app 容器经 env_file: .env.local 注入。必需项与常用可选项如下(行为级实测详解见本专栏《配置参数详解》篇):
| 变量 | 必需 | 本文取值 | 说明 |
|---|---|---|---|
NEXT_BASE_URL | 是 | http://localhost:8151/ | 应用对外根地址,与浏览器访问地址一致 |
DB_HOST | 是 | db | 数据库主机(compose 服务名;也可在 compose environment: 中覆盖) |
DB_PORT | 是 | 3306 | 数据库端口 |
DB_USER / DB_PASS | 是 | mmdl / q1w2e3r4 | 数据库账号,须与 db 服务的 MYSQL_USER/MYSQL_PASSWORD 一致 |
DB_NAME | 是 | mmdl | 数据库名,须与 MYSQL_DATABASE 一致 |
DB_DIALECT | 是 | mysql | 三方言:mysql / postgres / sqlite |
AES_PASSWORD | 是 | Kx9mT2vLp7qR4wZc8bNf1jHd3sGy6uXe0aQl5tYv | CalDAV 密码加密主密钥;有数据后不可变更 |
ADDITIONAL_VALID_CALDAV_URL_LIST | 内网 CalDAV 时是 | ["http://caldav:5232"] | CalDAV URL 白名单(JSON 数组) |
DOCKER_INSTALL | Docker 场景是 | true | 允许 Web /install 触发数据库迁移 |
DISABLE_USER_REGISTRATION | 否 | false | true 时注册接口返回 401 CANT_CREATE_USER |
MAX_CONCURRENT_LOGINS_ALLOWED | 否 | 3 | 单用户并发会话(ssid)上限 |
MAX_SESSION_LENGTH | 否 | 2592000 | 会话最长存活(秒,30 天) |
MAX_OTP_VALIDITY | 否 | 1800 | OTP 有效期(秒) |
ENFORCE_SESSION_TIMEOUT | 否 | true | 是否强制会话超时 |
SMTP_* | 否 | (留空) | 邮件通知,不用可不设 |
USE_NEXT_AUTH | 否 | false | 启用 OAuth(Keycloak/Google/Authentik)时置 true |
💡 说明:
DB_CHARSET/DB_COLLATE在 v0.10.0 的运行时配置加载器(config/config.js)中并不消费,仅历史建表 SQL 拼接使用,新部署可不设。
七、compose 配置参数说明
逐项解释本文 compose 中非显而易见的配置:
| 配置项 | 位置 | 作用与取值理由 |
|---|---|---|
name: | 顶层 | 显式项目名 manage-my-damn-life-0.10.0-stack,容器/网络前缀固定,便于多版本共存时识别 |
networks.app-tier.ipam | 顶层 | 显式子网 10.89.56.0/24,避免与宿主既有网段冲突(部署前用 ip route 确认空闲) |
ports: 8151:3000 | app | 容器内 Next.js 固定监听 3000(镜像 EXPOSE 3000),宿主映射端口自选 |
depends_on: [db] | app | 仅控制启动顺序,不等待 MySQL 就绪;首次启动若 install 报连不上库,等 20-30 秒初始化完成再重试即可 |
restart: always | 三服务 | 宿主重启/进程崩溃后自动拉起 |
env_file | app | 注入 .env.local(应用配置与 compose 环境分离,便于版本管理) |
environment: DB_HOST: db | app | 与 env_file 叠加,显式固定数据库主机名为服务名 |
extra_hosts: host-gateway | app | 容器内可经 host.docker.internal 访问宿主服务(如宿主跑 Nextcloud) |
expose: 3306(不 ports) | db | MySQL 不对宿主暴露端口,仅栈内可达,减少攻击面 |
volumes: ./data/mysql | db | 数据库持久化(官方样例缺失项,本文补齐) |
volumes: ./radicale/config:/config:ro | caldav | Radicale 配置目录只读挂载,路径必须是 /config(见 11.3) |
八、配置文件挂载
本栈三类配置文件的挂载与生效方式:
| 配置文件 | 宿主路径 | 容器路径 | 修改后生效方式 |
|---|---|---|---|
| 应用环境变量 | ./.env.local | app 进程环境 | docker compose up -d app(重建容器;restart 不会重读 env_file) |
| Radicale 主配置 | ./radicale/config/config | /config/config(ro) | docker compose restart caldav |
| Radicale 账号 | ./radicale/config/users | /config/users(ro) | 无需重启,下次请求即生效 |
⚠️ 注意:
docker compose restart只重启容器进程,不会重新读取env_file;改了.env.local必须执行docker compose up -d app让 compose 重建容器。这是实测中容易踩的操作区别。
九、等价 docker run
不用 compose、手工用 docker run 起应用容器的等价命令(前提:网络 manage-my-damn-life-0100-stack_app-tier 与 MySQL 已就绪),实测:
docker run -d --name mmdl-runtest \
--network manage-my-damn-life-0100-stack_app-tier \
-p 8157:3000 \
--env-file /opt/mmdl/stack/.env.local \
-e DB_HOST=db \
--restart unless-stopped \
intriin/mmdl:latest
实测回显(返回容器 ID):
261f8d1a5c30c3fca784851fa1f690cfc51a5e4182f5603cf7620a11db18835f
# 验证:新容器共享同一 MySQL,安装状态一致
curl -s http://localhost:8157/api/install/check
实测回显:
{"success":true,"data":{"message":""}}
# 验证完毕删除测试容器
docker rm -f mmdl-runtest
实测回显:
mmdl-runtest
💡 说明:
--env-file只能注入 KEY=VALUE 且不支持行内注释/引号展开差异,复杂值(如 JSON 数组白名单)建议仍走 compose。生产环境推荐 compose:依赖顺序、网络、挂载一处声明。
十、常用运维命令
以下命令均在 /opt/mmdl/stack 目录实测。
# 查看栈状态
docker compose ps
实测回显:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
manage-my-damn-life-0100-stack-app-1 intriin/mmdl:latest "docker-entrypoint.s…" app 8 minutes ago Up Less than a second 0.0.0.0:8151->3000/tcp, [::]:8151->3000/tcp
# 重启应用(配置改动后用 up -d 重建而非 restart,见第八节)
docker compose restart app
实测回显:
Container manage-my-damn-life-0100-stack-app-1 Restarting
Container manage-my-damn-life-0100-stack-app-1 Started
# 停止/单独拉起应用
docker compose stop app && docker compose start app
实测回显:
Container manage-my-damn-life-0100-stack-app-1 Stopping
Container manage-my-damn-life-0100-stack-app-1 Stopped
Container manage-my-damn-life-0100-stack-app-1 Starting
Container manage-my-damn-life-0100-stack-app-1 Started
# 查看最近日志(排障第一步;持续跟踪用 logs -f app)
docker compose logs --tail 5 app
实测回显(末行为一次事件接口调用的运行时日志):
app-1 | - Network: http://d047047adda1:3000
app-1 |
app-1 | ✓ Starting...
app-1 | ✓ Ready in 195ms
app-1 | checkIfCalendarURLinCaldavAccount_GetID calendar_id 1
# 进入应用容器查版本(Node/npm)
docker compose exec app node -v && docker compose exec app npm -v
实测回显:
v20.10.0
10.2.3
# 查库(SHOW TABLES 等)
docker compose exec db mysql -ummdl -pq1w2e3r4 mmdl -e "SELECT COUNT(*) FROM users;"
实测回显:
COUNT(*)
1
# 全栈停止(数据保留)/ 彻底移除
docker compose down
实测回显:
Container manage-my-damn-life-0100-stack-app-1 Stopping
Container manage-my-damn-life-0100-stack-caldav-1 Stopping
Container manage-my-damn-life-0100-stack-app-1 Stopped
Container manage-my-damn-life-0100-stack-app-1 Removing
Container manage-my-damn-life-0100-stack-caldav-1 Stopped
Container manage-my-damn-life-0100-stack-caldav-1 Removing
Container manage-my-damn-life-0100-stack-app-1 Removed
Container manage-my-damn-life-0100-stack-db-1 Stopping
Container manage-my-damn-life-0100-stack-caldav-1 Removed
Container manage-my-damn-life-0100-stack-db-1 Stopped
Container manage-my-damn-life-0100-stack-db-1 Removing
Container manage-my-damn-life-0100-stack-db-1 Removed
Network manage-my-damn-life-0100-stack_app-tier Removing
Network manage-my-damn-life-0100-stack_app-tier Removed
升级流程说明:官方镜像仅有 latest 标签,升级即 docker pull intriin/mmdl:latest 重拉后 docker compose up -d app 重建应用容器;升级前务必按第二节方法核验新镜像 package.json 版本号,并备份 ./data/mysql 与 ./data/radicale 两个目录。
十一、常见问题
本节 8 条均为本次部署真实踩坑实录(错误现象 → 定位 → 解决)。
11.1 MySQL 8.4 启动即崩:unknown variable
现象:照抄官方样例(含 command: --default-authentication-plugin=mysql_native_password)启动后,db 容器反复重启,日志:
[ERROR] [MY-000067] [Server] unknown variable 'default-authentication-plugin=mysql_native_password'
随后 Aborting → 容器进入 Restarting 循环。
定位:MySQL 8.4 已移除该服务器选项(对应插件机制废弃)。官方样例写作旧版 MySQL 时代,未跟进 8.4。
解决:删除 db 服务的整行 command:。MMDL 的 Sequelize/mysql2 驱动对默认 caching_sha2_password 完全兼容,实测注册、登录、迁移全部正常。
11.2 Table 'mysql.user' doesn't exist:失败初始化残留
现象:11.1 崩溃前 MySQL 已写入部分数据目录;修正配置重新启动后,db 日志报:
Table 'mysql.user' doesn't exist
定位:MySQL entrypoint 检测到数据目录非空即跳过初始化,但残留的是半初始化脏数据。
解决:docker compose down 后 rm -rf ./data/mysql,再 docker compose up -d 全新初始化。规则:MySQL 首次初始化一旦失败,必须清空数据目录重来。
11.3 Radicale 配置挂载路径错误 → 鉴权静默失效
现象:按直觉把配置挂到 /app/config,启动正常,但空密码的 PROPFIND 也返回 207 Multi-Status——htpasswd 鉴权根本没生效。
定位:tomsquest/docker-radicale 镜像内置配置在 /config/config,且默认 type = none(不鉴权)。挂错路径等于没挂。
解决:挂载改为 ./radicale/config:/config:ro。修正后未带凭据的请求返回 401,Successful login: 'admin' (htpasswd) 出现在日志中。验证命令:
curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X PROPFIND http://localhost:8153/
11.4 Radicale 3.7 启动报 Invalid option 'htpasswd_algorithm'
现象:
[CRITICAL] Invalid configuration: Invalid option 'htpasswd_algorithm' of section 'auth'
定位:网上大量旧教程带 htpasswd_algorithm = bcrypt 行,Radicale 3.7 已移除该选项(只保留默认 md5crypt/apr1)。
解决:删除该行,用 openssl passwd -apr1 生成 md5crypt 哈希(见 4.4)。bcrypt 哈希配 type = htpasswd 在 3.7 不再受支持。
11.5 Radicale 建日历:400 与 409 两个坑
现象:为新用户初始化日历集合时,PUT 空 VCALENDAR(仅含 VTIMEZONE)返回 400 at least one component is needed;直接 PUT 子集合 PUT /admin/calendars/work.ics/ 返回 409。
定位:① Radicale 要求集合体至少含一个 VTODO/VEVENT 组件;② 父集合 /admin/calendars/ 不存在时不允许创建其下资源。
解决:先建父集合(无 body 的 PUT 集合路径,返回 201),再放对象。MMDL 场景下日历集合由 CalDAV 客户端(或本文 4.4 后手工初始化)创建,MMDL 的 caldav/register 只做发现与索引,不代建集合。
11.6 API 返回 PLEASE_LOGIN:鉴权头格式错误
现象:登录成功后按直觉把 Authorization: <userhash>:<ssid> 直接带上,所有接口返回:
{"success":false,"data":{"message":"PLEASE_LOGIN"}}
定位:读源码 getUserHashSSIDfromAuthorisation:服务端要求标准 Basic 方案——base64(userhash:ssid),且只解析 Basic 前缀后的内容。
解决:
B=$(printf '%s' "<userhash>:<ssid>" | base64 -w0)
curl -s -X GET http://localhost:8151/api/install/check -H "Authorization: Basic $B"
11.7 旧令牌突然失效:并发登录上限清理
现象:脚本里连续多次登录后,最早拿到的 ssid 调接口报 PLEASE_LOGIN。
定位:MAX_CONCURRENT_LOGINS_ALLOWED=3(默认)时,服务端在每次新登录时先查该用户 ssid 列表、超限则删除最旧的一条。测试脚本频繁重登会滚动淘汰旧令牌,属设计行为非 bug。
解决:脚本化操作时复用同一令牌;确需多端并发则调大该变量(详解见配置篇)。
11.8 events/add 请求挂起:calendar_id 漏传
现象:POST /api/v2/calendars/events/add 迟迟不返回,客户端超时(HTTP_CODE=000);app 日志出现未处理异常:
url http://caldav:5232/admin/calendars/mmdl-created-0002.ics
TypeError: Cannot read properties of undefined (reading 'url')
⨯ unhandledRejection: TypeError: Cannot read properties of undefined (reading 'url')
定位:读源码 add.ts 第 45 行:请求体中的 calendar_id 会被直接传给 tsdav 的 createCalendarObject 作为 calendar 参数。它不是数字 ID,而必须是日历对象 {url, ctag, syncToken}(取自 caldav/register 响应)。漏传时服务端在 Promise 链外抛 TypeError,HTTP 响应永远不发出。
解决:请求体补上 calendar_id 对象(见 4.9 的 addbody.json),立即返回 success:true 与新事件 etag。同理 fetch 接口缺 caldav_accounts_id/ctag/syncToken/url 任一 query 参数时返回 422 INVALID_INPUT,按报错补齐即可。
十二、参考链接
- 官方仓库:https://github.com/intri-in/manage-my-damn-life-nextjs
- 官方 Docker 安装文档:仓库
docs/install/docker.md与docker-compose.yml.sample - 官方配置文档:仓库
docs/install/Configuration.md - Docker Hub 镜像:https://hub.docker.com/r/intriin/mmdl
- Radicale 镜像与文档:https://github.com/tomsquest/docker-radicale、https://radicale.org/
- MySQL 8.4 发布说明(认证插件变更):https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html
- 站内:《Manage My Damn Life 介绍与选型指南》、配置参数详解与 CLI 详解见本专栏其余篇目