RecipeSage Docker Compose 部署指南

RecipeSage v4.1.1 官方 RecipeSage-selfhost 六服务栈(proxy/static/api/pushpin/postgres/valkey)在 Ubuntu 24.04/Docker 29.9.2 实测部署:compose 全文与端口 8162 改造、Prisma 69 迁移自动执行、注册/建谱/购物清单/双账号好友协作/JSON-LD 导出/40 表 pg_dump 备份全链路逐字回显、616MiB 资源实测、环境变量表与 8 条 FAQ(实测日期 2026-11-02)

RecipeSageRecipeSageDocker Compose自托管PostgreSQLValkey2026/11/02

RecipeSage Docker Compose 部署指南

目标:在一台干净的 Ubuntu 24.04 服务器上,用官方 RecipeSage-selfhost compose 从零部署 RecipeSage v4.1.1 六服务栈(proxy/static/api/pushpin/postgres/valkey),完成注册、建食谱、协作购物清单、JSON-LD 导出与数据库备份的端到端闭环。全部命令与回显均来自实测环境(Ubuntu 24.04.5 LTS / x86_64 / Docker 29.9.2 / Docker Compose v5.6.1,实测日期 2026-11-02)。组件功能与选型评估见站内文章 《RecipeSage 介绍与选型指南》。

⚠️ 三条首要纠偏:① 部署仓库是 julianpoy/RecipeSage-selfhost(源码仓库 julianpoy/RecipeSage,不存在 RecipeSage/RecipeSage);② v4 数据库是 PostgreSQL 16,不是旧版的 MongoDB,前端是 Angular,不是 Ember;③ 官方 compose 默认钉 v4.1.0,本文按最新稳定 v4.1.1 升级部署(tag 实测存在、可拉取)。

⚠️ 验收方式说明:本次实测环境无浏览器自动化工具,前端页面验收降级为「HTTP 状态码 + 页面 <title> + REST API 全链路实测」,未做像素级 UI 走查;功能链路(注册/建谱/清单/协作/导出)均通过 API 实测闭环。

一、环境信息

项目值
操作系统Ubuntu 24.04.5 LTS(x86_64)
Docker 引擎29.9.2
Docker Composev5.6.1(docker compose 子命令形态)
内存15.57GiB(栈空载常驻约 616MiB,见 4.5)
应用镜像julianpoy/recipesage-selfhost:api-v4.1.1(解包 3.21GB/压缩 588MB)、julianpoy/recipesage-selfhost:static-v4.1.1(268MB/72MB)
基础镜像julianpoy/recipesage-selfhost-proxy:2026-08-16(nginx)、julianpoy/pushpin:2023-09-17、postgres:16、valkey/valkey
对外端口宿主 8162 → proxy 容器 80(本批次 8161 已被其他组件占用,改用 8162)
compose 项目名recipesage(-p recipesage,卷/网络前缀由此而来)
数据卷recipesage_apimedia、recipesage_postgresdata
演示账号邮箱 admin@example.com、friend@example.com,密码均 q1w2e3r4
镜像源⚠️ 实测环境直连 Docker Hub 受限,全部镜像经国内镜像加速源(docker.1ms.run 前缀)拉取成功;有直连条件的服务器按原 tag 拉取即可

二、镜像选型说明

RecipeSage 官方为自托管发布成对版本 tag:api-vX.Y.Z(后端)与 static-vX.Y.Z(前端),必须同版本成对升级,否则前后端 API 形状可能错位。GitHub Releases 实测版本线:v4.1.1(2026-10-31)→ v4.1.0(10-26)→ v4.0.19(10-25)。

服务镜像版本策略
apijulianpoy/recipesage-selfhost:api-v4.1.1与应用版本严格成对;官方 compose 默认 v4.1.0,本文已升 v4.1.1
staticjulianpoy/recipesage-selfhost:static-v4.1.1同上
proxyjulianpoy/recipesage-selfhost-proxy:2026-08-16按日期 tag 钉定,独立于应用版本
pushpinjulianpoy/pushpin:2023-09-17GRIP 代理,日期 tag,长期未变
postgrespostgres:16官方钉 16 主版本
valkeyvalkey/valkey官方 compose 未钉 tag(实测解析为 latest);生产建议自钉稳定版本

