如果你自己部署过 n8n,应该很快会遇到一个很基础但又绕不开的需求:把工作流里的数据写到服务器本地,或者把服务器上的文件读进来继续处理。比如每天定时从某个接口拉一份 JSON,存到磁盘留档;又比如批量读取一批 CSV,清洗后写成 Excel 再发邮件。这些操作听起来简单,但在 n8n 工作流里并不是无脑拖一个节点就能跑通,尤其是当你用 Docker 部署、目录没挂载、或者路径理解错的时候,报错能让你怀疑人生。这篇内容我以“n8n 读写本地文件”为主线,把内置文件节点、Code 节点、Execute Command 三种方案、服务器部署 n8n 时的挂载与权限问题,以及我踩过的坑一起整理出来。无论你是刚接触 n8n 的使用教程型新手,还是已经在考虑企业级部署方案的老手,这部分内容应该都能帮你少走不少弯路。
1. 先搞懂 n8n 本地文件读写为什么和想象中不一样
1.1 在 n8n 工作流里,“文件”到底是什么形态
很多第一次接触 n8n 的人会下意识地以为,读写文件就像在操作系统里用记事本打开文件一样直接。但 n8n 是一套基于节点的工作流引擎,节点之间传递的不是文件描述符,而是 JSON 对象。一个节点可以把文件内容包装成二进制字段(Binary Data),传给下游节点;下游节点也可以把二进制字段再落回磁盘。
举个例子,你用 Read Binary Files 节点读取了一个 a.txt,这个节点输出的不是“文件路径”,而是一个包含 data 字段的对象,data 字段里放的是文件的二进制内容。你要是想把这个内容再写到另一个路径,就需要用 Write Binary File 节点,把上游的 data 字段指定为输入,再填一个目标路径。理解这一点非常关键,因为它决定了你在 n8n 中处理文件时的核心思路:文件内容是可以被打包、转换、合并、传递的“数据载荷”,而不是某个必须在某个位置存在的实体。
换句更直白的话说,n8n 里的本地文件操作,本质上是“二进制数据的搬运”。节点之间处理的是数据流,不是文件流。所以你在配置节点时,最需要关注的是:上游有没有输出二进制字段,下游要从哪个字段读取二进制内容。
1.2 云版和自托管版,文件读写能力完全不同
n8n 有两种常见的使用方式:SaaS 云版和自己部署在服务器上。你可能在云版里找了半天都没找到“本地文件”相关节点,这是正常的,因为云版运行在托管环境中,用户无法访问底层服务器的文件系统,所以本地文件读写节点在云版里没有意义。
只有当你采用服务器部署 n8n 的方式,把 n8n 跑在自己能控制的机器或容器里,才能真正操作这台机器上的文件。换句话说,“读写本地文件”这个能力天然就是自托管环境的特权。如果你用的是云版,但又有文件暂存需求,一般得配合对象存储(比如 S3、OSS)或者一些中间件来完成,而不是直接读服务器磁盘。
这条限制也直接影响了 n8n 企业级部署方案的设计。在做企业内部流程时,很多团队会用 Docker Compose 部署一个 n8n 实例,然后让工作流直接读写服务器上某个共享目录,与现有业务系统交换文件。如果你还没确定自己要用哪种部署方式,建议先把这一点想清楚:你的文件路径最终落在哪台机器上,决定了后面所有节点该怎么配。
1.3 文件读写报错,八成以上是“路径认知”出了问题
我在实际用 n8n 的过程中发现,真正操作节点本身其实不难,难的是搞清楚“路径”到底指的是哪里的路径。n8n 是一个 Node.js 应用,启动后会有一个默认工作目录,在官方 Docker 镜像里通常是 /home/node。如果你的节点里写了一个相对路径,比如 ./data/input.csv,那它其实是相对于 n8n 进程工作目录的,而不是相对于你浏览器里看到的某个目录。
更隐蔽的是 Docker 部署造成的路径割裂。你在宿主机上看到 /root/n8n-data/input.csv,但在容器内部,这个目录可能被挂载成了 /data/input.csv。n8n 容器内只能看到容器内的路径,它并不知道宿主机上的 /root 是什么。很多新手第一次跑通读文件时报 No such file or directory,原因往往是:文件在宿主机上明明存在,但容器内没有这个路径,或者路径前缀对不上。
我的习惯是:不管在哪个环境,节点里的文件路径都写绝对路径,并且只使用挂载进容器的固定目录,比如 /data。这样至少能把“路径语义”这个变量控制住,剩下的权限问题、编码问题再逐个排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实操前先选定方案:内置节点、代码节点还是命令节点
2.1 内置 Read Binary Files / Write Binary File 节点怎么配
在 n8n 节点面板里搜索 “Read/Write Files from Disk”,你会看到两个最基础的文件节点:Read Binary Files 和 Write Binary File。它们都属于官方内置节点,配置不复杂,但有几个地方很容易忽略。
Read Binary Files 节点支持两种输入方式。第一种是把输入数据设为“String”,然后在下面的路径列表里填一个或多个文件路径,每行一个。这种方式适合提前知道要读哪些文件的场景。第二种是把输入数据设为“Binary Data”,然后告诉节点“从上游哪个字段拿到二进制内容”,这种方式适合把前一个节点生成的内存文件落盘,或者处理循环中动态出现的文件内容。
在 Read Binary Files 的 Options 里,有几个值得注意的设置:
- Ignore Errors:如果某个文件读不到,勾选后不会中断整个工作流,适合批量扫描场景。
- Encoding:默认 utf8,但如果你读的是老旧的 GBK 编码文本文件,可能需要手动改成对应编码,否则读出来就是乱码。
- Max File Size:为了防止超大文件把内存撑爆,可以限制单文件大小。
Write Binary File 节点则更简单直接。它的核心参数有三个:Input Binary Field 指定上游二进制数据所在的字段名(默认是 data),File Name 指定要写入的绝对路径,Options 里可以选择是否覆盖已有文件、是否追加写、以及文件权限。需要注意,如果有多个二进制项流经这个节点,并且它们都用了同一个文件名,后面的写入会覆盖前面的内容。
2.2 用 Code 节点读写文件,灵活度直接拉满
内置节点的问题是:你只能在可视化的参数框里做选择,遇到需要动态拼接路径、批量遍历目录、读取后做复杂解析的情况,会捉襟见肘。这时候我一般会直接上 Code 节点,用 JavaScript 操作文件系统。
例如,读取一个文本文件并把内容输出给下游:
javascript复制const fs = require('fs');
const content = fs.readFileSync('/data/input.txt', 'utf8');
return [{ json: { content: content, length: content.length } }];
写入文件也同理:
javascript复制const fs = require('fs');
fs.writeFileSync('/data/output.txt', 'hello from n8n');
return [{ json: { ok: true, path: '/data/output.txt' } }];
Code 节点最大的优势是可以自由编写逻辑。比如你想读取某个目录下所有 .csv 文件,内置节点没法直接列目录,但 Code 节点可以:
javascript复制const fs = require('fs');
const path = require('path');
const dir = '/data/input';
const files = fs.readdirSync(dir).filter(file => file.endsWith('.csv'));
return files.map(file => ({
json: {
path: path.join(dir, file),
filename: file
}
}));
这样生成一个文件路径数组,下游就可以把每一个 path 字段交给 Read Binary Files 节点,或者直接用 Code 节点把文件内容读取出来,一次性做清洗和转换。
不过用 Code 节点要记住一个原则:尽量做纯数据处理,不要把账号密码硬编码进去。如果你需要调用外部接口,应当使用 n8n 的 Credentials 机制,而不是在代码里写死 key。这个细节放到后面专门说。
2.3 Execute Command 节点:用系统命令处理大文件和批量任务
除了内置节点和 Code 节点,n8n 还有一个容易被忽视的“外挂方案”:Execute Command 节点。这个节点允许你直接执行系统命令,比如 ls、cat、cp、tar,相当于在 n8n 工作流里开了一个 shell。
为什么需要它?因为有些文件操作如果用 Node.js 代码写,反而不如几条 shell 命令高效。比如你要把一个目录下的所有日志文件打包压缩,用 Code 节点得调 child_process,还得处理各种路径转义,但 Execute Command 节点里直接写:
bash复制cd /data && tar -czf backup.tar.gz ./logs
执行完,你就能在 /data 下拿到 backup.tar.gz。如果你要批量复制或重命名大量文件,shell 的 for 循环也比 JavaScript 文件遍历更直白。这里提醒一句:Execute Command 节点只适用于自托管部署,云版同样用不了,因为它建立在能访问服务器 shell 的前提下。
使用 Execute Command 时有几个安全习惯值得养成:
- 不要直接拼接用户输入到命令里,尽量用节点面板的“表达式”把参数传进去,避免注入风险。
- 命令执行失败时,默认会抛出错误并中断工作流,如果需要容错,可以在选项里开启“Ignore Errors”,然后通过输出字段判断结果。
- 命令的工作目录也是 n8n 进程的工作目录,不是任意目录。建议命令里都写绝对路径,或者先
cd到目标目录。
2.4 三种方式怎么选:一张表说清楚
| 方案 | 灵活度 | 上手难度 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| 内置 Read/Write Files 节点 | 中 | 低 | 标准文件读写、简单批量 | 路径配置必须写对,动态生成路径不方便 |
| Code 节点 | 高 | 中 | 需要自定义解析、循环、目录遍历 | 注意数据量,超大文件要分块处理 |
| Execute Command 节点 | 高 | 中 | 系统命令、Shell 脚本、打包压缩 | 只能在自托管环境使用,注意命令注入风险 |
在实际项目中,我一般是混合使用。比如用 Code 节点生成文件清单,再用内置 Read Binary Files 节点读取,最后用 Code 节点做业务处理,或者直接 Execute Command 做归档。节点不是越高级越好,而是看哪个环节匹配你的真实操作成本。
3. 服务器部署 n8n 后,本地文件读写要打通这四件事
3.1 Docker Compose 挂载目录的正确姿势
如果你准备在服务器部署 n8n,最稳妥的方式是用 Docker Compose。n8n 官方镜像默认把数据存在 /home/node/.n8n,所以至少要挂载这个目录,保证工作流和凭证不会在容器重建后丢失。但如果你的工作流还要读写其他本地文件,就必须额外增加挂载项。
一个比较典型的 docker-compose.yml 长这样:
yaml复制services:
n8n:
image: n8nio/n8n
restart: always
ports:
- "5678:5678"
environment:
- N8N_DATA_FOLDER=/home/node/.n8n
volumes:
- n8n_data:/home/node/.n8n
- ./local-data:/data
volumes:
n8n_data:
这里的关键是 ./local-data:/data 这一行。它把宿主机上当前目录下的 local-data 文件夹,映射到容器内的 /data。之后你在 n8n 节点里写 /data/input.csv,实际访问的就是宿主机 ./local-data/input.csv。这样既不会污染 n8n 自己的配置目录,又方便你在宿主机上直接管理文件。
很多人会在这一步犯迷糊,写出了 /home/node/.n8n/data 或者 /root/data 这样的路径,然后容器重启后文件全没了。记住一条铁律:容器内可见的路径范围,只限于挂载进容器的那些目录;凡是没挂载的宿主机路径,n8n 一律不认。
3.2 目录权限与规划建议
文件路径通了,下一个坑就是权限。n8n 官方镜像默认以 UID 1000 的用户运行,如果你在宿主机上创建的目录属主是 root,或者权限是 700,容器里的 n8n 进程很可能没有读取或写入的权限。
最简单的处理方法是给共享目录设置合适的属主:
bash复制mkdir -p ./local-data/input ./local-data/output ./local-data/archive
chown -R 1000:1000 ./local-data
如果你只是想快速验证,也可以直接 chmod -R 777 ./local-data,但这在生产环境里不推荐,毕竟 777 意味着所有用户都能读写,存在安全隐患。更好的做法是尽量细分目录:输入文件放 input,输出文件放 output,处理完留档放 archive。这样即使某个工作流出问题,也不会把输入和输出混在一起,排查时能省不少时间。
3.3 用 Webhook + Header Auth 安全触发文件处理工作流
实际项目中,“读写本地文件”很少是纯手动的,更多时候是由外部系统触发。比如上游系统生成完数据文件后,通过 HTTP 请求通知 n8n 开始处理。这时候你通常会用到 Webhook 节点。
Webhook 节点本身是公开暴露的 URL,如果不加认证,谁都可以往这个地址发请求,轻则白白触发几次工作流,重则被人恶意刷文件处理任务。所以一定要在 Webhook 节点上配认证。n8n 支持多种认证方式,其中针对服务间调用最常用的就是 Header Auth。
配置方法是:先在 n8n 的 Credentials 里新建一个 Header Auth 类型凭证,设置一个 Header 名称和值,比如 X-API-Key: your-secret-key。然后在 Webhook 节点的“Authentication”里选择 “Header Auth”,并绑定刚才创建的凭证。这样外部调用方必须在请求头里带正确的 X-API-Key,n8n 才会继续执行后续的文件处理节点。
为什么建议用 Header Auth 而不是 Query Auth?因为 URL 中的 query 参数容易出现在访问日志、反向代理日志、浏览器历史里,密钥容易泄露。而放在 Header 里相对更安全,也符合多数系统间认证的常见姿势。这里也顺带回应一下 n8n header auth 这个热词——它不是一个独立节点,而是 Webhook 节点的一种凭证类型,用之前得先在建 Credentials 的地方建好。
3.4 在本地文件流程中正确使用 n8n Credentials 和表达式
再往深一层,Credential 不仅仅用于 Webhook 认证。当你在 n8n 工作流里需要调用外部 API,然后把结果保存为本地文件时,Credentials 的作用就更明显了。比如 HTTP Request 节点里配置好某种认证方式,请求返回的 JSON 数据就可以转成二进制文件写到本地。这样账号信息不会散落在各个工作流里,而是统一放在 Credentials 中心,方便管理和轮换。
与此同时,文件路径和文件名不要写死在节点里。n8n 的表达式支持读取环境变量,写法是 {{ $env.变量名 }}。比如你在 docker-compose 里配置了 DATA_DIR=/data,那么节点里可以写 {{ $env.DATA_DIR }}/output.json,而不是直接写死 /data/output.json。这样以后目录变了,只需要改环境变量,不用逐个节点修改。
以我自己的习惯,一个比较规范的本地文件读取节点配置是这样的:
- Read Binary Files 的路径来自上一个 Code 节点输出的字段,而不是手动输入字符串。
- Write Binary File 的 File Name 永远用表达式拼接,至少包含日期或执行 ID。
- 凡是涉及外部接口的调用,都通过 Credential 引用,而不是在 Code 节点里硬编码 token。
这套组合在 n8n 企业级部署方案里很常见:外部 API 读取数据、安全认证、本地文件落盘、按时间目录归档。可维护性比“所有参数全部写死”的版本高出一个档次。
4. 一份可以直接抄的 n8n 工作流:批量读取 CSV、处理并写回
4.1 工作流整体结构
说了这么多理论,下面用一条完整的 n8n 工作流把流程串起来。这个场景很典型:批量读取 /data/input 下的多个 CSV 文件,给每行数据加一个处理时间,然后写回 /data/output 目录。
节点顺序如下:
- Manual Trigger:手动触发,方便调试。
- Code 节点:列出
/data/input下所有 CSV 文件路径。 - Read Binary Files:根据上游给出的路径列表读取文件。
- Extract From File:把 CSV 内容解析成 JSON 数组。
- Code 节点:给每条数据添加上
processedAt字段。 - Convert to File:把处理后的 JSON 再转回 CSV 二进制。
- Write Binary File:写到
/data/output目录。
这套流程里,真正的业务逻辑不复杂,但每个节点的数据衔接很值得仔细看。
4.2 第一步:用代码节点生成文件清单
为什么不用 Read Binary Files 直接填多个路径?因为路径是动态的,你不知道目录里到底有几个文件。用 Code 节点列目录更灵活。
javascript复制const fs = require('fs');
const path = require('path');
const dir = '/data/input';
const files = fs.readdirSync(dir).filter(file => file.endsWith('.csv'));
return files.map(file => ({
json: {
path: path.join(dir, file),
filename: file
}
}));
这段代码执行后,会输出一个数组,每一项包含 path 和 filename 两个字段。如果目录下没有 CSV 文件,它会输出一个空数组。为了不浪费执行时间,你可以在后面加一个 If 节点判断数组长度,为 0 时直接结束,不是 0 才继续后续处理。
4.3 第二步:读取文件并解析 CSV
把 Code 节点的输出连接到 Read Binary Files 节点,然后在 Read Binary Files 节点配置里,把“Input Data”选为“String”,并且让“Path”通过表达式引用上游字段:
code复制{{ $json.path }}
这样每个 CSV 文件都会作为一个独立的二进制项进入下游。注意,这里上游如果输出了多个 JSON 对象,Read Binary Files 会分别对每个对象读取对应路径,非常符合批量处理的需求。
接下来接一个 Extract From File 节点。它的作用是读取二进制内容,并按文件类型解析。因为我们的文件是 CSV,所以选择“CSV”类型,输出就是 JSON 数组,数组里的每个元素是一行数据。如果是 JSON 文件,也可以选 JSON 类型,让 n8n 自动把文本解析成对象。
4.4 第三步:处理数据并转换回文件
现在到了业务处理环节。你可以用 Code 节点给每条数据加字段:
javascript复制const items = $input.all();
return items.map(item => {
const row = item.json;
row.processedAt = new Date().toISOString();
return { json: row };
});
这段代码会遍历所有输入项,给每一行 CSV 数据都加上 processedAt 字段。之后再接一个 Convert to File 节点,把 JSON 数据再转回 CSV 格式,输出二进制字段。这一步相当于把“数据”重新包装成“文件”。
最后接 Write Binary File 节点。节点配置里:
- Input Binary Field:填
data,因为 Convert to File 节点默认输出的二进制字段名就是data。 - File Name:用表达式构造,比如:
code复制/data/output/{{ $json.filename }}-{{ Date.now() }}.csv
这样写出来的文件名会带上时间戳,避免重名覆盖。要注意的是,此时流经节点的每个 JSON 对象都可能带有不同的 filename 字段,所以文件名表达式要结合当前数据项的字段来拼,而不是写一个静态名字。
4.5 时间戳与唯一命名:看起来小,踩坑不少
文件命名是个容易忽略的细节。如果你直接写 /data/output/result.csv,多条数据并行处理时,后面写入的会覆盖前面的,最后只留下一份文件。我在开始阶段就吃过这个亏,明明处理了 10 个文件,最后 output 目录里只剩 1 个文件。
解决办法通常有三种:
- 使用
{{ Date.now() }}加毫秒时间戳,基本能保证同一秒内不重复。 - 使用 n8n 自带的
{{ $executionId }}变量,每次执行唯一。 - 使用
{{ $runIndex }}配合循环批量重命名。
我个人最常用的是时间戳加原始文件名组合,比如 /data/output/{{ $json.filename }}-{{ Date.now() }}.csv。这样一眼就能看出这个文件是从哪个源文件来的,处理时间也清楚。
5. 常见问题与排查技巧实录
5.1 文件明明在宿主机,n8n 却报 No such file or directory
这是最常见的问题,没有之一。文件在宿主机上能看到,但工作流一跑就报文件不存在。排查思路按顺序来:
第一,确认容器挂载。在服务器上执行:
bash复制docker ps
docker exec -it <容器ID> ls /data
如果容器内列表里没有你要的文件,那说明挂载路径不对,或者文件没放在宿主机对应的目录里。
第二,确认路径写的是容器内路径。你在节点里填的路径,必须能在容器内被解析到。如果你填了 /root/n8n-data/input.csv,但容器内根本没有 /root/n8n-data,自然读不到。
第三,确认权限。即使路径存在,如果 n8n 进程用户没有读取权限,也会报权限错误。这时可以 chown -R 1000:1000 修正属主。有一种很隐蔽的情况是:文件所有者没问题,但父目录的权限是 700,导致无法逐级访问,这时需要检查整条路径上每一层目录的权限。
5.2 写入没有报错,但宿主机上找不到文件
写入类的问题比读取类更隐蔽。Write Binary File 节点执行后,日志里显示成功,但你在宿主机上怎么都找不到那个文件。原因几乎都是:n8n 容器把文件写到了容器内的某个目录,但这个目录并没有挂载到宿主机。
比如你在节点里填了 /home/node/output.txt,而容器里 /home/node 虽然存在,但只有 .n8n 子目录挂载到了宿主机,output.txt 实际写在容器可写层里。容器一删除,这个文件就彻底消失了。解决方法是统一把输出路径写到挂载目录(比如 /data/output.txt),并且可以在 Write Binary File 后面接一个 Code 节点,用 fs.existsSync 验证文件是否存在,作为兜底检查。
5.3 CSV 中文乱码与文件名乱码
读 CSV 时中文乱码,多半是因为文件编码不是 UTF-8。旧系统导出的 CSV 经常是 GBK 或 GB18030 编码,n8n 默认按 UTF-8 解析,自然会出现乱码。解决方案有两种:
- 在读取阶段把 Encoding 改成对应的编码。Read Binary Files 节点的 Options 里有 Encoding 配置,填
gbk或实际编码。 - 用 Code 节点配合 iconv-lite 之类的库做转码,适合需要精细控制的场景。
文件名乱码相对少一些,但我也遇到过。主要表现为:在宿主机上是中文文件名,到 n8n 容器里变成一串乱码。这通常是容器内 locale 环境变量没有设置中文支持导致的。可以在 docker-compose 的环境变量里加:
yaml复制environment:
- LANG=C.UTF-8
- LC_ALL=C.UTF-8
设置后重启容器,中文文件名基本就能正常识别了。
5.4 常见问题速查表
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 读取报 No such file or directory | 容器路径未挂载 / 路径写错 | 检查挂载,使用容器内绝对路径 |
| 读取报 EACCES | 权限不足 | 设置目录属主 UID 1000,或调整权限 |
| 写入成功但宿主机找不到 | 写到了未挂载的容器路径 | 把输出路径改到挂载目录 |
| 中文内容乱码 | 文件编码不是 UTF-8 | 调整读取节点的 Encoding 配置 |
| 文件名乱码 | 容器 locale 不支持中文 | 设置 LANG=C.UTF-8、LC_ALL=C.UTF-8 |
| 多个文件互相覆盖 | 文件名重复 | 文件名中加入时间戳或执行 ID |
| 大文件读入导致内存高 | 单次读取文件过大 | 用 Execute Command 或分块处理 |
5.5 最后说一个我的排查习惯
在我自己的项目里,最常出现问题的不是节点不会配,反而是“以为文件写成功了”。所以后来我养成了一个习惯:凡是写文件的关键节点后面,一定跟一个 Code 节点把文件读回来做校验,确认内容长度、关键词存在后再继续往下走。
javascript复制const fs = require('fs');
const content = fs.readFileSync('/data/output.txt', 'utf8');
return [{ json: { exists: true, length: content.length } }];
这个习惯帮我挡掉了很多次“看似成功实则空文件”的事故。如果你也在 n8n 本地文件读写上碰到过类似的坑,不妨从检查路径和挂载开始排查,大部分问题都能在这两步里找到答案。
