n8n 跑在自己服务器上,比跑在云版省的不只是钱,主要是你的密钥不用交给别人。
我这次的实测结果:2 个容器、442 MB 内存、4 个测试用例全部返回正确结果。下面是从零开始的完整过程,包括我踩的 5 个坑。
先说结论,如果你只想知道怎么选:
| 你的情况 | 建议 |
|---|---|
| 只试玩、跑两三个简单工作流 | SQLite(默认),一个容器就够 |
| 要长期跑、有历史执行记录要查 | Postgres,本篇的做法 |
| 多人都要用、需要权限隔离 | Postgres + 反向代理 + 关掉注册 |
后面两种的差别不在今天,在三个月后:SQLite 在并发写入时会锁库,执行记录一多就开始卡。
一、为什么一定要用 Postgres 而不是默认的 SQLite?
n8n 默认用 SQLite,装完就能跑。但它有两个硬伤,都是攒出来的:
一是并发写会锁库。 SQLite 同一时刻只允许一个写入者。n8n 每次执行都要写执行记录,工作流一多、触发频率一高,就会看到 SQLITE_BUSY: database is locked,表现是工作流偶尔莫名其妙不执行。
二是执行记录会撑爆文件。 默认保留全部执行历史,SQLite 是单个文件,涨到几个 G 之后备份和查询都变慢。
Postgres 这两个问题都没有。代价是多一个容器、多 60 MB 内存 —— 实测 postgres:16-alpine 空载就是 60.35 MiB,很便宜。
二、两个容器怎么摆:目录和文件
先建目录,所有东西都放在这个目录下,将来迁移只搬这一个目录:
mkdir -p ~/n8n && cd ~/n8n
然后建 .env。这个文件存密码和加密密钥,权限必须是 600:
cat > .env <<'EOF'
POSTGRES_PASSWORD=换成你自己的强密码
N8N_ENCRYPTION_KEY=换成你自己的32位随机串
N8N_HOST=n8n.example.com
EOF
chmod 600 .env
生成随机密钥直接用这条,不用自己想:
openssl rand -hex 32
⚠️ N8N_ENCRYPTION_KEY 是整篇文章最重要的一个值
n8n 用它加密所有凭证(数据库密码、API Key、OAuth token)。这个值一旦变了,之前存的所有凭证全部解不开,界面上会显示成一堆乱码,只能重新填一遍。
而且它不会自己备份。我第一次装的时候没设,以为 n8n 会存在数据卷里 —— 它会,但存在数据卷里意味着你哪天误删了 volume,密钥和凭证一起没了。
所以:显式写在 .env 里,并且和数据库一起备份。
三、compose 文件(可直接用)
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=n8n
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 10
n8n:
image: n8nio/n8n:2.45.5
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
depends_on:
postgres:
condition: service_healthy
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://${N8N_HOST}:5678/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=Asia/Shanghai
- TZ=Asia/Shanghai
- N8N_RUNNERS_ENABLED=true
- N8N_DIAGNOSTICS_ENABLED=false
volumes:
- n8n_data:/home/node/.n8n
volumes:
pgdata:
n8n_data:
有五处是我改过的,逐条说为什么:
① ports 写 127.0.0.1:5678:5678,不写 5678:5678
冒号前面加 IP,意味着只监听本机。这样 n8n 不会被公网直接扫到,必须经过反向代理。如果你写成 5678:5678,等于把管理后台暴露在公网上,而 n8n 后台只要登录就能执行任意代码 —— 这是真实的攻击面。
② depends_on 带 condition: service_healthy
不能只写 depends_on: [postgres]。那样只保证容器启动了,不保证数据库能接受连接。n8n 启动时连不上库会直接退出,然后被 restart 反复拉起,形成重启循环。加上 healthcheck 条件才是「等库真的能用」。
③ N8N_RUNNERS_ENABLED=true
新版 n8n 把 Code 节点放到独立进程执行(task runner)。不开的话 Code 节点跑在主进程里,一个死循环就能把 n8n 卡死。开了之后 task runner 挂了主进程还活着。
④ 时区两处都要设
GENERIC_TIMEZONE 决定工作流里 $now 这类表达式用哪个时区,TZ 决定容器系统时间和日志时间。只设一个会出现「界面显示 8 点、日志打印 0 点」这种别扭情况。
⑤ N8N_DIAGNOSTICS_ENABLED=false
关掉遥测。自建的机器没必要往外发数据。
四、启动和验证
cd ~/n8n
docker compose up -d
正确的启动顺序在日志里是这样,注意 postgres 先变成 healthy,n8n 才开始:
Container n8n-postgres-1 Starting
Container n8n-postgres-1 Started
Container n8n-postgres-1 Waiting
Container n8n-postgres-1 Healthy
Container n8n-n8n-1 Starting
Container n8n-n8n-1 Started
再看状态:
docker compose ps
NAME IMAGE STATUS PORTS
n8n-n8n-1 n8nio/n8n:2.45.5 Up 4 minutes 127.0.0.1:5678->5678/tcp
n8n-postgres-1 postgres:16-alpine Up 17 minutes (healthy) 5432/tcp
看资源占用 —— 这是自建和云版对比时最有说服力的数字:
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
NAME CPU % MEM USAGE / LIMIT
n8n-n8n-1 1.76% 382MiB / 15.26GiB
n8n-postgres-1 1.09% 60.35MiB / 15.26GiB
合计 442 MB。一台 1 核 1G 的机器跑这套会很紧张,2 核 2G 是舒适线。
最后验证能访问:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5678/
# 200
五、初始化:第一次打开会被要求建账号
浏览器打开 http://<你的IP>:5678,会跳到 /setup,要求填邮箱(作为登录名)、姓名、密码。
密码规则:8 位以上,至少 1 个数字、1 个大写字母。规则就写在输入框下面,但很容易忽略,我试密码时就卡在这。
点 Next 之后,后端其实已经建号成功了(日志里会出现 Owner was set up successfully),但前端可能停在 loading 不动。这不是失败,直接访问 /home/workflows 就能进。
这一步我第一次遇到时以为装坏了,其实是前端的一个小毛病。
进到主界面之后是这样:
六、导入测试工作流
n8n 支持导入 JSON。界面上的入口在右上角菜单里,但用命令行更稳,尤其是服务器上没有图形界面的时候:
# 把 JSON 传进容器
docker cp workflow.json n8n-n8n-1:/tmp/wf.json
# 导入
docker exec n8n-n8n-1 n8n import:workflow --input=/tmp/wf.json
Importing 1 workflows...
Successfully imported 1 workflow.
然后激活它(import 导入的工作流默认是未激活状态,webhook 不会生效):
docker exec n8n-n8n-1 n8n update:workflow --id=<工作流ID> --active=true
注意这里有个坑:update:workflow 改的是数据库里的标记,n8n 进程已经把工作流加载到内存里了,所以最后那句提示才是「Please restart n8n for changes to take effect」。必须重启:
docker restart n8n-n8n-1
不重启的表现是:webhook 一直返回 404,你去界面上看工作流明明是绿的。
七、测试工程:订单毛利巡检
这个工作流的用途很实际 —— 电商/分销场景下,订单进来先算毛利,低于阈值就拦下来人工看,避免「卖得越多亏得越多」。
5 个节点,结构是这样:
从 POST /webhook/order-margin-check 进,经过校验和计算,由 IF 节点分成两条路:告警和正常。
完整 JSON(直接存成 workflow.json 用上面的命令导入):
{
"name": "订单毛利巡检",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "order-margin-check",
"responseMode": "responseNode",
"options": {}
},
"id": "a1000000-0000-4000-8000-000000000001",
"name": "订单回调",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-200, 0],
"webhookId": "order-margin-check"
},
{
"parameters": {
"jsCode": "const out = [];\nfor (const item of $input.all()) {\n const b = (item.json && item.json.body) ? item.json.body : item.json;\n const need = ['order_id', 'revenue', 'cost'];\n const missing = need.filter(k => b[k] === undefined || b[k] === null || b[k] === '');\n\n if (missing.length > 0) {\n out.push({ json: { ok: false, code: 'MISSING_FIELDS', missing: missing } });\n continue;\n }\n\n const revenue = Number(b.revenue);\n const cost = Number(b.cost);\n if (!isFinite(revenue) || !isFinite(cost) || revenue <= 0) {\n out.push({ json: { ok: false, code: 'BAD_NUMBER', revenue: b.revenue, cost: b.cost } });\n continue;\n }\n\n const margin = (revenue - cost) / revenue;\n out.push({\n json: {\n ok: true,\n order_id: String(b.order_id),\n currency: b.currency || 'CNY',\n revenue: revenue,\n cost: cost,\n margin: Math.round(margin * 10000) / 10000,\n margin_pct: (Math.round(margin * 10000) / 100) + '%',\n },\n });\n}\nreturn out;"
},
"id": "a1000000-0000-4000-8000-000000000002",
"name": "校验并算毛利",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [20, 0]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.ok === false || $json.margin < 0.15 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1000000-0000-4000-8000-000000000003",
"name": "要拦下来吗",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [240, 0]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'ALERT', reason: $json.ok === false ? $json.code : 'MARGIN_TOO_LOW', data: $json }) }}",
"options": { "responseCode": 200 }
},
"id": "a1000000-0000-4000-8000-000000000004",
"name": "返回告警",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [480, -120]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'OK', data: $json }) }}",
"options": { "responseCode": 200 }
},
"id": "a1000000-0000-4000-8000-000000000005",
"name": "返回正常",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [480, 120]
}
],
"connections": {
"订单回调": { "main": [[{ "node": "校验并算毛利", "type": "main", "index": 0 }]] },
"校验并算毛利": { "main": [[{ "node": "要拦下来吗", "type": "main", "index": 0 }]] },
"要拦下来吗": {
"main": [
[{ "node": "返回告警", "type": "main", "index": 0 }],
[{ "node": "返回正常", "type": "main", "index": 0 }]
]
}
},
"settings": { "executionOrder": "v1" }
}
三个设计要点,值得单独说
① 为什么用 respondWith: responseNode 而不是直接返回
responseMode 有三种:onReceived(收到就回,不管后续)、lastNode(最后一个节点的输出)、responseNode(由 Respond to Webhook 节点决定)。
只有 responseNode 能根据分支返回不同的内容。如果写 lastNode,那两条分支必须都能走到最后,而 IF 节点有两条出口,实际哪条走都行 —— 但你就没法控制 HTTP 状态码和响应体结构了。
② Code 节点里为什么要 isFinite() 而不只是 isNaN()
Number("abc") 是 NaN,Number("") 是 0,Number(Infinity) 是 Infinity。用 isNaN 判断,空字符串会被当成合法的 0;用 isFinite 能同时挡掉 NaN 和 Infinity。
电商系统里金额字段传成空字符串太常见了。
③ IF 节点为什么把两个错误合并成一个条件
={{ $json.ok === false || $json.margin < 0.15 }}
因为下游只需要知道「要不要拦」,不需要知道具体哪种错 —— 具体原因在 reason 字段里区分(MISSING_FIELDS / BAD_NUMBER / MARGIN_TOO_LOW)。这样告警侧只需要接一个通道,不用为每种错误建一个分支。
八、测试:四个用例跑通
# 用例1:正常单,毛利率 36.87%
curl -s -X POST http://127.0.0.1:5678/webhook/order-margin-check \
-H "Content-Type: application/json" \
-d '{"order_id":"A-1001","revenue":1299,"cost":820,"currency":"CNY"}'
{"status":"OK","data":{"ok":true,"order_id":"A-1001","currency":"CNY",
"revenue":1299,"cost":820,"margin":0.3687,"margin_pct":"36.87%"}}
# 用例2:低毛利单,只有 1.84%
curl -s -X POST http://127.0.0.1:5678/webhook/order-margin-check \
-H "Content-Type: application/json" \
-d '{"order_id":"A-1002","revenue":599,"cost":588}'
{"status":"ALERT","reason":"MARGIN_TOO_LOW",
"data":{"ok":true,"order_id":"A-1002","margin":0.0184,"margin_pct":"1.84%"}}
# 用例3:缺字段
curl -s -X POST http://127.0.0.1:5678/webhook/order-margin-check \
-H "Content-Type: application/json" -d '{"order_id":"A-1003"}'
{"status":"ALERT","reason":"MISSING_FIELDS",
"data":{"ok":false,"code":"MISSING_FIELDS","missing":["revenue","cost"]}}
# 用例4:金额传成非数字(模拟上游系统脏数据)
curl -s -X POST http://127.0.0.1:5678/webhook/order-margin-check \
-H "Content-Type: application/json" -d '{"order_id":"A-1004","revenue":"abc","cost":100}'
{"status":"ALERT","reason":"BAD_NUMBER",
"data":{"ok":false,"code":"BAD_NUMBER","revenue":"abc","cost":100}}
四个用例全部符合预期。执行记录里能看到对应 4 次运行,耗时分别是 51 ms / 18 ms / 22 ms / 125 ms:
第一次是 51 ms(冷启动,要建 Code 节点沙箱),后面稳定在 20 ms 上下。125 ms 那次是含字符串转换的脏数据用例,多花了一点时间在序列化上。
这个耗时数值得记住:一个 webhook 工作流 20 ms 量级,意味着你可以把它放在同步链路里(比如下单接口后面),而不是只能做异步任务。
九、必做:反向代理 + HTTPS
到这一步 n8n 只在 127.0.0.1 上,外部访问不了。这是故意的,也是对的。
用 Caddy 只要三行,比 Nginx 省事(自动申请和续期证书):
n8n.example.com {
reverse_proxy 127.0.0.1:5678
}
用 Nginx 的话,这四个头必须转发,少一个就出问题:
location / {
proxy_pass http://127.0.0.1:5678;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持,编辑器实时更新要用
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
配上 HTTPS 之后,.env 里这两行要改:
N8N_PROTOCOL=https
WEBHOOK_URL=https://n8n.example.com/
WEBHOOK_URL 不改的后果:webhook 地址在界面上显示成 http://...:5678/webhook/xxx,而你的外部系统走 https,地址是错的。而且 n8n 会给第三方服务(比如 OAuth 回调)生成错误的地址,表现为「授权完跳回来 404」。
十、我踩的五个坑
坑 1:改了 N8N_ENCRYPTION_KEY 之后凭证全废
现象是界面上所有凭证显示成一串乱码,执行工作流报 Credentials could not be decrypted。
原因就是这个 key 用于加解密凭证。它不会自己恢复,只能重新填一遍所有凭证。
这条记在 .env 里也没用 —— 需要和数据库一起备份,而且要存在别的地方。
坑 2:import:workflow 之后 webhook 404
导入的工作流默认未激活。而且 update:workflow --active=true 之后必须重启容器,因为 n8n 的工作流注册表在内存里。日志里那句 Please restart n8n for changes to take effect 不是客套话。
坑 3:depends_on 写了还是启动失败
只写 depends_on: [postgres] 只保证容器启动顺序,不保证数据库就绪。n8n 连不上库直接退出 → 被 restart: unless-stopped 拉起 → 再退出。日志看起来像无限重启。必须用 condition: service_healthy + postgres 的 healthcheck。
坑 4:ports 写成 5678:5678 把后台暴露在公网
这个最危险。n8n 后台登录后可以执行任意 JavaScript(Code 节点),等于公网上给了一个代码执行入口。
正确写法 127.0.0.1:5678:5678,只监听本机,外部必须过反向代理。
坑 5:时区只设了一个
GENERIC_TIMEZONE 管表达式,TZ 管容器系统时间。只设前者,日志时间还是 UTC,你会看到「执行时间 08:00、日志 00:00」,排查时序问题时非常误导。
速查卡
| 目的 | 命令 |
|---|---|
| 生成加密密钥 | openssl rand -hex 32 |
| 启动全部服务 | docker compose up -d |
| 看服务状态 | docker compose ps |
| 看资源占用 | docker stats --no-stream |
| 看 n8n 日志 | docker compose logs -f n8n |
| 导入工作流 | docker exec n8n-n8n-1 n8n import:workflow --input=/tmp/wf.json |
| 列出工作流 | docker exec n8n-n8n-1 n8n list:workflow |
| 激活工作流 | docker exec n8n-n8n-1 n8n update:workflow --id=<ID> --active=true |
| 激活后必须重启 | docker restart n8n-n8n-1 |
| 备份数据库 | docker exec n8n-postgres-1 pg_dump -U n8n n8n > n8n-$(date +%F).sql |
| 恢复数据库 | cat n8n-2026-11-03.sql | docker exec -i n8n-postgres-1 psql -U n8n -d n8n |
| 测 webhook | curl -X POST <url> -H "Content-Type: application/json" -d '{...}' |
常见问题
装完之后访问 5678 打不开?
先确认 docker compose ps 里两个容器都是 Up,然后 curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5678/。本机返回 200 但外部打不开,就是防火墙或安全组没放行,或者 ports 写成了只监听回环(那是正确的做法,需要配反向代理)。
Webhook 一直返回 404?
三个原因按顺序查:① 工作流没激活;② 激活后没重启容器;③ N8N_PROTOCOL / WEBHOOK_URL 写错,n8n 生成的路径和你请求的不一致。
能直接升级 n8n 版本吗?
可以,改 compose 里的镜像 tag 然后 docker compose up -d。但大版本升级前一定先备份数据库,n8n 会跑数据库迁移,迁移不保证可回退。备份命令在速查卡里。
数据存在哪?怎么迁移到新机器?
两个 volume:n8n_data(配置、密钥相关)和 pgdata(数据库)。迁移最稳的方式是 pg_dump 导出 + 拷 .env,而不是直接搬 volume —— 后者对 Postgres 版本敏感。
关于机器成本
这套东西只吃一台常开的小机器。我现在跑在腾讯云轻量应用服务器上,2 核 2G 那档一年几百块,跑 n8n 加 Postgres 之外还能塞几个小服务。
需要注意一点:n8n 的工作流是常驻的,机器不能休眠。所以不要用那种"按量计费、闲置自动关机"的方案,否则定时触发的工作流会静默不执行——你不会收到任何报错,只是它没跑。这类问题排查起来很费时间,因为从 n8n 界面上看不出区别。
一个问题
你这个工作流跑起来之后,如果订单量突然涨到原来的 10 倍,你现在的 n8n 会先卡在哪一环? 是 Postgres 的连接数、Code 节点的沙箱启动,还是执行记录的写入?
我可以按你实际的量级算一下瓶颈在哪。