💡 技术拓展:队列/缓存组件这里用了 Valkey——它是 Redis 因许可证变更(BSL→RSALv2/SSPLv1)后由 Linux 基金会主导的开源分叉,协议与命令与 Redis 7.x 兼容;同类可选还有 redis:7(旧 BSD 版为 7.2.4 以前)与 KeyDB。RecipeSage compose 通过 JOB_QUEUE_REDIS_HOST 指向它,官方直接换用 Valkey 镜像,说明走的是纯 Redis 协议兼容路径,无 Valkey 专属特性依赖——如需换回 redis 镜像亦可行。

三、前置条件

3.1 Docker 与 Compose 版本

docker version --format 'Server: {{.Server.Version}}'
docker compose version

实测回显:

Server: 29.9.2
Docker Compose version v5.6.1

3.2 端口与资源

本实例宿主对外仅占用 8162 一个端口(proxy 容器 80 映射),其余服务只在 compose 内部网络互通、不映射宿主端口。部署前用 docker ps --format '{{.Ports}}' 确认 8162 空闲即可;资源按 616MiB 常驻 + 3.5GB 镜像磁盘预留。

四、部署步骤

4.1 目录结构

recipesage/
└── docker-compose.yml

官方仓库 julianpoy/RecipeSage-selfhost 的 compose 配置版本为 2026-10-29。selfhost 形态没有 .env 文件,全部配置内联在 compose 的 x-common-env 锚点段(改配置=改 compose 文件,详见同专栏《RecipeSage 4.1.1 配置参数详解》)。

4.2 docker-compose.yml(实测可用全文)

相对官方原版的修改点仅三处:端口 7270→8162、API_PUBLIC_BASE_URL 指向本地地址、static/api 镜像 v4.1.0→v4.1.1(另为 valkey 补了 container_name)。

# This config file is for RecipeSage-selfhost config version 2026-10-29, and RecipeSage version 4.1.1.

x-common-env: &common-env
  # === THESE VALUES MUST BE CHANGED ===
  API_PUBLIC_BASE_URL: http://127.0.0.1:8162/api # host+port with /api suffix

  # === THESE VALUES MAY OPTIONALLY BE CHANGED, BUT DO NOT NEED TO BE ===
  GRIP_KEY: recipesage_selfhost # Pushpin is only exposed within the docker network, so you can leave this as-is if desired.
  POSTGRES_DB: recipesage_selfhost # If changing this, make sure to update the DATABASE_URL below accordingly
  POSTGRES_USER: recipesage_selfhost # If changing this, make sure to update the DATABASE_URL below accordingly
  POSTGRES_PASSWORD: recipesage_selfhost # If changing this, make sure to update the DATABASE_URL below accordingly
  DATABASE_URL: postgresql://recipesage_selfhost:recipesage_selfhost@postgres:5432/recipesage_selfhost # Format is: postgresql://USERNAME:PASSWORD@HOST:PORT/DBNAME and must match the variables above
  GROCERY_CATEGORIZER_URL: https://grocery-categorizer.recipesage.com/ # Set to the local grocery-categorizer service if you uncomment it below
  AI_PROVIDER: "openrouter" # Please follow the instructions in the README if you decide to supply a value here
  AI_API_KEY: "" # Please follow the instructions in the README if you decide to supply a value here
  AI_API_BASE_URL: "" # This does not need to be changed unless you are self-hosting your own LLM.
  AI_MODEL_WEBPAGE: google/gemini-2.5-flash-lite # This model is recommended for this purpose
  AI_MODEL_TEXT: google/gemini-2.5-flash-lite # This model is recommended for this purpose
  AI_MODEL_OCR: google/gemini-2.5-flash # This model is recommended for this purpose
  AI_MODEL_DOCUMENT: google/gemini-2.5-flash # This model is recommended for this purpose
  AI_MODEL_MODERATION: google/gemini-2.5-flash # This model is recommended for this purpose
  AI_MODEL_VISION: anthropic/claude-sonnet-4.6 # This model is recommended for this purpose
  AI_MODEL_NUTRITION: google/gemini-2.5-flash-lite # This model is recommended for this purpose
  AI_MODEL_ASSISTANT: anthropic/claude-sonnet-4.6 # Use whatever model you prefer
  AI_MODEL_ASSISTANT_LOW: google/gemini-2.5-flash # Use whatever model you prefer
  SCRAPFLY_API_KEY: "" # Can be provided to bypass CloudFlare anti-bot for better clip from URL support. Or, just use the browser extension instead.

