这篇在一台 Linux 机器上用官方 Docker Compose 部署 Dify 1.17.1,并接入本机 Ollama 里的两个模型:对话用
qwen2.5-local:1.5b,向量用qwen3-embedding-local:0.6b。整个过程不需要任何云端 API Key。 初始化管理员、安装 Ollama 插件、注册模型、设置默认模型都写成了脚本,最后用check_setup.py自检,五项全部通过才算完成。
先看清成品(box 上真实运行)
docker compose ps显示 15 个服务都在运行,api、db_postgres、redis、sandbox 等显示 healthy。- 从 Marketplace 装上了
langgenius/ollama1.0.2。- 两个本地模型校验通过,状态 active,并设为工作区默认模型。
check_setup.py五项全部 ✓,退出码 0;pytest 2 项通过。
合规提醒:Dify 采用附加条件的 Apache 2.0 许可证:未经书面授权不得用源码运营多租户服务,也不得移除或修改前端的 LOGO 和版权信息,详见仓库里的 LICENSE。自托管时,数据都留在你自己的机器上,但如果接入了云端模型,提示词和数据会发送给对应的服务商。管理员密码、应用 API Key、外部知识库 Key 都不要提交进仓库。本文不列 Dify Cloud 的价格,以官网为准。
关于控制台 API:初始化、装插件、导入 DSL 这几步用的是控制台前端调用的
/console/api/*接口。它不是官方公开的稳定 API,本文按 1.17.1 的源码确认了路径和字段,升级后可能变化。同样的操作都可以在网页控制台里点完,脚本只是为了让环境可以重复搭建。对外集成请用公开的/v1Service API(B 篇)。
运行说明:全部步骤都在 box 上真实执行。没有做的部分:HTTPS 和域名配置、对公网开放、数据备份。这些属于生产部署,本文只搭本机环境。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 一套本机 Dify | 控制台在 http://localhost,数据存在 docker/volumes/ |
ollama_relay.py |
把 Ollama 安全地暴露给容器网络的小转发器(原因见调试示例) |
dify_console.py |
最小控制台客户端:登录、CSRF 头、Cookie |
install_ollama_plugin.py |
从 Marketplace 安装 Ollama 插件并等待任务完成 |
add_ollama_models.py |
校验并注册两个本地模型,设为默认模型 |
check_setup.py |
五项自检 |
test_console.py |
测试密码编码和 CSRF 头 |
需要准备哪些环境?
| 项目 | 要求 |
|---|---|
| 硬件 | 官方 README 写的最低要求:CPU ≥ 2 核、内存 ≥ 4 GiB(再跑本地模型需要更多) |
| 软件 | Docker 和 Docker Compose、git、Python 3.10 以上 |
| 模型 | 已安装 Ollama,并拉取了一个对话模型和一个向量模型(本文用的是 box 上已有的 qwen2.5-local:1.5b 和 qwen3-embedding-local:0.6b) |
文件结构
55-dify-A-src/
├── ollama_relay.py
├── dify_console.py
├── install_ollama_plugin.py
├── add_ollama_models.py
├── check_setup.py
└── test_console.py
第 1 步:用 Docker Compose 部署
git clone --branch 1.17.1 --depth 1 https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
sudo docker compose up -d
sudo docker compose ps
.env 里的 SECRET_KEY 默认是空的。注释里写着“Leave empty to auto-generate a persistent key in the storage directory”,也就是留空时 Dify 会自动生成一个密钥并保存在存储目录里,不需要手动填。如果要多台机器共用同一个密钥,再自己填一个强随机值。
第 2 步:初始化管理员并登录
curl -s http://localhost/console/api/setup # {"step":"not_started",...}
curl -s -X POST http://localhost/console/api/setup -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","name":"admin","password":"<你的强密码>"}' # {"result":"success"}
登录时有一个坑:控制台前端会先把密码做 Base64 编码再提交(这只是编码,不是加密),直接发明文会返回 401。dify_console.py 处理了这一点。登录成功后会拿到 access_token、refresh_token、csrf_token 三个 Cookie,之后每个写请求都要带上 X-CSRF-Token 头:
"""最小 Dify 控制台客户端:仅用于本机自托管实例的初始化脚本。"""
import base64, http.cookiejar, json, os, urllib.request, urllib.error
BASE = os.environ.get("DIFY_BASE", "http://localhost")
UA = "binbho-tutorial/1.0 (+self-hosted dify setup)"
class Console:
def __init__(self, base=BASE):
self.base = base.rstrip("/")
self.jar = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
def _csrf(self):
for c in self.jar:
if c.name.endswith("csrf_token"):
return c.value
return ""
def call(self, method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(self.base + "/console/api" + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", UA)
if self._csrf():
req.add_header("X-CSRF-Token", self._csrf())
try:
with self.op.open(req, timeout=120) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw)
except ValueError:
return e.code, raw.decode(errors="replace")
def login(self, email, password):
# 控制台前端把密码做 Base64 编码后再提交(不是加密),这里照做
enc = base64.b64encode(password.encode()).decode()
st, js = self.call("POST", "/login", {"email": email, "password": enc, "remember_me": True})
if st != 200:
raise SystemExit(f"登录失败 {st}: {js}")
return js
第 3 步:让容器能访问 Ollama
Ollama 默认只监听 127.0.0.1:11434,容器里访问不到。不要把 Ollama 改成监听 0.0.0.0,那样会把它暴露在所有网卡上。只在 Compose 网络的网关地址上开一个转发就够了:
sudo docker network inspect docker_default -f '{{(index .IPAM.Config 0).Gateway}}' # 本机是 172.28.0.1
python ollama_relay.py 172.28.0.1 11434 127.0.0.1 11434 &
import asyncio, sys
LH, LP, TH, TP = sys.argv[1], int(sys.argv[2]), sys.argv[3], int(sys.argv[4])
async def pipe(r, w):
try:
while (d := await r.read(65536)):
w.write(d); await w.drain()
finally:
w.close()
async def handle(cr, cw):
tr, tw = await asyncio.open_connection(TH, TP)
await asyncio.gather(pipe(cr, tw), pipe(tr, cw), return_exceptions=True)
async def main():
s = await asyncio.start_server(handle, LH, LP)
async with s: await s.serve_forever()
asyncio.run(main())
第 4 步:安装插件、注册模型
import json, os, sys, time, urllib.request
from dify_console import Console, UA
EMAIL = os.environ["DIFY_EMAIL"]; PW = os.environ["DIFY_PASSWORD"]
c = Console(); c.login(EMAIL, PW)
req = urllib.request.Request("https://marketplace.dify.ai/api/v1/plugins/langgenius/ollama", headers={"User-Agent": UA})
ident = json.load(urllib.request.urlopen(req, timeout=30))["data"]["plugin"]["latest_package_identifier"]
print("插件包:", ident)
st, js = c.call("POST", "/workspaces/current/plugin/install/marketplace", {"plugin_unique_identifiers": [ident]})
print("安装请求:", st, json.dumps(js, ensure_ascii=False)[:200])
if js.get("all_installed"):
sys.exit(0)
task = js["task_id"]
for _ in range(60):
time.sleep(3)
st, t = c.call("GET", f"/workspaces/current/plugin/tasks/{task}")
status = t["task"]["status"]
print("任务状态:", status)
if status in ("success", "failed"):
for p in t["task"]["plugins"]:
print(" -", p["plugin_id"], p["status"], p.get("message", ""))
sys.exit(0 if status == "success" else 1)
sys.exit("超时")
"""把本机 Ollama 模型注册进 Dify(LLM + Embedding),并设为工作区默认模型。"""
import json, os
from dify_console import Console
OLLAMA = os.environ.get("OLLAMA_BASE_URL", "http://172.28.0.1:11434")
PROVIDER = "langgenius/ollama/ollama"
MODELS = [
("qwen2.5-local:1.5b", "llm", {"mode": "chat", "context_size": "4096", "max_tokens": "1024",
"vision_support": "false", "function_call_support": "false"}),
("qwen3-embedding-local:0.6b", "text-embedding", {"context_size": "8192"}),
]
c = Console(); c.login(os.environ["DIFY_EMAIL"], os.environ["DIFY_PASSWORD"])
base = f"/workspaces/current/model-providers/{PROVIDER}/models"
for name, mtype, extra in MODELS:
creds = {"base_url": OLLAMA, **extra}
st, js = c.call("POST", base + "/credentials/validate", {"model": name, "model_type": mtype, "credentials": creds})
print("校验", name, st, js)
if st != 200 or js.get("result") != "success":
raise SystemExit(1)
st, js = c.call("POST", base + "/credentials", {"model": name, "model_type": mtype, "credentials": creds, "name": name[:30]})
print("添加", name, st, js)
st, js = c.call("POST", "/workspaces/current/default-model", {"model_settings": [
{"model_type": "llm", "provider": PROVIDER, "model": MODELS[0][0]},
{"model_type": "text-embedding", "provider": PROVIDER, "model": MODELS[1][0]},
]})
print("默认模型", st, js)
st, js = c.call("GET", base)
for m in js["data"]:
print(" -", m["model"], m["model_type"], m["status"])
模型表单的字段(base_url、mode、context_size、max_tokens 等)是从 GET /console/api/workspaces/current/model-providers 返回的 model_credential_schema 里读出来的,不是猜的。
第 5 步:自检
"""自检:控制台已初始化、Ollama 插件已装、两个本地模型 active、默认模型已设。全部通过退出 0。"""
import os, sys
from dify_console import Console
PROVIDER = "langgenius/ollama/ollama"
c = Console()
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print(("✓" if cond else "✗"), name, detail)
st, js = c.call("GET", "/setup")
check("控制台已初始化", js.get("step") == "finished", js.get("step"))
c.login(os.environ["DIFY_EMAIL"], os.environ["DIFY_PASSWORD"])
st, js = c.call("GET", "/workspaces/current/plugin/list?page=1&page_size=100")
plugins = {p["plugin_id"]: p["version"] for p in js.get("plugins", [])}
check("Ollama 插件", "langgenius/ollama" in plugins, plugins.get("langgenius/ollama", ""))
st, js = c.call("GET", f"/workspaces/current/model-providers/{PROVIDER}/models")
models = {m["model"]: m["status"] for m in js.get("data", [])}
for m in ("qwen2.5-local:1.5b", "qwen3-embedding-local:0.6b"):
check(f"模型 {m}", models.get(m) == "active", models.get(m, "缺失"))
st, js = c.call("GET", "/workspaces/current/default-model?model_type=llm")
d = js.get("data") or {}
check("默认 LLM", d.get("model") == "qwen2.5-local:1.5b", d.get("model", "未设置"))
sys.exit(0 if ok else 1)
import base64, json
import dify_console
def test_login_encodes_password_base64(monkeypatch):
sent = {}
def fake_call(self, method, path, body=None):
sent.update(body=body, path=path); return 200, {"result": "success"}
monkeypatch.setattr(dify_console.Console, "call", fake_call)
dify_console.Console().login("a@example.com", "Pa55word")
assert sent["path"] == "/login"
assert base64.b64decode(sent["body"]["password"]).decode() == "Pa55word"
def test_csrf_header_from_cookie():
import http.cookiejar
c = dify_console.Console()
ck = http.cookiejar.Cookie(0, "csrf_token", "tok", None, False, "localhost", False, False, "/", True,
False, None, False, None, None, {})
c.jar.set_cookie(ck)
assert c._csrf() == "tok"
第 6 步:运行
$ export DIFY_EMAIL=admin@example.com DIFY_PASSWORD=...
$ python install_ollama_plugin.py
插件包: langgenius/ollama:1.0.2@ef203a332d1b2b8952dbaa012be70be3e0a2d576b001489fb3b070efc970ffbe
安装请求: 200 {"all_installed": false, "task_id": "01a15dee-...", "task": null}
任务状态: success
- langgenius/ollama success installed
$ python add_ollama_models.py
校验 qwen2.5-local:1.5b 200 {'result': 'success', 'error': None}
添加 qwen2.5-local:1.5b 201 {'result': 'success'}
校验 qwen3-embedding-local:0.6b 200 {'result': 'success', 'error': None}
添加 qwen3-embedding-local:0.6b 201 {'result': 'success'}
默认模型 200 {'result': 'success'}
- qwen2.5-local:1.5b llm active
- qwen3-embedding-local:0.6b text-embedding active
$ python check_setup.py
✓ 控制台已初始化 finished
✓ Ollama 插件 1.0.2
✓ 模型 qwen2.5-local:1.5b active
✓ 模型 qwen3-embedding-local:0.6b active
✓ 默认 LLM qwen2.5-local:1.5b
$ python -m pytest -q
2 passed
检查点:打开 http://localhost,用管理员账号登录,在“设置 → 模型供应商”里能看到 Ollama 和两个模型。
调试示例
问题 1:登录返回 {"code":"authentication_failed","message":"Invalid encrypted data","status":401}。 第一次我直接提交了明文密码。在源码里搜这条报错,找到 controllers/console/wraps.py 里的 decrypt_password_field 装饰器,它调用 libs/encryption.py 的 decrypt_field,而这个函数做的只是 base64.b64decode。把密码做 Base64 编码后,登录返回 200,三个 Cookie 也都拿到了。
问题 2:容器访问不到 Ollama。 在 plugin_daemon 容器里请求 http://172.28.0.1:11434/api/tags,报 Connection refused。ss -ltn 显示 Ollama 只监听 127.0.0.1:11434。我先想装 socat 来转发,但 box 上没装成,于是改用上面那个 20 行的 asyncio 转发器,只绑定在 Compose 网关地址上。之后容器内请求就能返回模型列表了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 登录 401 “Invalid encrypted data” | 密码没做 Base64 编码 | 按前端的做法先编码 |
| 写请求返回 401/403 | 没带 X-CSRF-Token 头 |
从 csrf_token Cookie 里取值放进请求头 |
| 模型校验失败、连接被拒 | 容器访问不到 Ollama | 在网关地址上开转发,或者按 Ollama 官方说明设置 OLLAMA_HOST,注意不要暴露到公网 |
| 80 端口被占用 | EXPOSE_NGINX_PORT=80 |
在 .env 里改端口,再执行 docker compose up -d |
| 升级后脚本报 404 | 控制台 API 不是稳定接口 | 对照新版本源码或改用网页操作 |
模板规则
- 部署要固定版本号(
--branch 1.17.1),升级前先备份docker/volumes。 - 本地模型服务只开放给需要它的网络,不监听
0.0.0.0。 - 环境搭建写成脚本,最后用自检脚本确认,别只靠肉眼看。
项目资源
| 用途 | 地址 |
|---|---|
| Dify 源码(Docker Compose 在 docker/ 目录) | https://github.com/langgenius/dify |
| Dify 文档索引(llms.txt) | https://docs.dify.ai/llms.txt |
| Docker Compose 部署 | https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose |
| 环境变量说明 | https://docs.dify.ai/en/self-host/deploy/configuration/environments |
| Ollama 插件(Marketplace) | https://marketplace.dify.ai/plugins/langgenius/ollama |
装好的这套本机 Dify 后面两篇都会用到:B 篇在上面发布工作流,C 篇给它接外部知识库。