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 LifeManage My Damn Life自托管DockerMySQLCalDAV2026/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:latest0.10.0(Next.js 15.5.25 / Node v20.10.0)81513000无状态
元数据库mysql:8.48.4.13不映射(仅内网 3306)3306./data/mysql
CalDAV 后端tomsquest/docker-radicale:3.7.6.0Radicale 3.7.681535232./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
BaikalPHP/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)
CalDAVtomsquest/docker-radicale:3.7.6.0社区主流 Radicale 镜像,固定具体版本

💡 技术拓展:MySQL 8.x 默认认证插件与旧插件对比——

插件特点现状
caching_sha2_passwordMySQL 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/mysqlMMDL 元数据(用户/会话/日历索引/加密后的 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是Kx9mT2vLp7qR4wZc8bNf1jHd3sGy6uXe0aQl5tYvCalDAV 密码加密主密钥;有数据后不可变更
ADDITIONAL_VALID_CALDAV_URL_LIST内网 CalDAV 时是["http://caldav:5232"]CalDAV URL 白名单(JSON 数组)
DOCKER_INSTALLDocker 场景是true允许 Web /install 触发数据库迁移
DISABLE_USER_REGISTRATION否falsetrue 时注册接口返回 401 CANT_CREATE_USER
MAX_CONCURRENT_LOGINS_ALLOWED否3单用户并发会话(ssid)上限
MAX_SESSION_LENGTH否2592000会话最长存活(秒,30 天)
MAX_OTP_VALIDITY否1800OTP 有效期(秒)
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:3000app容器内 Next.js 固定监听 3000(镜像 EXPOSE 3000),宿主映射端口自选
depends_on: [db]app仅控制启动顺序,不等待 MySQL 就绪;首次启动若 install 报连不上库,等 20-30 秒初始化完成再重试即可
restart: always三服务宿主重启/进程崩溃后自动拉起
env_fileapp注入 .env.local(应用配置与 compose 环境分离,便于版本管理)
environment: DB_HOST: dbapp与 env_file 叠加,显式固定数据库主机名为服务名
extra_hosts: host-gatewayapp容器内可经 host.docker.internal 访问宿主服务(如宿主跑 Nextcloud)
expose: 3306(不 ports)dbMySQL 不对宿主暴露端口,仅栈内可达,减少攻击面
volumes: ./data/mysqldb数据库持久化(官方样例缺失项,本文补齐)
volumes: ./radicale/config:/config:rocaldavRadicale 配置目录只读挂载,路径必须是 /config(见 11.3)

八、配置文件挂载

本栈三类配置文件的挂载与生效方式:

配置文件宿主路径容器路径修改后生效方式
应用环境变量./.env.localapp 进程环境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,按报错补齐即可。

十二、参考链接

Manage My Damn Life Docker Compose 部署指南 - Quzuo