# === Please do not change any values below this line ===

services:
  proxy: # The proxy must not be removed. If needed, point your own proxy to this container, rather than removing this
    container_name: recipesage_proxy
    image: julianpoy/recipesage-selfhost-proxy:2026-08-16
    ports:
    - 8162:80
    depends_on:
      - static
      - api
      - pushpin
    restart: unless-stopped
  static: # Hosts frontend assets
    container_name: recipesage_static
    image: julianpoy/recipesage-selfhost:static-v4.1.1
    restart: unless-stopped
  api: # Hosts backend API
    container_name: recipesage_api
    image: julianpoy/recipesage-selfhost:api-v4.1.1
    depends_on:
      postgres:
        condition: service_healthy
      pushpin:
        condition: service_started
      valkey:
        condition: service_started
    command: ./scripts/selfhost-startup.sh
    environment:
      <<: *common-env
      # Please don't change these environment variables
      ENVIRONMENT: selfhost
      NODE_ENV: selfhost
      NODE_OPTIONS: "--max-old-space-size=400"
      MALLOC_ARENA_MAX: "2"
      FILESYSTEM_STORAGE_PATH: /rs-media
      STORAGE_TYPE: filesystem
      VERBOSE: "false"
      VERSION: selfhost
      POSTGRES_HOST: postgres
      POSTGRES_PORT: "5432"
      POSTGRES_SSL: "false"
      POSTGRES_LOGGING: "false"
      DATABASE_SSL: "false"
      GRIP_URL: http://pushpin:5561/
      JOB_QUEUE_REDIS_HOST: valkey
      STRIPE_SK: "" # Value should not be set.
      GCM_KEYPAIR: "" # Value should not be set.
      SENTRY_DSN: "" # Value should not be set.
      STRIPE_WEBHOOK_SECRET: "" # Value should not be set
    volumes:
      - apimedia:/rs-media
    restart: unless-stopped
  pushpin: # Provides websocket support
    container_name: recipesage_pushpin
    image: julianpoy/pushpin:2023-09-17
    entrypoint: /bin/sh -c
    command:
      [
        'sed -i "s/sig_key=changeme/sig_key=$$GRIP_KEY/" /etc/pushpin/pushpin.conf && echo "* $${TARGET},over_http" > /etc/pushpin/routes && pushpin --merge-output',
      ]
    environment:
      <<: *common-env
      TARGET: api:3000
    restart: unless-stopped
  postgres: # Database
    container_name: recipesage_postgres
    image: postgres:16
    environment:
      <<: *common-env
    volumes:
      - postgresdata:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "pg_isready", "-d", "recipesage_selfhost", "-U", "recipesage_selfhost"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
  valkey:
    container_name: recipesage_valkey
    restart: unless-stopped
    image: valkey/valkey

volumes:
  apimedia:
    driver: local
  postgresdata:
    driver: local

要点解读:x-common-env 锚点被 api/pushpin/postgres 三方共享(同一份 DB 凭据与 AI 配置);postgres 带 pg_isready 健康检查,api 用 condition: service_healthy 等库就绪后才启动并自动迁移;proxy 是唯一映射宿主端口的服务,官方注释明确不可移除(外部反代应指向它,而不是绕过它直连 api/static)。

4.3 启动

docker compose -p recipesage up -d

实测回显(栈已运行时的幂等输出;首次启动会先显示各容器 Created/Started,并拉取约 4.5GB 镜像):

 Container recipesage_api Running 
 Container recipesage_pushpin Running 
 Container recipesage_postgres Running 
 Container recipesage_valkey Running 
 Container recipesage_proxy Running 
 Container recipesage_static Running 
 Container recipesage_postgres Waiting 
 Container recipesage_postgres Healthy 

验证六容器全部在线:

docker compose -p recipesage ps --format '{{.Name}} {{.Status}}'

实测回显:

recipesage_api Up 14 minutes
recipesage_postgres Up 14 minutes (healthy)
recipesage_proxy Up 14 minutes
recipesage_pushpin Up 14 minutes
recipesage_static Up 14 minutes
recipesage_valkey Up 14 minutes

4.4 api 启动序列与自动迁移

api 容器 command: ./scripts/selfhost-startup.sh,序列为 prisma migrate deploy → CLI seed → CLI indexRecipes → 启动后端,无需手工初始化数据库。首次启动日志实测节选(输出节选,实测首尾):

