在 VPS 上自托管 n8n:Docker + HTTPS
用 Docker Compose、Postgres 和反向代理背后的 HTTPS 在 VPS 上运行 n8n:讲清 WEBHOOK_URL 与加密密钥这两个坑,以及每一条报错。
您要搭建的东西
n8n 是一个工作流自动化工具:一个可视化编辑器,其中的触发器(webhook、定时计划、表单提交)会启动一串节点,去调用 API、重塑数据并写入其他系统。它已经成为 AI 智能体工作流的默认粘合层,因为它无需您编写任何服务就能与每一家模型供应商和数据库对话。一条 docker run 就能在两分钟内跑起一个可用的编辑器。本指南讲的是剩下那百分之九十:用 Postgres 代替默认的 SQLite 文件让它更可靠、让它可以通过 HTTPS 访问,以及几乎所有人都会弄错的那部分——让 webhook 交出一个外部世界真正能访问到的 URL。
最终的技术栈是同一个 Docker 网络上的两个容器:n8n 本身,以及保存其工作流和凭据的 Postgres 数据库。主机上的一个反向代理负责终结 TLS 并转发到 localhost 上的 n8n,因此除了通过那个代理,没有任何东西直接面向互联网。它与 2026 年自托管清单 上的其他服务并列。
前置条件,以及诚实的限制
您需要一台至少 1 GB 内存的 VPS;一旦工作流开始干真活,请按 2 GB 规划,因为执行过程加上 Node.js 运行时会吃内存,而内存耗尽杀手(OOM killer)在运行到一半时干掉容器,是一种很痛苦的学习方式。单个 vCPU 起步足够。
您需要一个域名或子域名——比如 n8n.example.com——并配好指向 VPS 公网 IP 的 A 记录,在您申请证书之前它必须能解析。端口 80 和 443 必须对代理开放;n8n 自己的端口 5678 则不能面向互联网。您需要 Docker Engine 和 Compose 插件;如果 docker compose version 报错 docker: 'compose' is not a docker command,说明您装的是旧的独立二进制文件,插件用 sudo apt install docker-compose-plugin 安装。
SQLite 用来测试没问题,但凡是您依赖的东西都用 Postgres
n8n 的默认数据库是位于 /home/node/.n8n/database.sqlite 的一个 SQLite 文件。用来试玩它没问题——不挂载卷,第一次重建容器时您就会丢掉它,这本身也是一课。改用 Postgres 的理由不是原始速度,而是 SQLite 持有单一写入锁,因此一个同时运行多个工作流的实例,或者您迟早会想要的队列模式,在并发下会抛出 SQLITE_BUSY: database is locked。Postgres 没有这个天花板,用 pg_dump 备份很干净,而且 n8n 自己的文档也假定您依赖的服务器用它。以后再切换意味着要手动迁移数据,所以如果这台机器很重要,一开始就用 Postgres。
DNS 与防火墙
先配好记录并开放端口,这样后面的证书步骤才不会因为名字无法解析而失败。
dig +short n8n.example.com
curl -s ifconfig.me
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow OpenSSH
sudo ufw enable不要开放 5678。compose 文件把 n8n 绑定到 127.0.0.1:5678,因此只有主机的反向代理才能访问它,而一条 ufw allow 5678 会破坏这层隔离。
Compose 文件
创建一个工作目录和一个 docker-compose.yml。这就是整个技术栈——两个服务、一个私有网络、两个具名卷。
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: n8n
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- n8n_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:2.29.10
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- N8N_HOST=n8n.example.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://n8n.example.com/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_PROXY_HOPS=1
- GENERIC_TIMEZONE=Europe/London
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- n8n_data:/home/node/.n8n
networks:
- n8n_net
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
networks:
n8n_net:有几个决定值得直说。DB_POSTGRESDB_HOST=postgres 是那个服务名,Docker 会在共享网络上解析它——而不是 localhost,在 n8n 容器内部 localhost 指的是 n8n 自己。带 condition: service_healthy 的 depends_on 能阻止 n8n 在启动时抢在 Postgres 前面;没有它,n8n 会先启动、找不到数据库、然后退出。位于 /home/node/.n8n 的具名卷 n8n_data 保存着加密密钥,以及在使用 SQLite 时的数据库——这是您绝不能丢失的那一个目录。把镜像钉在一个确切的版本上,永远不要用 latest;原因在下面的升级章节。
密钥文件
绝不要把密码放进 compose 文件。把它们放进旁边一个 Compose 会自动读取的 .env 文件里,并生成它们,让它们真正随机。
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
printf 'N8N_ENCRYPTION_KEY=%s\n' "$(openssl rand -hex 32)" >> .env
chmod 600 .envN8N_ENCRYPTION_KEY 是这里最重要的一个字符串——它是每一条已存储凭据的加密密钥。请显式设置它,而不是让 n8n 自己生成一个,因为您生成的值才是您能记下来并用于恢复的值。一旦 n8n 用这个密钥加密了它的第一条凭据,更改它会让每一条凭据都无法解密——所以现在就把它设定一次,然后再也不要碰那一行。
决定 webhook 能否工作的那几个环境变量
有四个变量控制着 n8n 如何向外部世界描述自己,把它们弄错是排名第一的 n8n 支持问题。
N8N_HOST是公网主机名,n8n.example.com。在代理背后把它保留为默认的localhost,编辑器就会试图在您的浏览器里从localhost加载它自己的 API,这会失败。N8N_PROTOCOL=https告诉 n8n 它是通过 TLS 提供服务的,因此它会把自己的会话 cookie 标记为Secure并构造https://的 URL。N8N_PORT=5678是 n8n 在容器内部监听的端口。它不是公网端口;443 归代理所有。WEBHOOK_URL=https://n8n.example.com/是那个会咬人的变量。n8n 会根据这些值构造出您粘贴到 Stripe、GitHub 或任何外部调用方的 webhook 地址。如果它未设置或设错了,n8n 会退回到N8N_HOST:N8N_PORT,交给您https://n8n.example.com:5678/webhook/...,或者更糟的http://localhost:5678/webhook/...——打印时不报任何错、看起来煞有介事,但从互联网上无法访问,于是调用方的请求悄无声息地永远到不了。把它设为确切的公网基础 URL,带上末尾的斜杠,然后确认 webhook 节点显示的是一个不带端口的 URL。
N8N_PROXY_HOPS=1 告诉 n8n 的 Express 服务器信任它前面的一层代理,这样限流以及任何读取客户端 IP 的功能看到的就是真实地址,而不是代理的地址。有一个变量您在这里要故意不设置:N8N_RUNNERS_ENABLED——任务运行器(task runner,即 n8n 在一个独立的沙箱进程里运行 Code 节点逻辑)自 1.69 起就是默认行为,从本指南钉住的 2.x 系列起更是强制的,因此那个旧的选择性开关已被弃用。现在设置它,n8n 只会记录一条通知,告诉您把它删掉。
首次启动
docker compose up -d
docker compose ps
docker compose logs -f n8n一次健康的首次启动会以一行 Editor is now accessible via: 结束,上面还有一行 n8n ready on ..., port 5678。docker compose ps 应当显示两个容器都是 Up,其中 postgres 标记为 (healthy)。如果 n8n 卡在 Restarting 循环里,就去读日志——几乎总是数据库连接问题,或者下面讲到的卷权限问题。
用反向代理配置 TLS
n8n 自身在 5678 上讲的是纯 HTTP;由前面的某个东西来终结 HTTPS。有两个干净的选择。
如果您已经在运行好几个容器,就把 n8n 放到 一个自动签发 TLS 证书的 Traefik 反向代理 后面,只需加上几个标签——Traefik 会替您申请并续期证书。
如果这台机器上只有这一个应用,那么用带 Let's Encrypt 证书的 nginx 虚拟主机更简单。用 适用于 Ubuntu 24.04 的 Certbot 与 nginx TLS 配置 来获取证书,然后用下面这个 server 块:
server {
listen 443 ssl;
server_name n8n.example.com;
ssl_certificate /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600;
client_max_body_size 16m;
}
}Upgrade 和 Connection "upgrade" 这两个头不是可选的。n8n 通过 WebSocket 向编辑器推送实时的执行更新,没有那两行,登录页会加载出来,然后就挂在一个连接丢失的横幅上。proxy_read_timeout 3600 能防止长时间运行的执行被 nginx 默认的 60 秒切断。X-Forwarded-Proto $scheme 头是 N8N_PROXY_HOPS=1 的搭档:它告诉 n8n 原始请求是 HTTPS,即便代理是通过纯 HTTP 到达它的,这样 n8n 就不会判定连接不安全并拒绝自己的 cookie。
您的第一个工作流,让它变得真实
打开 https://n8n.example.com/,创建所有者账户(见下一节),然后搭一个能证明整条路径可用的最小工作流:一个 webhook 进来、一次 HTTP 调用、一个响应出去。
- 添加一个 Webhook 节点。把方法设为
POST,路径设成hello之类。它会显示两个 URL,一个 Test URL 和一个 Production URL——这是一半"我的 webhook 不工作"报告的根源。Test URL 只回应一次调用,而且只在您点击了 Listen for test event 之后;随后它就失效。Production URL 只要工作流处于 Active 就会回应。 - 在它后面添加一个 HTTP Request 节点,指向任意公开的 JSON API——对
https://api.github.com/zen发一个 GET 会返回一行字符串,这就够了。 - 添加一个 Respond to Webhook 节点,并把 Webhook 节点的 Respond 选项设为 "Using Respond to Webhook node",这样调用方就能拿回 HTTP 节点的输出。
- 把工作流切换为 Active(右上角),然后调用它:
curl -X POST https://n8n.example.com/webhook/hello。您应当拿回那行 zen——POST 进来、调用 API、响应出去,这就是大多数真实自动化的形状。
一个定时变体会把 Webhook 节点换成 Schedule Trigger,并改为调用一个模型端点——用 运行在同一台 VPS 上的 Ollama 提供的自托管端点,是搭建一个每晚总结器的利落方式。
用户管理,而不是 basic auth
较旧的 n8n 指南会让您设置 N8N_BASIC_AUTH_ACTIVE=true。那些变量在 n8n 1.0 中已被移除,现在什么都不做。今天的身份验证是所有者账户:第一次加载编辑器时,n8n 会强制您创建一个带邮箱和密码的所有者,而这道门是强制的——没有匿名模式。首次启动后立刻创建它,在您把 URL 交给任何人之前:在 docker compose up 和那第一次表单提交之间,这个实例可以被最先访问到它的人认领。在上面再加一层反向代理 basic-auth 是一道合理的额外锁,但它是第二道防线,不是真正的身份验证。
备份:先备份加密密钥,再备份数据库
有两样东西需要备份,而它们的可替代性并不相同。
N8N_ENCRYPTION_KEY。 您在 n8n 里存储的每一条凭据——API 令牌、数据库密码、OAuth 密钥——都用这个密钥静态加密。没有它,Postgres 里的工作流就是废的:把数据库恢复到一台密钥不同的新机器上,n8n 一条凭据也解不出来,无从恢复、无法重置。您的 .env 文件保存着这个密钥;在您创建它的当天,就把它复制到服务器之外的某个地方——放进密码管理器条目里最理想。这才是真正重要的备份。
Postgres 数据库,用来保存工作流、执行历史以及加密后的凭据本身:
docker compose exec -T postgres pg_dump -U n8n -d n8n \
| gzip > n8n-db-$(date +%F).sql.gz按计划运行它,并把导出文件复制到机器之外。要在一台全新的 VPS 上恢复:先把技术栈起一次让数据库存在,停掉 n8n,用 psql 把导出文件加载回去,把相同的 N8N_ENCRYPTION_KEY 放进 .env,然后启动 n8n。相同的密钥加上导出文件就是一个可用的实例;一个新密钥则是一堆连一条凭据都用不了的工作流。
升级:钉住标签
compose 文件把镜像钉在 n8nio/n8n:2.29.10 而不是 latest,这是有意的。n8n 几乎每周都会发布一个新的次要版本,偶尔会在版本之间更改数据库模式或节点行为,所以 latest 意味着一次无人值守的拉取可能给您一个一启动就迁移数据库的构建。钉住一个版本,在升级前阅读发布说明——n8n 会在那里指出破坏性更改——然后有意识地升级:
docker compose exec -T postgres pg_dump -U n8n -d n8n | gzip > pre-upgrade.sql.gz
# edit the image tag in docker-compose.yml, then:
docker compose pull n8n
docker compose up -d n8n
docker compose logs -f n8n主要版本的跨越是这件事最要紧的地方。比如 2.0 系列把 N8N_BLOCK_ENV_ACCESS_IN_NODE 默认翻成了 true,因此任何读取 process.env 的 Code 节点会悄无声息地失去访问权限,直到您把它设回 false;同一版本还开始对设置文件强制施加严格权限。在跨越一个主要版本边界之前,请阅读 2.0 破坏性更改页面。n8n 会在启动时自动运行任何需要的数据库迁移——这正是升级前的 pg_dump 并非可选的原因。因为凭据是用 .env 里的密钥加密存储的、数据存在 Postgres 里,所以容器是一次性的:您通过替换它们来升级,通过钉住上一个标签并恢复导出文件来回滚。
故障模式,以及您会看到的报错字符串
The requested webhook "POST hello" is not registered. 这是调用一个工作流未处于 Active 的 webhook 时的 404,或者是在无人监听时调用测试路径。测试路径(/webhook-test/...)只在您点击了 "Listen for test event" 时回应;生产路径(/webhook/...)只在工作流开关打开时回应。与之相邻的 This webhook is not registered for GET requests. Did you mean to make a POST request? 意味着方法用错了——节点期望 POST,而您发了 GET。
webhook URL 里出现 :5678 或 localhost。 节点显示 https://n8n.example.com:5678/webhook/... 或 http://localhost:5678/...。WEBHOOK_URL 未设置或设错了,于是 n8n 用 N8N_HOST:N8N_PORT 而不是您的公网基础地址构造了地址。设置 WEBHOOK_URL=https://n8n.example.com/,用 docker compose up -d 重建容器,端口就会消失。
浏览器里出现 There was a problem loading init data。 编辑器加载了,但无法访问它自己的后端 API。在代理背后,这几乎总是 N8N_HOST 或 WEBHOOK_URL 弄错了、代理缺少 WebSocket 的 Upgrade 头,或者 N8N_PROTOCOL 与您的连接方式不匹配。确认那四个面向公网的变量,并确认代理转发了 Upgrade 和 Connection。
日志里出现 password authentication failed for user "n8n",同时容器在重启。 n8n 发送的密码与数据库初始化时的密码不匹配。这个坑:Postgres 只在初始化一个空的数据目录时才读取 POSTGRES_PASSWORD。把技术栈起过一次,然后在 .env 里更改 POSTGRES_PASSWORD,而现有的 postgres_data 卷仍然保存着旧密码。把它改回原来的值,或者,如果您没有要保留的数据,就 docker compose down 再 docker volume rm 掉那个 postgres 卷,然后重新起一份新的。
启动时出现 EACCES: permission denied, open '/home/node/.n8n/config'。 n8n 以 node 用户(UID 1000)身份运行,无法写入它的配置目录。这会咬到那些绑定挂载了一个属主为 root 的主机文件夹(./n8n_data:/home/node/.n8n)的人。用上面展示的具名卷,或者如果您坚持用绑定挂载,就先 sudo chown -R 1000:1000 ./n8n_data。
Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. Changing permissions to 0600.. 从 2.x 系列起,n8n 默认对那个设置文件强制 0600,并在启动时自行修正——这条日志行意味着它已经改正了权限模式,常见于绑定挂载之后,或者恢复操作以宽松权限把文件复制回来之后。无需任何操作;只有当您的文件系统确实无法支持权限时,才设置 N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false。
Mismatching encryption keys——更完整的那行会说设置文件 /home/node/.n8n/config 里的加密密钥与您环境中的 N8N_ENCRYPTION_KEY 不匹配。您环境中的密钥与 n8n 在此前某次运行时写入其数据卷的那个不同——最常见的原因是当变量未设置时 n8n 在更早的一次启动中生成了一个随机密钥,而您随后又设了一个不同的。把原来的密钥放回 .env,或者,只有在您确实没有任何值得保留的已存储凭据时,才删除 n8n_data 卷里的 config 文件、让 n8n 重新生成它——同时接受现有凭据会变得不可读。
一条关于安全 cookie 的登录横幅: Your n8n server is configured to use a secure cookie, however you are either visiting this via an insecure URL, or using Safari. 您设置了 N8N_PROTOCOL=https,却通过纯 HTTP 访问了 n8n——通常是直接访问 IP 和端口,而不是走 HTTPS 代理。请通过 https://n8n.example.com/ 访问它。只有当您确实无法使用 HTTPS 时,才应该设置 N8N_SECURE_COOKIE=false,而且在面向互联网的机器上永远不要这么做。
要在那些工作流里放进一个语言模型,参见 用 Claude 和 n8n 搭建 AI 工作流。
FAQ
n8n 应该用 SQLite 还是 Postgres?
SQLite(默认)用来试用 n8n、以及运行一个一次只跑一个工作流的个人实例是没问题的。凡是您依赖的东西都改用 Postgres:SQLite 的单一写入锁在并发下会抛出 database is locked,而 Postgres 用 pg_dump 备份很干净。以后再迁移是手动的,所以如果这台机器很重要,一开始就用 Postgres。
为什么我的 n8n webhook 从来不触发?
几乎总是 WEBHOOK_URL。未设置或设错时,n8n 打印出用 N8N_HOST:N8N_PORT 构造的 webhook 地址——常常带着 :5678 或 localhost——它们看起来有效,但从互联网上无法访问,于是调用方的请求永远到不了。设置 WEBHOOK_URL=https://n8n.example.com/,并确认节点显示的是一个不带端口的 URL。第二个原因是调用了一个工作流未切换为 Active 的 webhook,这会返回 The requested webhook ... is not registered.
n8n 里我必须备份什么?
两样东西。您 .env 文件里的 N8N_ENCRYPTION_KEY,因为每一条已存储凭据都用它加密,丢了它就会让它们永久无法解密——在您创建它的当天就把它复制到服务器之外。以及用于保存工作流、历史和凭据的 Postgres 数据库的一份 pg_dump。恢复时两者都需要:相同的密钥加上导出文件。
我该如何把 n8n 放到 HTTPS 背后?
n8n 在端口 5678 上提供纯 HTTP 服务;前面的一个反向代理负责终结 TLS。把 n8n 绑定到 127.0.0.1:5678,让只有代理能访问它,然后用带自动证书的 Traefik,或者用带 Let's Encrypt 证书的 nginx。设置 N8N_PROTOCOL=https 和 WEBHOOK_URL=https://your-host/,并确保代理转发了 WebSocket 的 Upgrade 头,否则编辑器会挂住。
我该如何安全地升级 n8n?
钉住一个具体的镜像标签而不是 latest,先做一份 pg_dump(因为 n8n 会在启动时自动运行迁移),阅读发布说明中的破坏性更改,然后升上标签并运行 docker compose pull n8n && docker compose up -d n8n。容器是一次性的,所以回滚就是钉住上一个标签并恢复升级前的导出文件。