使用 Traefik 代理 Strapi

页面摘要: 使用 server.url 和 server.proxy 选项告知 Strapi 它的公开地址以及前方存在代理。然后为 Strapi 容器添加 Traefik 标签,使 Traefik 能够发现它、将流量路由到 1337 端口,并为你的域名申请证书。

Strapi 监听一个普通的 HTTP 端口,自身并不终止 TLS。诸如 Traefik 这样的反向代理位于其前方,用于处理 HTTPS、在 443 端口提供你的应用,并将请求转发给 Strapi 进程。Traefik 与本节中的其他代理有所不同:它不通过描述路由的配置文件来工作,而是监听 Docker API,并从每个容器上的标签读取路由规则。这适合容器不断创建和销毁的部署场景。本指南先介绍让你的应用感知代理的 Strapi 配置,然后是将来流量路由到它的 Traefik 设置。

WARNING
  • 一个能够在本地启动并运行的 Strapi 5 应用(参见 部署指南)。
  • Strapi 已被打包为容器镜像(参见 Docker 安装)。
  • 主机上已安装 Docker Compose。
  • 一个 DNS A 记录指向该主机的域名。
  • 80 和 443 端口对公网开放。Traefik 需要其中至少一个端口来完成证书挑战。

为反向代理配置 Strapi

Strapi 需要知道它对外提供服务的公开地址,并且需要信任代理添加的头部。如果没有这两项设置,Strapi 会从 localhost:1337 构建 URL,并将代理的 IP 地址读取为客户端 IP。

设置公开 URL

服务器配置中的 url 选项定义了应用的公共地址。Strapi 使用它构建密码重置邮件、第三方登录提供方和媒体资源路径的绝对 URL。

将其设置为你应用访问者在浏览器中使用的地址:

JavaScript

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  app: {
    keys: env.array('APP_KEYS'),
  },
});

TypeScript

export default ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  app: {
    keys: env.array('APP_KEYS'),
  },
});

host 值必须保持为 0.0.0.0。如果绑定到 localhost,Strapi 只接受来自自身容器内部的连接,Traefik 将无法访问它。

WARNING

更改 /config/server.js 需要重新构建管理面板。保存文件后请运行 yarn build 或 npm run build,然后重新构建你的镜像。

信任代理头部

Traefik 会添加一个携带原始客户端 IP 地址的 X-Forwarded-For 头部。在你开启代理支持之前,Strapi 会忽略该头部。

通过服务器配置中的 proxy 选项启用代理支持:

JavaScript

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  // highlight-start
  proxy: {
    koa: true,
    maxIpsCount: 1,
  },
  // highlight-end
  app: {
    keys: env.array('APP_KEYS'),
  },
});

TypeScript

export default ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  // highlight-start
  proxy: {
    koa: true,
    maxIpsCount: 1,
  },
  // highlight-end
  app: {
    keys: env.array('APP_KEYS'),
  },
});

每个选项起不同作用:

选项作用
proxy.koa当为 true 时,Strapi 信任 X-Forwarded-* 请求头。客户端 IP、协议和主机从代理读取,而非 socket。
proxy.maxIpsCount(v5.52.0+)从转发请求头链末尾读取的地址数量。单个代理设为 1,若请求经过多个代理则设为代理数量。
proxy.ipHeader(v5.52.0+)用于读取客户端 IP 的请求头。默认为 X-Forwarded-For,仅在代理发送其他请求头(如 CF-Connecting-IP)时设置。
IP 欺骗

将 proxy.koa 设为 true 但未设置 proxy.maxIpsCount 时,计数会保留其默认值 0,即无限制。请将 maxIpsCount 设为 Strapi 前方代理的真实数量,以便只读取由你自己的基础设施添加的地址。