==== Running migrations, please wait. ====
Loaded Prisma config from prisma.config.ts.

Prisma schema loaded from packages/prisma/src/prisma.
Datasource "db": PostgreSQL database "recipesage_selfhost", schema "public" at "postgres:5432"

69 migrations found in prisma/migrations

Applying migration `0_init`
Applying migration `20231107015959_default_createdat`
Applying migration `20231112183754_create_assistantmessage`
...

迁移完成后开始服务请求,日志尾部可见实时请求行(节选):

POST /compat/v2/jobs/startExportJob 200 3.829 ms - -
Starting processing job 2 with EXPORT.jsonld
Finished processing job 2
Job queue has drained

Job queue has drained 即 Valkey 队列的导出 job 被 worker 消费完毕的证据。

4.5 资源实测

docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' recipesage_proxy recipesage_static recipesage_api recipesage_pushpin recipesage_postgres recipesage_valkey

实测回显:

recipesage_proxy     6.754MiB / 15.57GiB   0.00%
recipesage_static    6.551MiB / 15.57GiB   0.00%
recipesage_api       516.7MiB / 15.57GiB   0.48%
recipesage_pushpin   35.09MiB / 15.57GiB   0.04%
recipesage_postgres  43.66MiB / 15.57GiB   3.27%
recipesage_valkey    7.27MiB / 15.57GiB    0.10%

合计约 616MiB,api 占 84%——其堆上限由 compose 注入的 NODE_OPTIONS=--max-old-space-size=400 控制,小内存机器可下调(见配置详解篇)。

五、页面与 API 验收

5.1 前端入口

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8162/
curl -s http://127.0.0.1:8162/app/ -o rs_app.html && wc -c rs_app.html
curl -s http://127.0.0.1:8162/app/ | grep -o '<title>[^<]*</title>'
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8162/app/login

实测回显:

301 http://127.0.0.1/app/
15217 rs_app.html
<title>RecipeSage</title>
200

/ 被 nginx 301 到 /app/(Angular SPA,base href=/app/,meta robots noindex),页面壳 15217 字节,登录页 200。⚠️ 301 的 Location 不含宿主端口是容器内 nginx 按自身监听端口 80 生成的,浏览器跟随跳转时会保留原 Host:Port,不影响使用;如挂真实域名反代则无此现象。

5.2 API 欢迎页与自描述

curl -s http://127.0.0.1:8162/api/ | head -c 200
curl -s http://127.0.0.1:8162/api/openapi.json -o rs_openapi.json && wc -c rs_openapi.json
python3 -c "import json;d=json.load(open('rs_openapi.json'));print('title:',d['info']['title'],'| version:',d['info']['version'],'| paths:',len(d['paths']))"

实测回显:

<!DOCTYPE html><html><head><title></title><link rel="stylesheet" href="/stylesheets/style.css"></head><body><h1>RecipeSage API</h1><p>Welcome to the RecipeSage API<br>Usage of the RecipeSage API is free for personal, non-commercial use.
403081 rs_openapi.json
title: RecipeSage API | version: selfhost | paths: 144

