用 Docker 搭 n8n 私有服务器:2 个容器、382 MB 内存,附可导入的测试工程

0 阅读10分钟

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 个大写字母。规则就写在输入框下面,但很容易忽略,我试密码时就卡在这。

n8n 首次打开的初始化页面:填邮箱、姓名、密码

点 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 个节点,结构是这样:

订单毛利巡检的 5 个节点:webhook、Code、IF、两个 Respond

从 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:

执行记录里 4 次运行全部 Succeeded,绿色勾表示该分支实际走过

第一次是 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
测 webhookcurl -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 节点的沙箱启动,还是执行记录的写入?

我可以按你实际的量级算一下瓶颈在哪。