Strapi 读取由 proxy.ipHeader 指定的头部,其默认值为 X-Forwarded-For,Traefik 设置的正是这个头部。Traefik 还会设置 X-Forwarded-Proto,Strapi 使用它来将管理面板的刷新令牌 cookie 标记为 Secure。密码重置、登录提供方回调以及媒体资源的绝对 URL 是基于 url 选项构建的,而非基于该头部。

Traefik 在此处提供的保护并非每个代理都具备。默认情况下,它会清理客户端发送的 X-Forwarded-* 头部并写入自己的值,因此客户端无法向链条中注入伪造地址。该保护依赖于 Traefik 是第一个看到请求的代理。如果 Traefik 前方还有 CDN 或负载均衡器,请在入口点的 forwardedHeaders.trustedIPs 中列出其地址,使 Traefik 保留上游添加的内容,然后在设置 proxy.maxIpsCount 时将链条中的每个代理都计算在内:

entryPoints:
  websecure:
    address: ':443'
    forwardedHeaders:
      trustedIPs:
        - '203.0.113.0/24'

提高上传的体积上限

Traefik 默认会流式将请求体转发给后端,且不设大小上限,除非你主动添加。因此默认适用的是 Strapi 的限制。如果你通过媒体库上传文件,请提高这些限制。

在 Strapi 侧,body 中间件解析传入的请求。上传的文件以 multipart 数据到达,因此 formidable.maxFileSize 是限制它们的选项。formLimit 和 jsonLimit 选项覆盖普通表单字段和 JSON 负载,而非文件本身:

module.exports = [
  // ...
  {
    name: 'strapi::body',
    config: {
      formLimit: '100mb', // form body
      jsonLimit: '100mb', // JSON body
      textLimit: '100mb', // text body
      formidable: {
        maxFileSize: 100 * 1024 * 1024, // uploaded file size, in bytes
      },
    },
  },
  // ...
];

媒体库提供方执行单独的 sizeLimit,默认值为 1 GB。要更改它,请参阅本地上传提供方配置和最大文件大小。

配置 Traefik

Traefik 读取两类配置。静态配置定义入口点和证书解析器,在 Traefik 启动时一次性设置。动态配置定义路由,来自容器标签,并随容器的出现而被拾取。

编写静态配置

以下文件定义了每个公开端口上的入口点,将 HTTP 重定向到 HTTPS,并注册了一个 Let's Encrypt 证书解析器:

entryPoints:
  web:
    address: ':80'
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
  websecure:
    address: ':443'

providers:
  docker:
    # 仅路由显式启用 traefik.enable=true 的容器
    exposedByDefault: false

certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@example.com
      storage: /acme/acme.json
      tlsChallenge: {}

将 exposedByDefault 设为 false 很重要。若保留默认值,Traefik 会发布它能看到的每一个容器,包括你本不打算暴露的那些。

通过标签将流量路由到 Strapi

静态配置就位后,Strapi 容器通过标签声明自己的路由:

services:
  traefik:
    image: traefik:v3.7
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_acme:/acme
    networks:
      - web

  strapi:
    image: my-strapi-app
    environment:
      HOST: 0.0.0.0
      PORT: 1337
      PUBLIC_URL: https://api.example.com
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.strapi.rule=Host(`api.example.com`)'
      - 'traefik.http.routers.strapi.entrypoints=websecure'
      - 'traefik.http.routers.strapi.tls.certresolver=letsencrypt'
      - 'traefik.http.services.strapi.loadbalancer.server.port=1337'
    networks:
      - web

volumes:
  traefik_acme:

networks:
  web:

loadbalancer.server.port 标签是容易被遗漏的一项。Traefik 路由到容器网络内部的一个端口,因此 Strapi 容器自身无需 ports 端口映射。只有 Traefik 向主机发布端口,从而使 Strapi 不暴露到公共接口。

WARNING