openapi.json 由 tRPC 路由自动生成(文件内注明 "generated from RecipeSage's tRPC router"),144 个 path 是 REST 兼容层 /compat/v2/* 的权威清单;交互式文档在站外 docs.recipesage.com/api/(/api/docs 实测 404,属正常)。

5.3 nginx 路由表(recipesage_proxy 容器内 default.conf 实读)

路径去向说明
/301 → /app/前端入口
/app/static:80Angular SPA
/api/、/app/api/api:3000后端与 REST 兼容层
/grip/wspushpin:7999/ws实时推送 WebSocket
/minio/$minio 变量仅 S3/MinIO 存储形态启用,默认 filesystem 不走
manifest.json、service-worker.jsstaticPWA 必须走根路径

六、业务闭环端到端验证

以下全部为 2026-11-02 实测命令与逐字回显。设 TOKEN 为 6.1 返回的 token。

6.1 注册主账号(无需邮箱验证)

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/users/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"admin","email":"admin@example.com","password":"q1w2e3r4"}'

实测回显(HTTP 200):

{"token":"490d2318d743839953460fe010bc1a0ae5cfe8e2588bde69d74ac436ba1ccc71ef6a928226a9fe822cd4518b7814ccd7","userId":"42754089-a1d9-4e1d-8b04-7df237fbb337","email":"admin@example.com","isNewUser":true}

会话验证:

curl -s http://127.0.0.1:8162/api/compat/v2/users/validateSession -H "Authorization: Bearer $TOKEN"

实测回显:"Valid"(HTTP 200)。

6.2 创建食谱

⚠️ 实测踩坑:createRecipe 的 Zod schema 要求 title/description/yield/activeTime/totalTime/source/url/notes/instructions/ingredients/rating/folder/labelIds/imageIds 全部字段出现(缺任一报 400 invalid_type),folder 为枚举 main|inbox(传 null 报 invalid_value),时长用 ISO8601(PT10M)。完整请求:

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/recipes/createRecipe \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Pasta Aglio e Olio","description":"Garlic chili pasta","yield":"2 servings",
       "activeTime":"PT10M","totalTime":"PT15M","source":"quzuo G25","url":"","notes":"keep starchy water",
       "instructions":"1. Boil pasta.\n2. Sauté garlic and chili in olive oil.\n3. Toss pasta with garlic oil.",
       "ingredients":"200 g spaghetti\n4 cloves garlic\n1 tsp chili flakes\n60 ml olive oil\n1 bunch parsley",
       "rating":null,"folder":"main","labelIds":[],"imageIds":[]}'

实测回显:{"id":"fb231af7-5993-42d8-9bc8-e6a5862afaea"}(HTTP 200)。

列表验证(⚠️ getRecipes 是 POST,GET 实测 404 NOT_FOUND):

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/recipes/getRecipes \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"folder":"main","orderBy":"createdAt","orderDirection":"desc","offset":0,"limit":10}'

实测回显:

{"recipes":[{"id":"fb231af7-5993-42d8-9bc8-e6a5862afaea","userId":"42754089-a1d9-4e1d-8b04-7df237fbb337","fromUserId":null,"title":"Pasta Aglio e Olio","description":"Garlic chili pasta","yield":"2 servings","activeTime":"PT10M","totalTime":"PT15M","source":"quzuo G25","url":"","folder":"main","createdAt":"2026-11-01T20:54:59.373Z","updatedAt":"2026-11-01T20:54:59.373Z","lastMadeAt":null,"rating":null,"recipeLabels":[],"recipeImages":[],"fromUser":null,"user":{"id":"42754089-a1d9-4e1d-8b04-7df237fbb337","name":"admin","handle":null,"enableProfile":false,"profileImages":[]}}],"totalCount":1}

统计接口(⚠️ getMyStats 是 GET,POST 实测 404):

curl -s http://127.0.0.1:8162/api/compat/v2/users/getMyStats -H "Authorization: Bearer $TOKEN"

实测回显:{"recipeCount":1,"recipeImageCount":0,"messageCount":0,"createdAt":"2026-11-01T20:54:43.926Z","lastLogin":null}。

6.3 购物清单与条目

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/shoppingLists/createShoppingList \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Weekly Groceries","collaboratorUserIds":[]}'

实测回显:{"reference":"db793080-69fc-4845-8f88-f32b83cee172","id":"d0e7f44b-e764-435c-96f7-aca6d96bc637"}(⚠️ collaboratorUserIds 必填,缺省报 400 expected array, received undefined)。

加条目(shoppingListId 取上一步 id,recipeId 关联 6.2 的食谱):

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/shoppingLists/createShoppingListItem \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"shoppingListId":"d0e7f44b-e764-435c-96f7-aca6d96bc637","title":"spaghetti","quantity":"200 g","recipeId":"fb231af7-5993-42d8-9bc8-e6a5862afaea","completed":false,"categoryTitle":"Grains"}'

实测回显:{"reference":"c4763253-8f6c-4b93-ac70-2423fc46c752","id":"9c034266-d978-43cf-8818-75477903ca48"}(HTTP 200)。

6.4 双账号好友协作

注册第二账号:

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/users/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"friend","email":"friend@example.com","password":"q1w2e3r4"}'

实测回显:{"token":"de4b2728834164ea6b22315e873361ca72e1147f565d3208f3d861cdd822e5c706a3133d04f2008288d3e6640c095773","userId":"1404d776-7b7f-4eb3-aa1e-552cd741e585","email":"friend@example.com","isNewUser":true}(HTTP 200)。

好友关系需双向建立(A 发起 + B 接受各调一次 createFriendship):

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/users/createFriendship \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"friendId":"1404d776-7b7f-4eb3-aa1e-552cd741e585"}'
curl -s -X POST http://127.0.0.1:8162/api/compat/v2/users/createFriendship \
  -H "Authorization: Bearer $FTOKEN" -H 'Content-Type: application/json' \
  -d '{"friendId":"42754089-a1d9-4e1d-8b04-7df237fbb337"}'

两次实测回显均为 "Created"(HTTP 200)。A 侧确认:

curl -s http://127.0.0.1:8162/api/compat/v2/users/getMyFriends -H "Authorization: Bearer $TOKEN"

实测回显:{"friends":[{"id":"1404d776-7b7f-4eb3-aa1e-552cd741e585","name":"friend","handle":null,"enableProfile":false,"profileImages":[]}],"incomingRequests":[],"outgoingRequests":[]}。

把清单共享给 B(updateShoppingList 需带全 title 与 collaboratorUserIds):

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/shoppingLists/updateShoppingList \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"id":"d0e7f44b-e764-435c-96f7-aca6d96bc637","title":"Weekly Groceries","collaboratorUserIds":["1404d776-7b7f-4eb3-aa1e-552cd741e585"]}'

实测回显:{"reference":"c7a0c9ac-2232-46d8-ad94-ed6e9572e0c4","id":"d0e7f44b-e764-435c-96f7-aca6d96bc637"}(HTTP 200)。B 侧立即可见(协作闭环成立):

curl -s http://127.0.0.1:8162/api/compat/v2/shoppingLists/getShoppingLists -H "Authorization: Bearer $FTOKEN"

实测回显(节选):

[{"id":"d0e7f44b-e764-435c-96f7-aca6d96bc637","userId":"42754089-a1d9-4e1d-8b04-7df237fbb337","user":{"id":"42754089-a1d9-4e1d-8b04-7df237fbb337","name":"admin"},"collaboratorUsers":[{"user":{"id":"1404d776-7b7f-4eb3-aa1e-552cd741e585","name":"friend"}}],"title":"Weekly Groceries","_count":{"items":1}}]

⚠️ 纠偏:v4 已无旧群组 API——GET/POST /api/groups 实测均 404(Express 默认 404 页)。协作模型 = 好友关系 + 对象的 collaboratorUserIds(购物清单/餐计划通用)。

6.5 导出 JSON-LD(schema.org)

curl -s -X POST http://127.0.0.1:8162/api/compat/v2/jobs/startExportJob \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"format":"jsonld","recipeIds":["fb231af7-5993-42d8-9bc8-e6a5862afaea"]}'

实测回显:{"jobId":"2e47aa87-0c7d-4b80-a75e-60fb78925c0a"}(⚠️ format 枚举 txt|pdf|jsonld)。轮询 job(参数名是 id 而非 jobId):

curl -s "http://127.0.0.1:8162/api/compat/v2/jobs/getJob?id=2e47aa87-0c7d-4b80-a75e-60fb78925c0a" \
  -H "Authorization: Bearer $TOKEN"

实测回显:

{"id":"2e47aa87-0c7d-4b80-a75e-60fb78925c0a","status":"SUCCESS","type":"EXPORT","userId":"42754089-a1d9-4e1d-8b04-7df237fbb337","resultCode":0,"progress":100,"meta":{"exportType":"jsonld","exportScope":"recipeids","exportStorageBucket":"filesystem","exportStorageKey":"dataExport/2026/11/1793566536025-8296230e1b0ade","exportDownloadUrl":"api/images/filesystem/dataExport/2026/11/1793566536025-8296230e1b0ade","recipeIds":["fb231af7-5993-42d8-9bc8-e6a5862afaea"],"language":"en-us"},"createdAt":"2026-11-01T20:55:36.006Z","updatedAt":"2026-11-01T20:55:36.033Z"}

getJobDownloadUrlById 返回 {"signedUrl":"api/images/filesystem/dataExport/2026/11/1793566536025-8296230e1b0ade"}。⚠️ 实测经代理直接 GET 该相对路径返回 404(签名 URL 供前端带会话使用);产物实体可在 api 容器的 filesystem 存储卷内核验——存储键按年/月子目录展开(dataExport/2026/11/…),落盘真实路径即 /rs-media/ + 存储键:

docker exec recipesage_api sh -c 'wc -c "/rs-media/dataExport/2026/11/1793566536025-8296230e1b0ade" && head -c 300 "/rs-media/dataExport/2026/11/1793566536025-8296230e1b0ade"'

实测回显(输出节选,实测首尾:首行为 wc 结果,次行为产物前 300 字节原样):

778 /rs-media/dataExport/2026/11/1793566536025-8296230e1b0ade
{"recipes":[{"@context":"http://schema.org","@type":"Recipe","identifier":"fb231af7-5993-42d8-9bc8-e6a5862afaea","datePublished":"2026-11-01T20:54:59.373Z","description":"Garlic chili pasta","image":[],"name":"Pasta Aglio e Olio","prepTime":"PT10M","recipeIngredient":["200 g spaghetti","4 cloves gar

产物共 778 字节,为合法 schema.org JSON-LD(@type:Recipe、recipeIngredient 数组、recipeInstructions 为 HowToStep 列表——完整结构以 head -c 600 实测确认),导出闭环成立。

6.6 URL 抓取(clip)的 selfhost 限制

curl -s -X POST http://127.0.0.1:8162/api/clip -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"url":"https://www.example.com/recipe"}'

实测回显:{"status":400}(HTTP 400);带 html 字段同样 400。api 容器出站网络实测正常(容器内 wget https://example.com 成功),排除网络因素——v4 的 clip 走 AI 解析管道,未配 AI_API_KEY 时不可用。官方 README 给出的替代路径:用浏览器扩展(webextension-v3)直接抓取当前页 HTML,或配置 SCRAPFLY_API_KEY/AI key(参数见配置详解篇)。

6.7 数据库与备份

表结构规模:

docker exec recipesage_postgres psql -U recipesage_selfhost -d recipesage_selfhost \
  -tAc "select count(*) from pg_tables where schemaname='public'"

实测回显:40(Users/Sessions/Recipes/Recipe_Images/Labels/LabelGroups/ShoppingLists/ShoppingListItems/MealPlans/Job/Friendships/_prisma_migrations 等)。

逻辑备份:

docker exec recipesage_postgres pg_dump -U recipesage_selfhost recipesage_selfhost | gzip > rs_backup.sql.gz
ls -la rs_backup.sql.gz

实测回显:-rw-r--r-- 1 root root 20890 Nov 2 04:56 rs_backup.sql.gz(约 20KB)。恢复可用 api 容器内 /app/scripts/restore-db.sh(dropdb → createdb → gunzip | psql),详见 CLI 篇。

七、数据持久化说明

docker volume ls | grep recipesage

实测回显:

local     recipesage_apimedia
local     recipesage_postgresdata

全部状态只存两处:postgresdata(PG 数据目录)与 apimedia(/rs-media:食谱图片、导出产物等,STORAGE_TYPE=filesystem 形态)。备份 = pg_dump + apimedia 卷打包,无第三处状态。

💡 技术拓展:Docker 数据持久化三种常见形态——

形态特点适用
named volume(本文)由 Docker 管理于 /var/lib/docker/volumes/,权限自动处理,可随 compose 项目命名数据库/上传目录通用首选
bind mount直接挂宿主目录,路径透明易备份,但受宿主权限/SELinux 影响需要宿主侧直接读写配置或产物
tmpfs仅内存,容器删除即失敏感临时数据

官方 compose 用 named volume,备份走 pg_dump/卷打包;若你更习惯宿主目录直接管理,可把 postgresdata:/var/lib/postgresql/data 改写为 bind mount,数据等价。

八、环境变量表

selfhost 的权威配置面 = compose 顶部 x-common-env 锚点(api/pushpin/postgres 三方共享)。全量语义与改参闭环见同专栏《RecipeSage 4.1.1 配置参数详解》,此处给部署最小必知:

变量默认值(compose)必改性说明
API_PUBLIC_BASE_URL官方占位 https://example.com/api必改对外基址,必须带 /api 后缀;本实测 http://127.0.0.1:8162/api
GRIP_KEYrecipesage_selfhost可不改pushpin 签名密钥,仅 docker 内网使用
POSTGRES_DB/USER/PASSWORDrecipesage_selfhost建议改⚠️ 三者改动必须同步改 DATABASE_URL
DATABASE_URLpostgresql://…@postgres:5432/…联动改格式 postgresql://USER:PASS@HOST:PORT/DB
GROCERY_CATEGORIZER_URLhttps://grocery-categorizer.recipesage.com/可选清单条目自动分类走公有 API;本地化见 compose 底部注释服务(需 AVX)
AI_PROVIDER/AI_API_KEY/AI_API_BASE_URLopenrouter / 空 / 空可选配 key 才解锁 URL clip/OCR/PDF 解析
AI_MODEL_*(9 个)gemini-2.5-flash(-lite)/claude-sonnet-4.6可选openrouter 模型名格式
SCRAPFLY_API_KEY空可选绕 Cloudflare 反爬改善 URL 抓取
api 服务注入段(ENVIRONMENT、NODE_OPTIONS、STORAGE_TYPE、GRIP_URL、JOB_QUEUE_REDIS_HOST、STRIPE_* 置空等)见 4.2 全文勿改官方注释明确 "Please do not change",改动会破坏 selfhost 形态

九、配置文件挂载说明

RecipeSage selfhost 不读 .env、不挂载任何配置文件:配置全部内联在 compose(x-common-env + api environment 段),改配置 → docker compose -p recipesage up -d <service> 重建即生效(改参→重启→验证闭环实测见配置详解篇)。唯一"配置"例外是 nginx 路由——它固化在 proxy 镜像内(/etc/nginx/conf.d/default.conf),如需自定义路由,官方建议另挂自己的反代指向 proxy 容器,而不是改/删 proxy。

十、常用运维命令

# 查看栈状态
docker compose -p recipesage ps

# 跟踪 api 日志(排障主入口)
docker compose -p recipesage logs -f api

# 重启单个服务
docker compose -p recipesage restart api

# 升级:改 compose 中 api/static 的成对 tag 后
docker compose -p recipesage pull api static
docker compose -p recipesage up -d

# 停止整栈(数据卷保留)
docker compose -p recipesage down

升级要点:api 与 static 的 tag 必须成对改;迁移由 api 启动脚本自动执行(Prisma migrate deploy),无需手工动作;回滚 = tag 改回旧值重新 up(迁移向后兼容不保证,回滚前先 pg_dump)。

十一、常见问题(FAQ)

Q1: 首次 up -d 后访问 8162 全 502? A: api 启动要先跑 69 个迁移 + seed + 全文检索回填,约需 30–60 秒。用 docker compose -p recipesage logs api 看到后端请求行出现后再访问;postgres 未 healthy 时 api 不会启动,ps 里确认 (healthy)。

Q2: 注册成功但前端功能异常/图片 404? A: 九成是 API_PUBLIC_BASE_URL 没带 /api 后缀或端口与实际访问地址不一致。改 compose 后 docker compose -p recipesage up -d api 重建,用 curl http://<地址>/api/ 验证欢迎页。

Q3: createRecipe 一直 400? A: 按 6.2 的清单逐字段补齐(全字段必填 + folder 只能 main/inbox)。400 响应体的 data 里带 Zod 逐项错误(invalid_type/invalid_value + path),照着补最快。

Q4: getRecipes GET 返回 404? A: 它是 POST(参数在 body)。同理 getMyStats 是 GET、getJob 参数名为 id。以 /api/openapi.json(144 paths)为准。

Q5: 网页版"从 URL 导入"报 400? A: selfhost 未配 AI_API_KEY 时 clip 不可用(见 6.6)。改用浏览器扩展抓取,或按 README 配 openrouter key。

Q6: 旧教程里的 /api/groups 404? A: v4 已删除群组 API,协作改用好友 + collaboratorUserIds(见 6.4)。

Q7: Docker Hub 拉不到镜像? A: 本实测环境即如此——经国内镜像加速源(如 docker.1ms.run/julianpoy/recipesage-selfhost:api-v4.1.1)拉取成功后 docker tag 回原名即可,注意注明网络渠道。

Q8: 导出 job 的 signedUrl 直接访问 404? A: 属正常——该相对 URL 供前端带会话使用;产物在 apimedia 卷 /rs-media/dataExport/<年>/<月>/<键> 路径下(见 6.5)。

十二、参考链接


取证说明:本文全部命令与回显来自 2026-11-02 实测的 RecipeSage v4.1.1 六服务栈(Ubuntu 24.04.5 / Docker 29.9.2 / Compose v5.6.1,宿主端口 8162);镜像经国内镜像源拉取;浏览器工具不可用,页面验收降级为 HTTP+title+API 闭环。

RecipeSage Docker Compose 部署指南 - Quzuo