我从 n8n 里第一次真正把文件写到服务器磁盘,其实是被一个很老的业务系统逼的。那套系统不支持任何接口,供应商只留了一个“把 CSV 放到指定目录”的入口,而且每天凌晨必须更新。当时我新接手的 n8n 是个纯 API 编排工具,四处找了一圈才发现它自己就能读写本地文件。后来用顺手了,发现这个能力在自动化里被严重低估,很多“绕来绕去”的流程用本地文件一步就能走通。这篇就把我实际跑过的读文件、写文件、Docker 权限、动态文件名和清理策略一起整理出来,尤其适合正在做 n8n 本地部署、又卡在文件路径和权限上的朋友。
1. 什么场景下才会用到“n8n 读写本地文件”
1.1 最常见的三种需求:数据中转、批处理暂存、外部程序对接
n8n 原生最擅长的是把各种 API 串起来,比如点击 Webhook 后调 CRM 接口、把数据写回数据库。但实际项目里经常遇到一个尴尬:数据在两个系统之间传递时,既没有现成接口,也不希望把数据直接暴露给外部平台,这时候本地文件就变成了一个非常稳的“中间人”。
第一种是数据中转。比如 A 系统每天生成一份订单明细,B 系统只接受固定格式 CSV,两边没有直接连接。n8n 就可以定时把 A 的接口数据拉下来,解析、清洗、落盘成/data/orders/orders.csv,B 系统那边再用自己的采集任务来读。整个链路里,文件是唯一的事实来源,A 和 B 完全解耦。
第二种是批处理暂存。有些自动化任务会从文件服务、邮件附件或外部 API 下载较大的二进制文件,比如几百 MB 的压缩包、PDF 报表。如果让 n8n 在工作流内存里直接操作这么大的二进制对象,很容易把节点内存顶爆。我通常先让它落盘到本地,再用后续节点慢慢读取、解压或拆分处理。换句话说,本地文件承担了“缓冲区”的角色。
第三种是外部程序对接。很多老系统、内网程序、数据分析脚本并不接受 HTTP 调用,它们只认某个固定目录下的文件。你不需要去改造这些老程序,只要让 n8n 把结果写进它们能读的路径即可。过去我维护过一套财务系统,每天要从新平台同步交易明细,对方要求必须放在内网共享盘上,n8n 直接写入后,那边财务软件到点自动导入,效果比中间再架一层同步服务要省事得多。
1.2 为什么不用云存储或数据库:成本与延迟的权衡
有人会问:既然 n8n 都能连 S3、能连数据库,为什么非要读写本地文件?这里有个很现实的权衡。
云存储和数据库当然好,但在“内网直读”的场景下,本地文件优势明显。第一,延迟低,文件操作走的是本地磁盘或挂载卷,不用过外网;第二,成本可控,对数据量大的批次任务来说,传到对象存储再读回来会产生流量和存储费用,本地落盘没有额外花销;第三,安全性好理解,文件不出服务器,符合很多企业的内网合规要求。
但另一方面,本地文件也不是万能药。文件系统不适合做复杂查询,没有索引,不支持并发事务,多个工作流同时写同一个文件时很容易互相覆盖。所以我的原则是:本地文件适合做“临时暂存、单向传输、批处理”这类场景。如果数据要经常按条件查询、多人同时写,那应该用数据库而不是硬写文件。明确了这个边界,后面配置起来才不会跑偏。
2. 环境与权限:读写本地文件前必须先确认的三件事
2.1 你运行 n8n 的方式决定路径写法
这步是很多人第一次踩坑的地方。n8n 可以跑在宿主机上,也可以跑在 Docker 容器里,两种方式的“本地路径”完全不是一个概念。
如果你是用npm install n8n或官方二进制方式直接跑在宿主机上,那么这个进程拥有宿主机的文件系统访问权。你在节点里填/opt/n8n_files/report.csv,它访问的就是宿主机上的这个目录,逻辑最简单。但要注意 n8n 进程运行的用户是谁,如果它用非 root 账号启动,就只能访问该账号有权限的目录。
如果你是 Docker 部署,情况就变了。宿主机目录必须通过 volume 挂载进容器,n8n 节点里写的路径是“容器内部路径”,而不是宿主机路径。例如宿主机目录/opt/n8n_files通过./n8n_files:/data挂载后,节点里要填/data/report.csv,宿主机上却显示在/opt/n8n_files/report.csv。这个映射关系如果搞混了,就会出现“文件明明写成功了,宿主机找不到”的怪事。
我把常见部署方式的路径写法整理成了表,方便对号入座。
| 运行方式 | n8n 节点里填写的路径 | 宿主机实际位置 | 典型注意事项 |
|---|---|---|---|
| 宿主机 npm 启动 | /opt/n8n_files/report.csv | /opt/n8n_files/report.csv | 进程用户必须对目标目录有写权限 |
| Docker bind mount | /data/report.csv | 宿主机挂载源目录如/opt/n8n_files/report.csv | 必须提前用-v或 compose volumes 挂载 |
| Docker 命名卷 | /data/report.csv | Docker 管理的卷目录 | 宿主机定位不方便,适合临时数据 |
| n8n Cloud 托管 | 一般不可用或受限 | 无法访问宿主机 | 不建议依赖本地文件 |
2.2 容器挂载与 UID/GID 权限问题
Docker 部署下,文件写入成功并不代表宿主机上的其他程序能读。最常见的现象是:n8n 容器内写文件没有任何报错,但你到宿主机一看,文件属主是一堆奇怪的 UID,比如101001,而不是你预期的普通用户。
原因是容器内的进程用户和宿主机用户并不一定一致。n8n 官方镜像默认会创建node用户,UID 一般是 1000,但如果你用 root 启动容器或者自定义 image 改了用户,写出来的文件属主就会跟着变。那些需要读取文件的外部程序,比如 Nginx、老业务系统、定时脚本,如果它们以www-data或另一个普通用户运行,就可能出现“文件明明在,但读不了、删不掉”的尴尬。
解决办法也很简单:把宿主机挂载目录的属主改成容器内进程用户的 UID。比如容器内用户是 UID 1000,在宿主机上执行:
sudo chown -R 1000:1000 /opt/n8n_files如果确定容器内服务是以 root 启动的,那文件属主会是 root,外部用户同样无法访问,所以最好不要用 root 跑 n8n。这里有个不成文的规矩:容器内进程尽量用普通用户,挂载目录属主和这个用户保持一致。
2.3 快速验证文件系统是否可达
配置好后,不要急着写完整流程,先做一个健康检查。我常用的办法是在工作流里临时加一个 Code 节点,执行一次文件写入和读取测试:
const fs = require('fs'); const testPath = '/data/n8n_write_test.txt'; fs.writeFileSync(testPath, 'n8n 文件访问正常', 'utf8'); const content = fs.readFileSync(testPath, 'utf8'); return [{ json: { ok: true, content } }];如果你用的不是自托管版本,或者 Code 节点受 sandbox 限制,可以直接用“Read/Write Files from Disk”节点手动写一个文件,再手动读回来。两边都能通,说明路径映射和权限没问题,后续再搭正式流程。这个验证动作我每次部署新环境都会做一次,能省掉后面一大半排错时间。
3. 读文件实战:从 CSV 读取到 JSON 结构化的完整流程
3.1 Read/Write Files from Disk 节点的读取配置
n8n 自带一个专门处理本地文件的核心节点,叫Read/Write Files from Disk,搜索“Files from Disk”就能找到。读文件的时候,操作选Read,然后在File Path字段填文件路径。这个路径是节点的执行环境路径,也就是容器内路径。我还习惯在选项里指定输出属性名,方便后面的节点引用。
配置好之后,节点会把文件内容作为二进制数据输出,而不是直接输出 JSON 或文本。所以你如果直接拖一个普通节点来看结果,看到的会是一个 binary 对象,而不是可以逐行读取的数据。这就是很多新手第一次“读文件”后很懵的原因:文件读进来了,但不知道怎么拆开。
我把读取节点的典型配置整理一下:
- 节点:Read/Write Files from Disk
- 操作:Read
- 文件路径:
/data/input/orders.csv - 输出属性名:
fileData - 后续处理:接 Extract from File 或 Code 节点
真实项目里如果文件路径是固定的,可以直接写死;如果路径要动态拼接,则在 File Path 字段用表达式,比如/data/input/{{ $json.fileName }}。
3.2 配合 Extract from File 和 Code 节点做解析
读取得到的二进制数据该怎么解析?n8n 提供了Extract from File节点,支持 CSV、JSON、XLSX、PDF 等常用格式。把 Read 节点输出的 binary 接到 Extract from File 节点的输入,选好输入格式和输出方式,它就能把文件内容结构化。
举个例子,读取一个 CSV 文件并按条件过滤,完整流程可以这样搭:
- Schedule Trigger 按天触发。
- Read/Write Files from Disk 读取
/data/input/orders.csv。 - Extract from File 节点,操作选择
Extract from File,格式选 CSV,输出方式选数组中每一项。 - Code 节点里过滤掉金额小于 100 的订单。
- 后续节点把结果写回文件或发送通知。
如果文件本身是 JSON 格式,不一定要用 Extract from File,也可以直接用 Code 节点处理。Code 节点的优势是可以一边解析一边做业务逻辑,比如对字段重命名、计算新字段、处理日期格式,一步到位。示例:
const fs = require('fs'); const raw = fs.readFileSync('/data/input/orders.json', 'utf8'); const orders = JSON.parse(raw); const filtered = orders.filter(order => order.amount >= 100); return filtered.map(order => ({ json: order }));这样返回的 items 就能直接进后续的任何节点,非常干净。
3.3 读取大文件的注意事项
本地文件不是流式的,Read/Write Files from Disk 节点默认会把整个文件一次性读进内存。对于几 MB 的 CSV 没感觉,但到了几百 MB 的文件,内存占用会非常明显,甚至在服务器内存不足时直接 OOM。
我处理大文件的思路有两个。一是如果能拆,就在上游先按日期或业务拆分,n8n 只读当天的小文件;二是如果文件必须整体读,则改用 Code 节点按流式处理,比如用readline逐行解析,或者用fs.createReadStream配合自定义逻辑。但这个复杂度不是每个项目都值得上,普通 CSV/JSON 配置文件直接用节点就够了。
另外,文件不存在时节点会直接报错。如果这个文件是可选的,建议在上游先用 Code 节点或fs.existsSync判断一下,文件不存在就返回空结果并走另一个分支,避免整个工作流被一个跳过的文件打断。状态检查逻辑放在文件读取之前,而不是等报错了再处理,这是我在生产环境里最常用到的调整。
4. 写文件实战:从 API 数据到落盘定时报告
4.1 把 JSON 转成文件的两种方式
写文件和读文件不同,它需要上游先准备好二进制数据。最常用的方式是用Convert to File节点,它可以把 JSON 或文本转换成 CSV、JSON、HTML、TXT 等格式,并输出 binary。另一个选择是Spreadsheet File节点,它对表格类数据支持得更好,可以自动生成列名、格式也更规范。
这两种方式我做过对比,简单总结:
Convert to File:轻量,适合快速把所有 JSON 转成文件;格式可选手动控制。Spreadsheet File:适合订单、报表、成员列表这类结构化数据,行列控制更强,对 Excel 兼容性更好。- Code 节点直接写文件:最灵活,适合自定义文件名、自定义编码、需要拼接复杂字符串的场景。
如果你只是想从 HTTP 返回的数据直接生成 CSV,Convert to File就够了;如果要做复杂的多级表头,建议用Spreadsheet File;如果还想顺手搞点字符串加工,直接 Code 也完全没问题。
4.2 完整工作流配置:定时拉取、生成 CSV、写入本地
这里给你一个我在订单报表场景里实际跑通的完整配置,照着抄基本不会错。
流程结构:
- Schedule Trigger 节点,触发时间设置为每天凌晨 2 点,Cron 表达式
0 2 * * *。 - HTTP Request 节点请求订单接口,认证方式可以选择 Header Auth。在 n8n 的 Credentials 里新建 Header Auth 类型的凭证,填上对应的 Header 名称和值,这样请求里就会自动带上认证信息,不需要在节点里暴露敏感头。
- 用一个 Code 节点,把接口返回的数组整理成 CSV 需要的数据结构,并拼一个动态文件名。
- 使用
Convert to File节点,将处理后的 JSON 转成 CSV。 - 使用
Read/Write Files from Disk节点,操作选Write,输入属性名填上一步产生的 binary 属性名,File Path 填/data/reports/{{ $json.fileName }}。
第 3 步里动态文件名的代码大概是这样的:
const dateStr = new Date().toISOString().slice(0, 10); return items.map(item => ({ json: { ...item.json, fileName: `orders_${dateStr}.csv` } }));然后第 5 步的File Path用表达式拼接/data/reports/{{ $json.fileName }}。这样每天都会生成一个新文件,不会互相覆盖。如果你希望所有数据写到一个文件里,就要在前一步用merge之类的节点把多行数据汇总到一个 item 中,再交给写文件节点;否则写入节点会对每个 item 执行一次,可能一次运行生成多个文件。
4.3 写入中文编码与换行符的坑
CSV 文件落盘后,经常遇到一个问题:用 Excel 打开中文全乱。原因是 n8n 默认生成的是 UTF-8 无 BOM 编码,而 Windows 下的 Excel 对没有 BOM 的 UTF-8 识别得并不好,它默认当成 GBK 去读。
解决办法是在生成文本时,给内容前面加一个 UTF-8 BOM 标记。用 Code 节点拼 CSV 字符串时,可以在开头加上\ufeff:
const header = '订单号,客户名称,金额\n'; const rows = items.map(item => `${item.json.id},${item.json.customer},${item.json.amount}`).join('\n'); const csv = '\ufeff' + header + rows; return [{ json: { csv } }];如果之后需要写文件节点,可以先把这个字符串通过Convert to File的 Text 格式转成二进制,再给写文件节点落盘。
另外换行符也值得注意。Linux 下默认\n,Windows 下的旧版 Excel 可能对只含\n的 CSV 换行不友好。我现在的习惯是统一生成\r\n,也就是把join('\n')改成join('\r\n'),两边平台都能正常打开。虽然是小细节,但每次交付给非技术同事时都能避开“文件打不开”的售后问题。
5. Docker 部署下文件写入权限故障排查完整记录
5.1 症状:文件生成了但宿主机没权限
最典型的生产事故是这样的:n8n 跑在 Docker 容器里,通过 docker-compose 挂载了宿主机/opt/n8n_files到容器/data。某天定时任务顺利执行完,n8n 也返回成功,但宿主机上的另一个读取程序开始报“Permission denied”,或者目录里出现了一批属主显示为101001的文件。
第一次遇到的时候我也很困惑:写文件成功了,为什么读不了?后来才明白,容器内进程写文件时的 UID 不是宿主机当前登录用户的 UID。n8n 写出来的文件权限默认是 644,属主是容器内用户,宿主机上的其他用户没有写权限,如果目录权限也不对,连删除都做不到。
5.2 排查链路:从容器用户到挂载目录权限
遇到这种问题,别急着改代码,先按下面链路走一圈。
- 找到容器 ID:
docker ps | grep n8n- 进入容器查看进程用户:
docker exec -it <container-id> whoami正常情况下输出是node,对应 UID 1000。如果你在容器里看了/etc/passwd,里面会有node:x:1000:1000。
- 查看挂载目录权限:
docker exec -it <container-id> ls -l /data- 在容器里实际测试写入:
docker exec -it <container-id> touch /data/test.txt如果这一步成功,说明容器内权限没问题;如果失败,你马上就看到了报错。
- 到宿主机查看文件属主:
ls -n /opt/n8n_files这一步能直接看到 UID。如果 UID 是 1000,而你的宿主机用户 UID 是 1001,那宿主机上用户对这个新建文件就是“其他人”,只能读取,不能删除或修改。
5.3 修复方案与 docker-compose 示例
修复方式有三种,我按推荐度排序。
第一种,也是最推荐的:把宿主机挂载目录属主改成容器内用户 UID。假设容器用户 UID 是 1000:
sudo chown -R 1000:1000 /opt/n8n_files这样 n8n 写出来的文件属主是 1000,宿主机如果想要同一批文件,也可以把这个目录给需要读取的用户加一个组权限。
第二种,修改 docker-compose 里的用户。在 service 下加上 user,强制容器进程以指定 UID 运行:
services: n8n: image: n8nio/n8n user: "1000:1000" ports: - "5678:5678" volumes: - ./n8n_data:/home/node/.n8n - /opt/n8n_files:/data environment: - N8N_SECURE_COOKIE=false这个方式适合你对容器内用户机制比较熟悉的情况,否则可能出现容器内目录 HOME 不是预期路径的问题。我更常用第一种,简单直接,不动容器本身。
第三种,使用 Docker 命名卷。命名卷的权限由 Docker 管理,宿主机上定位和读写不直观,但好处是 n8n 容器重建后数据不会丢。如果文件纯粹是 n8n 自己内部使用,命名卷很适合;如果要给宿主机上老系统读,就不建议了。
这里提醒一句:不要图省事把目录权限直接改成 777。文件里如果有业务数据,777 意味着任何进程都能改,风险太大。按 UID 精确控制才是正道。
6. 进阶:用 Code 节点实现动态路径、批量清理与多文件合并
6.1 Code 节点里的文件操作能力
自托管的 n8n,Code 节点实际上运行在后端 Node.js 环境。也就是说,很多 Node.js 内置模块都可以直接使用,fs、path这些都很顺手。托管版 n8n Cloud 可能会受限,但本地部署一般没问题。我自己在服务器上跑,基本就是拿它当 Node.js 小脚本来用。
Code 节点操作文件时要注意,同步方法readFileSync、writeFileSync用起来方便,但如果文件很大,会阻塞 Node.js 进程,影响 n8n 其他工作流响应。我的经验是:小于 10MB 的文件,同步方法没问题;超过这个量级,建议用异步方式或拆小任务分批处理。
简单示例,读取 JSON 并统计数量:
const fs = require('fs'); const raw = fs.readFileSync('/data/input/orders.json', 'utf8'); const orders = JSON.parse(raw); return [{ json: { total: orders.length } }];6.2 动态生成带时间戳的文件名
前面提过动态文件名,这里说细一点。用 Code 节点给每个 item 增加一个fileName字段,然后在写文件节点里引用,是实现“每次运行生成新文件”最优雅的方式。
const datePart = new Date().toISOString().slice(0, 10); const timePart = new Date().toISOString().replace(/[:.]/g, '-'); return items.map(item => ({ json: { ...item.json, fileName: `report_${datePart}_${timePart}.csv` } }));注意toISOString()返回的是 UTC 时间,如果你要的是北京时间,记得先new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),或者直接用你熟悉的日期库做格式化。
文件名里的冒号在 Windows 宿主机上是合法字符,但在老系统里尽量别用。我用连字符和下划线替代,兼容性最好。写文件节点的路径就填/data/reports/{{ $json.fileName }},每次运行生成独立文件,避免覆盖历史数据。
6.3 定时清理过期文件的脚本示例
文件越攒越多,磁盘迟早会满。n8n 里可以用 Code 节点写一个清理任务,每天定时运行。代码逻辑如下:
const fs = require('fs'); const path = require('path'); const dir = '/data/reports'; const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; const deleted = []; for (const name of fs.readdirSync(dir)) { if (!name.startsWith('orders_') || !name.endsWith('.csv')) { continue; } const fullPath = path.join(dir, name); const stat = fs.statSync(fullPath); if (stat.isFile() && stat.mtimeMs < cutoff) { fs.unlinkSync(fullPath); deleted.push(name); } } return [{ json: { deleted, count: deleted.length } }];这里最关键的是那两行 if 判断:只删除符合orders_前缀和.csv后缀的文件,防止因为路径写错而误删其他重要文件。这样即使工作流被误配置,也不会把整个目录清空。加上 Schedule Trigger 每周日凌晨执行,基本不用再人工管磁盘。
6.4 多文件合并再落盘
如果你需要把几个分片文件合并成一个总文件,也可以直接在 Code 节点操作。比如目录下有part_1.csv、part_2.csv等文件,把这些文件全部读出来,保留第一个文件的表头,然后合并所有数据行。
const fs = require('fs'); const path = require('path'); const dir = '/data/parts'; const files = fs.readdirSync(dir).filter(name => name.startsWith('part_') && name.endsWith('.csv')); files.sort(); let mergedLines = []; files.forEach((file, index) => { const content = fs.readFileSync(path.join(dir, file), 'utf8'); const lines = content.trim().split('\n'); if (index === 0) { mergedLines.push(lines[0]); } mergedLines = mergedLines.concat(lines.slice(1)); }); const output = mergedLines.join('\n'); return [{ json: { mergedCsv: output, fileCount: files.length } }];拿到mergedCsv后,再用 Convert to File 或 Read/Write Files from Disk 写回新文件即可。多文件合并时要注意文件编码一致,如果有的有 BOM、有的没有,合并出来的文件前面可能会多出奇怪字符。稳妥的做法是在合并前统一用.replace(/^\ufeff/, '')去掉 BOM,最后再按业务要求统一加一次 BOM。
7. 读写文件的边界与安全习惯
7.1 路径校验:永远不要信任输入文件名
n8n 工作流可以通过 Webhook 接收外部输入。如果你直接把外部输入的文件名或路径拼进 Read/Write Files from Disk 节点,就可能被路径穿越利用。比如外部传进来一个../../etc/passwd,工作流就有读取系统文件的风险。
所以,所有路径只要是来自用户输入的,都必须做白名单校验。我只允许字母、数字、下划线、连字符和点号,其它一律替换掉:
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');然后还要拼到固定目录下,判断解析后的路径是否仍在允许的根目录内:
const path = require('path'); const baseDir = '/data/inputs'; const resolved = path.resolve(baseDir, safeName); if (!resolved.startsWith(baseDir)) { throw new Error('非法路径'); }这一步多写几行代码,能避免一大堆安全风险。如果工作流是给内部使用的,路径可以写死;一旦开放给外部 Webhook,就必须做校验。
7.2 权限最小化与备份策略
本地文件落盘后,很容易成为被忽略的数据源。我建议从一开始就做好权限规划:n8n 进程专用一个普通用户,文件目录按“n8n 可写、外部程序可读”的最小权限设置,不需要让 root 参与运行。容器部署时,尽量不要使用 root 用户跑 n8n。
目录也不要和 n8n 的配置目录混在一起。n8n 默认数据目录包含工作流、凭证和用户数据,属于高敏感目录;业务文件应该单独放在另一个挂载点,比如/data,两者互不干扰。这样即使业务文件目录被误删,也不会影响 n8n 本身。
文件备份同样不能省。落盘文件如果是生产数据,建议额外用定时任务将/data/reports打包上传到对象存储或另一台服务器。n8n 自己就可以干这个活,读取目录下的所有文件,生成 ZIP 压缩包,再通过对象存储节点上传。
7.3 credentials 和敏感信息不要落盘
最后说一个最容易犯的错误:把凭证打到文件里。n8n 的 credentials 本身有加密存储机制,但如果你在 Code 节点里手动拼装业务数据时,不小心把 HTTP Headers、Authorization 字段、API Key 一起带进了变量,这些信息就可能跟着 CSV 一起落盘。文件一旦被其他人读取或误传到公开服务,凭证就泄露了。
我处理过类似问题:某个工作流需要调用第三方接口,用 Header Auth 凭证认证成功,然后把返回数据写成报表。一开始调试时直接在 Code 节点里把整个 response 对象拆开输出,结果 Header 内容也被带进了临时文件。后来我把 Code 节点改成只保留业务字段,所有认证相关的字段在进入文件生成前全部删掉,才彻底解决。
企业级部署还要注意一个点:n8n 的凭证加密密钥通常存在环境变量里(比如N8N_ENCRYPTION_KEY),这个密钥一定不要出现在工作流生成的文件里,也不要和业务文件放在同一目录。一旦密钥丢失,所有加密的凭证都无法恢复;一旦密钥泄露,凭证也不再安全。
我在实际项目中一直坚持把“文件读写”和“凭证逻辑”分开。读写文件只处理业务数据,认证信息只存在于 HTTP Request 节点和 n8n 的 credentials 存储里。只要守住这条线,n8n 的本地文件能力就是一个非常可靠的自动化底座,而不是一个隐形的风险口。