此文件中有两个部分的影响超出了 Traefik 本身:

  • 为证书存储路径挂载持久卷,如上所示使用 traefik_acme。Traefik 将签发的证书存储在 acme.json 中。如果没有它,每次容器重启都会请求新证书,从而触及 Let's Encrypt 速率限制,导致站点在限制重置之前没有有效证书。

  • 挂载 Docker socket 会赋予 Traefik 对 Docker API 的完全访问权限,这等同于主机上的 root。上面所示的 :ro 标志作用于 socket 文件而非 API,因此它并不能限制 Traefik 通过 socket 所能执行的操作。应将其视为一种约定,而非边界。请勿将 Traefik 仪表盘公开暴露,对于加固后的部署,请将 socket 置于一个仅暴露 Traefik 所需端点的代理之后。

限制请求体大小

默认情况下 Traefik 不施加请求大小限制。为了在代理层而非在 Strapi 处拒绝过大的上传,请向 Strapi 容器的标签添加 buffering 中间件:

    labels:
      # ...
      - 'traefik.http.middlewares.strapi-limit.buffering.maxRequestBodyBytes=104857600'
      - 'traefik.http.routers.strapi.middlewares=strapi-limit'

超过限制的请求会收到 413 响应。

NOTE

buffering 中间件会在转发之前读取整个请求体,超过阈值时会溢出到磁盘。这会给每次上传增加延迟和磁盘占用,对于接受大文件的媒体库来说是一笔不划算的代价。如果你的上传文件较大,请保留该中间件关闭,改由 Strapi 中的 formidable.maxFileSize 强制执行限制。

验证代理设置

启动整个栈并检查 Traefik 能否访问 Strapi。Strapi 在 /_health 暴露了一个健康检查路由,以 HTTP 204 No Content 和一个 strapi 头部作为响应:

docker compose up -d
curl -I https://api.example.com/_health

正常的设置会返回状态行和头部:

HTTP/2 204
strapi: You are so French!

然后确认链条的其余部分:

  1. 在浏览器中打开 https://api.example.com/admin 并登录。管理面板通过 HTTPS 加载,且拥有有效证书。
  2. 在媒体库中上传一张图片。其 URL 使用的是你的域名,而非 localhost:1337。
  3. 使用 docker compose logs strapi 检查 Strapi 容器日志,确认显示的是真实的客户端 IP 地址,而非 Traefik 容器地址。

故障排查

以下每个症状都指向设置中的某一方面。症状以粗体显示,其后为导致该症状的原因以及需要更改的内容:

  • Traefik 返回 404 page not found。 没有路由器匹配该请求。请确认容器带有 traefik.enable=true,Host() 规则与你请求的域名相匹配,并且两个容器共享同一个 Docker 网络。

  • Traefik 返回 502 Bad Gateway。 Traefik 匹配到了路由但无法访问 Strapi。请确认 loadbalancer.server.port 为 1337,且 Strapi 绑定到 0.0.0.0 而非 localhost。

  • 没有签发证书。 对于 tlsChallenge,证书挑战需要 443 端口可从公网访问,并且域名的 DNS A 记录必须已经解析到该主机。请使用 docker compose logs traefik 检查 Traefik 日志。

  • 证书在每次重启时都被重新签发。 证书存储路径不在持久卷上,因此 acme.json 随容器一起丢失。

  • 上传失败并返回 413 响应。 buffering 中间件的限制低于文件大小。请提高 maxRequestBodyBytes,或移除该中间件,改由 Strapi 中的 formidable.maxFileSize 强制执行限制。

  • Strapi 将 Traefik 容器地址记录为客户端 IP。 proxy.koa 未设为 true,因此 Strapi 读取的是 socket 地址,而非转发的头部。

  • 密码重置邮件链接到 localhost:1337。 url 选项未设置,或仍指向本地地址。请将其设为公开 URL,重新构建管理面板,并重新构建镜像。

后续步骤

后续步骤

查看服务器配置选项

构建 Strapi 镜像

阅读部署指南