solidtime v0.21.0 Docker Compose 部署指南
在真实 Linux 服务器以 Docker Compose 部署 solidtime v0.21.0 的全流程实测:镜像获取八条路线排障、containerd 拉取方案、密钥生成、数据库迁移、管理员创建与 Web 验证,含 401 死循环事故复盘。
solidtime v0.21.0 Docker Compose 部署指南
目标:在一台 Linux 服务器上以 Docker Compose 完整部署 solidtime v0.21.0 自托管时间追踪系统,覆盖镜像获取、环境文件、密钥生成、数据库迁移、管理员创建与 Web 验证全流程。所有命令与输出均为 2026-10-24 在真实服务器上的实测记录(含失败过程与排障结论),非文档摘抄。选型背景与架构认知见《solidtime v0.21.0 介绍与选型指南》。
一、部署目标与环境
1.1 环境信息
| 项 | 值 |
|---|---|
| 主机系统 | Linux x86_64(Docker 宿主) |
| Docker | 29.8.1(containerd 镜像存储已启用) |
| solidtime 镜像 | solidtime/solidtime:0.21.0(Docker Hub 官方镜像,amd64,解压后 1.89GB) |
| HTTP 引擎 | FrankenPHP(镜像内置 Octane 三引擎之一,默认值) |
| 数据库 | PostgreSQL 16(官方唯一支持的数据库,见选型篇 3.1) |
| PDF 渲染 | gotenberg/gotenberg:8.37(报表 PDF 导出依赖) |
| 应用端口 | 宿主 8137 → 容器 8000(8137 取自 8128-8152 区间,ss -ltn 实测未占用) |
| Compose 项目名 | solidtime-v0.21.0-stack(compose 文件显式 name: 声明) |
| 部署目录 | /opt/data/quzuo/tmp/solidtime/stack/(docker-compose.yml + laravel.env 双文件) |
⚠️ 提醒:solidtime 官方镜像不处理 TLS,设计上置于 Nginx/Traefik 等反向代理之后。本篇在可信内网以 HTTP 直连方式完成部署验证(对应官方示例仓库「Docker + Database」形态),生产环境请补反向代理并按配置篇设置
APP_FORCE_HTTPS与TRUSTED_PROXIES。
1.2 最终形态
一次 docker compose up -d 拉起 5 个服务,同一镜像通过 CONTAINER_MODE 拆分角色(实测镜像入口脚本支持 http / scheduler / worker / horizon / reverb / ssr 六种模式,本篇用前三种):
| 服务 | 镜像 | CONTAINER_MODE | 职责 |
|---|---|---|---|
| app | solidtime/solidtime:0.21.0 | http | Octane HTTP 服务器(FrankenPHP),监听 8000 |
| scheduler | solidtime/solidtime:0.21.0 | scheduler | 定时任务(每分钟跑 schedule:run,负责提醒等) |
| worker | solidtime/solidtime:0.21.0 | worker | 队列消费者(QUEUE_CONNECTION=database 时必需) |
| database | postgres:16-alpine | — | 数据库 |
| gotenberg | gotenberg/gotenberg:8.37 | — | PDF 渲染(HTML→PDF) |
二、镜像获取实录
2.1 先确认正确的镜像引用
solidtime 官方镜像发布在 Docker Hub:solidtime/solidtime(官方部署文档明确指向 hub.docker.com/r/solidtime/solidtime)。GitHub Container Registry 上没有这个仓库——本篇实测 ghcr.nju.edu.cn/solidtime-io/solidtime:0.21.0 返回 manifest unknown(南大镜像是对 ghcr.io 的透传,等于替你确认了 ghcr 侧不存在),网上一些教程写的 ghcr.io/solidtime-io/solidtime 是无效引用。
2.2 本服务器为什么 docker pull 全灭(根因定位)
一条命令在能直连 Docker Hub 的环境即可完成,但本服务器 daemon 配置的代理已失效:
docker pull solidtime/solidtime:0.21.0
# 实测失败输出:
# Error response from daemon: Head "https://docker.1ms.run/v2/solidtime/solidtime/manifests/0.21.0?ns=docker.io":
# proxyconnect tcp: dial tcp 192.168.146.1:7897: i/o timeout
docker info 显示 daemon 的 HTTP/HTTPS 代理指向 192.168.146.1:7897,该地址已无法连通(curl -x 实测超时)。daemon 的出网请求全部经过这个死代理,因此所有 docker pull(包括走加速器的)都会先在代理处失败。系统级代理配置在 systemd drop-in 中,修改需要重启 daemon(会波及宿主上其他在运行的服务,不可取),因此换不经 daemon 网络配置的路线。
2.3 路线实测记录(每条失败都查了响应体)
| # | 路线 | 实测结果 |
|---|---|---|
| 1 | ghcr.io/solidtime-io/solidtime:0.21.0(skopeo inspect) | token 端点 401 {"errors":[{"code":"UNAUTHORIZED"}]},实际该仓库在 ghcr 不存在 |
| 2 | ghcr.nju.edu.cn/solidtime-io/solidtime:0.21.0 | manifest unknown(透传证明 ghcr 无此仓库,路线 1/2 终结) |
| 3 | dockerproxy.net | connect: connection refused(服务已失效) |
| 4 | docker.1panel.live | TLS handshake timeout |
| 5 | docker.nju.edu.cn | 403 Forbidden(仅限校园网) |
| 6 | mirror.iscas.ac.cn | 404 |
| 7 | docker.1ms.run + skopeo copy ×2 | 连通但单流吞吐在共享带宽竞争下劣化(均值约 745KB/s→停滞),两次 600 秒硬超时 |
| 8 | docker.1ms.run + ctr(containerd) | 成功:96.2 秒拉完 347.6MiB,3.6MiB/s |
2.4 最终可用方案:containerd 原生拉取
前提:Docker 29.x 已启用 containerd 镜像存储(docker info 中 Storage Driver 显示 driver-type: io.containerd.snapshotter.v1 即是)。containerd 的网络出栈不带 daemon 的代理环境变量,直连加速器;且原生按层并行下载,吞吐远高于单流工具:
ctr -n moby images pull --platform linux/amd64 docker.1ms.run/solidtime/solidtime:0.21.0
# 实测输出(节选):
# Completed pull from OCI Registry (docker.1ms.run/solidtime/solidtime:0.21.0)
# elapsed: 96.2s total: 347.6 (3.6 MiB/s)
-n moby 是 Docker 所用的 containerd 命名空间,拉完即对 docker 可见。重打业务标签:
docker tag docker.1ms.run/solidtime/solidtime:0.21.0 solidtime/solidtime:0.21.0
docker images solidtime/solidtime
# 实测输出:
# REPOSITORY TAG IMAGE ID SIZE
# solidtime/solidtime 0.21.0 30ab1ede2a28 1.89GB
💡 经验:加速器(registry-mirror)域名必须显式写进拉取引用(
docker.1ms.run/solidtime/solidtime:0.21.0),containerd 不会读取 Docker 的 registry-mirrors 配置。PostgreSQL 与 Gotenberg 用同法准备(本机已备有postgres:16-alpine与gotenberg/gotenberg:8.37)。
2.5 同批真实事故教训:匿名 token 过期导致的 401 死循环
本篇发布前,同批次曾发生一次持续 4 小时的拉取事故,值得记录:
- 现象:自写分片并发下载脚本对同一加速器连续重试,日志显示 166 轮 × 8 分片全部返回 401,零进展空转。
- 根因:该加速器的匿名拉取 token 有效期仅约 25 分钟(实测响应字段
expires_in: 1500秒)。脚本拿一次 token 反复使用,过期后所有 blob 请求返回{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]};而脚本不校验响应内容,只看 HTTP 断码与文件大小,于是无限重试。 - 教训(可直接当规程用):
- 任何一次下载失败必须先读响应体再决定下一步——401/403/404 是「路线死了」的信号,不是「再试一次」的信号;
- 匿名 token 过期先刷新 token,刷新后仍 401 立即换路线;
- 下载必须有硬超时与总尝试上限(本篇执行的是单路线 ≤3 次、单次 10 分钟封顶);
- 共享加速器环境下自写并发分片脚本会与他人的拉取互相拖慢,标准工具的原生并行(ctr 按层并发)反而更快。
三、编写 Compose 编排文件
3.1 目录规划
/opt/data/quzuo/tmp/solidtime/stack/
├── docker-compose.yml # compose 编排
└── laravel.env # solidtime 应用环境变量(与 compose .env 分离的双文件形态)
3.2 docker-compose.yml 全量内容(实测可用)
name: solidtime-v0.21.0-stack
networks:
solidtime-v021-net:
external: true
volumes:
database:
services:
app:
image: solidtime/solidtime:0.21.0
container_name: solidtime-v0.21.0-app
networks:
- solidtime-v021-net
ports:
- "8137:8000"
env_file:
- laravel.env
environment:
CONTAINER_MODE: "http"
depends_on:
- database
- gotenberg
restart: unless-stopped
scheduler:
image: solidtime/solidtime:0.21.0
container_name: solidtime-v0.21.0-scheduler
networks:
- solidtime-v021-net
env_file:
- laravel.env
environment:
CONTAINER_MODE: "scheduler"
depends_on:
- database
restart: unless-stopped
worker:
image: solidtime/solidtime:0.21.0
container_name: solidtime-v0.21.0-worker
networks:
- solidtime-v021-net
env_file:
- laravel.env
environment:
CONTAINER_MODE: "worker"
WORKER_COMMAND: "php artisan queue:work"
depends_on:
- database
restart: unless-stopped
database:
image: postgres:16-alpine
container_name: solidtime-v0.21.0-database
networks:
- solidtime-v021-net
environment:
POSTGRES_USER: "solidtime"
POSTGRES_PASSWORD: "q1w2e3r4"
POSTGRES_DB: "solidtime"
volumes:
- database:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solidtime -d solidtime"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
gotenberg:
image: gotenberg/gotenberg:8.37
container_name: solidtime-v0.21.0-gotenberg
networks:
- solidtime-v021-net
restart: unless-stopped
要点说明(均为实测结论):
name: solidtime-v0.21.0-stack显式声明项目名,避免目录名干扰(compose 内部会把点号归一化为solidtime-v0210-stack,数据卷名随之,属正常现象);- 网络
solidtime-v021-net为外部预建网络(external: true),全新环境可删去此段让 compose 自建; - worker 必须显式传
WORKER_COMMAND——实测镜像入口脚本在 worker 模式下检查该变量,缺失即退出(见 9.4 排障),这是 v0.21.0 镜像的硬性行为,官方容器模式文档未提; - HTTP 引擎由
OCTANE_SERVER决定,镜像默认frankenphp(可改 swoole/roadrunner),无需手动设置; database带pg_isready健康检查,为观察 healthy 与排障提供依据;- app 端口映射
8137:8000——8000 是镜像固定服务端口,宿主侧端口按需选择(本篇经ss -ltn核实 8128-8152 区间后取 8137)。
3.3 laravel.env 初稿(密钥生成前)
APP_ENV=production
APP_DEBUG=false
APP_URL=http://localhost:8137
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=off
TRUSTED_PROXIES=*
TRUSTED_HOSTS="localhost,127.0.0.1"
APP_KEY=
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
SUPER_ADMINS="admin@solidtime.local"
LOG_CHANNEL=stderr
LOG_LEVEL=debug
DB_HOST=database
DB_PORT=5432
DB_SSL_MODE=prefer
DB_DATABASE=solidtime
DB_USERNAME=solidtime
DB_PASSWORD=q1w2e3r4
MAIL_MAILER=log
QUEUE_CONNECTION=database
FILESYSTEM_DISK=local
PUBLIC_FILESYSTEM_DISK=public
GOTENBERG_URL=http://gotenberg:3000
API_RATE_LIMIT_AUTH_PER_MINUTE=200
API_RATE_LIMIT_GUEST_PER_MINUTE=60
参数逐项解释在《solidtime v0.21.0 配置参数详解》展开,此处先按可运行最小集填写。两个说明:APP_ENABLE_REGISTRATION=off 关闭自助注册(生产推荐,用户走 CLI/超管面板创建);MAIL_MAILER=log 把出站邮件写入日志(验证部署用,验证邮件链接可直接从日志拷贝;生产改 SMTP,见配置篇)。
四、生成应用密钥
启动前必须生成三个密钥:APP_KEY(会话加密)、PASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEY(OAuth 服务器密钥对,API Token 认证依赖它)。官方提供一键生成命令,在 scheduler 服务上执行(该命令依赖镜像但不依赖数据库就绪):
cd /opt/data/quzuo/tmp/solidtime/stack
docker compose run --rm scheduler php artisan self-host:generate-keys
实测输出格式(值已脱敏,每次随机):
APP_KEY="base64:/<(44 位随机字符)>"
PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEF...\n-----END PRIVATE KEY-----"
PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQED...\n-----END PUBLIC KEY-----"
输出即 env 单行格式(PEM 以 \n 转义),可直接整行粘贴进 laravel.env 对应空槽,无需手工折叠。
⚠️ 两个注意:① 该命令会连带启动
database依赖容器并创建数据卷,属 compose run 的正常行为;② 密钥一旦投入使用不可更换,否则已发会话与 OAuth Token 全部失效。
五、启动与数据库迁移
5.1 启动全栈
docker compose up -d
实测输出(节选):
Container solidtime-v0.21.0-database Starting
Container solidtime-v0.21.0-database Started
Container solidtime-v0.21.0-gotenberg Started
Container solidtime-v0.21.0-app Started
Container solidtime-v0.21.0-scheduler Started
Container solidtime-v0.21.0-worker Started
5.2 观察健康状态
docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}'
实测输出(启动约 30 秒时):
NAME STATUS PORTS
solidtime-v0.21.0-app Up 46 seconds (healthy) 0.0.0.0:8137->8000/tcp
solidtime-v0.21.0-database Up About a minute (healthy) 5432/tcp
solidtime-v0.21.0-gotenberg Up 46 seconds 3000/tcp
solidtime-v0.21.0-scheduler Up 45 seconds (health: starting)
solidtime-v0.21.0-worker Restarting (1) 19 seconds ago
此时 worker 处于重启循环——这是真实遇到的问题,见 9.4(缺 WORKER_COMMAND),在 compose 中补上后 docker compose up -d 重建即恢复。scheduler 的 health: starting 与瞬时 unhealthy 属启动竞态误报(supervisord 尚未拉起受管进程),下一轮探测自动转 healthy,日志里 schedule:run 每分钟成功执行即为真实健康证据。
5.3 执行数据库迁移
创建全部表结构(每次升级 solidtime 版本后同样需要执行):
docker compose exec scheduler php artisan migrate --force
实测输出(节选,首尾):
INFO Preparing database.
Creating migration table ...................................... 11.16ms DONE
INFO Running migrations.
2014_10_12_000000_create_users_table .......................... 10.23ms DONE
2014_10_12_100000_create_password_reset_tokens_table ........... 4.09ms DONE
2016_06_01_000001_create_oauth_auth_codes_table ................ 5.78ms DONE
...(共数十条)...
2026_08_31_000001_add_send_time_entry_still_running_email_to_users_table 1.05ms DONE
2026_09_16_000001_remove_credentials_from_placeholder_users .... 1.23ms DONE
💡 简化部署可用环境变量
AUTO_DB_MIGRATE=true让应用启动时自动迁移(实测镜像入口脚本确认该行为,命令为php artisan migrate --isolated --force);多角色生产部署推荐保持手动迁移,避免多容器并发迁移竞态。
六、创建管理员账号
默认注册关闭(APP_ENABLE_REGISTRATION=off),首个用户经 CLI 创建:
printf 'q1w2e3r4\nq1w2e3r4\n' | docker compose exec -T scheduler \
php artisan admin:user:create "admin" "admin@solidtime.local" --ask-for-password --verify-email
--ask-for-password:交互输入密码(输入两遍),不传则输出随机密码;--verify-email:直接标记邮箱已验证,跳过验证邮件流程(HTTP 验证环境无出站邮箱,必加)。
实测输出:
ID: eee4c078-6503-46cc-819c-82b29d165b08
Name: admin
Email: admin@solidtime.local
Timezone: UTC
Week start: monday
Currency: EUR
本篇示例账号为 admin / q1w2e3r4。admin@solidtime.local 已写入 SUPER_ADMINS,该用户即可访问超级管理员面板。若创建时未在 SUPER_ADMINS,需补加后 docker compose up -d 重建生效。
七、Web 访问验证
7.1 首页探活与登录页
curl -sS -o /dev/null -w 'GET / -> %{http_code} redirect=%{redirect_url}\n' http://localhost:8137/
curl -sS -o /dev/null -w 'GET /login -> %{http_code}\n' http://localhost:8137/login
实测输出:
GET / -> 302 redirect=http://localhost:8137/login
GET /login -> 200
登录页 HTML 中无任何注册入口(grep Register 为空),与 APP_ENABLE_REGISTRATION=off 的预期一致。浏览器打开 http://<服务器IP>:8137 即见登录页。
7.2 TRUSTED_HOSTS 行为实测
curl -sS -o /dev/null -w 'via IP -> %{http_code}\n' http://192.168.146.128:8137/login
实测输出:
via IP -> 400
TRUSTED_HOSTS="localhost,127.0.0.1" 下用服务器 IP 直访被 400 拒绝(防 Host 头投毒,v0.19.0 引入的机制)。用 IP 访问的部署需把 IP 加进 TRUSTED_HOSTS 后重建。
7.3 登录与初始化向导
浏览器用 admin / q1w2e3r4 登录(会话 Cookie 由前端自动携带;用 curl 验证需走 Inertia 的 X-XSRF-TOKEN 头而非表单 _token 字段,这是实测得知的实现细节,浏览器操作无感)。实测登录后跳转 /dashboard:
POST /login -> 302 redirect=http://localhost:8137/dashboard
GET /dashboard -> 200
首次进入按向导初始化:创建组织(本篇实测名 demo)、设置默认货币与语言,随后创建项目与任务即可开始计时。另外实测发现:管理员账号创建后系统已自动生成一个「admin's Organization」个人组织,与手工创建的组织并存,两者均可在左侧组织切换器中切换。
7.4 超级管理员面板
GET /admin -> 200
SUPER_ADMINS 名单内账号可访问超级管理员面板(用户管理、实例配置、注册控制)。
八、常用运维命令速查
# 查看应用日志(LOG_CHANNEL=stderr 直出,实时跟踪)
docker compose logs -f app
# 进入应用侧执行 artisan 命令
docker compose exec scheduler php artisan list
# 升级流程:改 compose 中镜像 tag 后
ctr -n moby images pull --platform linux/amd64 docker.1ms.run/solidtime/solidtime:<新版本>
docker tag docker.1ms.run/solidtime/solidtime:<新版本> solidtime/solidtime:<新版本>
docker compose up -d
docker compose exec scheduler php artisan migrate --force
# 备份:数据库卷 + 环境文件
docker compose exec database pg_dump -U solidtime solidtime > solidtime-backup.sql
九、排障实录(全部为本次实测)
坑 1:所有 docker pull 报 proxyconnect tcp: dial tcp 192.168.146.1:7897: i/o timeout。 原因:daemon 级代理失效,且代理配置在 systemd drop-in 中,修改需重启 daemon(会波及其他在运行服务)。处置:改走 containerd 原生拉取(2.4 节),containerd 不读 daemon 代理环境。
坑 2:网上教程的 ghcr.io/solidtime-io/solidtime 拉不动。 实测该仓库在 ghcr 不存在(南大透传镜像返回 manifest unknown),官方镜像只在 Docker Hub。遇到 ghcr 系引用先做一次 skopeo inspect 探测,manifest unknown 即放弃整条 ghcr 路线。
坑 3:skopeo 单流拉取在共享加速器带宽下反复超时。 实测两次 600 秒硬超时(一次 90% 进度被掐断,一次几乎停滞),根因是 CDN 单流限速与他人拉取竞争。处置:换 ctr images pull,其按层并行下载实测 3.6MiB/s,96 秒完成。同一镜像别在单流工具上重试超过 2 次,直接换并行方案。
坑 4:worker 容器循环重启,日志刷 WORKER_COMMAND is undefined.。 原因:v0.21.0 镜像入口脚本在 CONTAINER_MODE=worker 时强制要求 WORKER_COMMAND 环境变量(官方容器模式文档未提及此点)。处置:compose 中为 worker 服务加 WORKER_COMMAND: "php artisan queue:work" 后重建。
坑 5:app/scheduler 首启瞬间因数据库未就绪退出重启。 首次 up -d 后 postgres 冷启动需数秒,应用不含等待逻辑。处置:restart: unless-stopped 自动恢复;或先单独 docker compose up -d database 等 healthy 再起其余服务。
坑 6:scheduler 启动后短暂 unhealthy。 原因:健康检查脚本用 supervisorctl status 判断受管进程,supervisord 自身尚在启动时探测会失败。处置:属启动竞态误报,下轮探测自动恢复;以日志中 schedule:run 每分钟成功执行为准。
坑 7:同批 401 死循环事故(2.5 节)。 匿名 token 25 分钟过期 + 脚本不校验响应体 + 无限重试 = 4 小时零进展。核心纪律:读响应体、换路线、设上限。
十、参考链接
- 官方 Docker 部署指南:https://docs.solidtime.io/self-hosting/guides/docker
- 官方配置文档:https://docs.solidtime.io/self-hosting/configuration
- 官方容器模式文档:https://docs.solidtime.io/self-hosting/container-mode
- 官方自托管示例仓库:https://github.com/solidtime-io/self-hosting-examples
- 本专栏其他篇目:《solidtime v0.21.0 介绍与选型指南》《solidtime v0.21.0 配置参数详解》《solidtime v0.21.0 API 使用详解》