SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 更新于 2026-07-19

Traefik 反向代理:一份 Compose 文件跑 5 个应用

在 Docker Compose 上用 Traefik v3 让五个应用共用一个 IP:按 Host 规则路由,自动签发 Let's Encrypt TLS 证书,并避开阻碍启动的 acme.json 权限陷阱。

一个 IP、五个应用、一个 443 端口

您的 VPS 只有一个公网 IPv4 地址和一个 TCP 443 端口。您想在上面跑 Gitea、应用的预发布副本、一个内部仪表盘、一个状态页和一个 Webhook 接收器,五个主机名,一台机器。反向代理(reverse proxy)就是那个占用 :80 和 :443 的进程,它读取每个请求上的 Host 头,再把请求交给正确的容器。Traefik 正是做这件事的,而且它会为每个主机名获取并续期证书,您完全不必手动运行 certbot。

Traefik 与 nginx 的 server {} 块的区别,在于配置从哪里来。用 nginx 时,您编辑一个文件再重载,证书生命周期仍然是一件独立的杂活,也就是您在 用 certbot 为 nginx 签发 Let's Encrypt 证书 时遵循的流程,其中续期定时器完全存在于 Web 服务器之外。Traefik 的 Docker provider 会监听 Docker 事件流,并读取容器上的标签(label):启动一个带有 Host() 规则标签的容器,一秒之内它就可路由;停掉它,路由随即消失。这也正是陷阱所在。存在于标签里的配置同时散落在五个地方,而错误的标签是静默的,容器只是没有被路由,Traefik 什么都不说。

四个名词

  • 入口点(Entrypoints)是监听的套接字。您会定义两个::80 上的 web:443 上的 websecure
  • 路由器(Routers)匹配一个请求(Host(...))并把它挂到一个服务上。证书是按路由器申请的,通过 tls.certresolver
  • 服务(Services)是后端,也就是一个容器以及它在 Docker 网络内部监听的端口。
  • 中间件(Middlewares)位于路由器和服务之间:basic auth、IP 允许列表、请求头改写、重定向。

静态配置(入口点、provider、ACME)通过 Traefik 的命令行或 traefik.yml 传入,改动它意味着重启 Traefik。动态配置(路由器、服务、中间件)来自容器标签,会被热重载。把两者混为一谈,正是“我的参数不起作用”的常见根源。

Compose 文件

一个名为 proxy 的共享 Docker 网络是骨干。只有当 Traefik 和容器都在这个网络上时,Traefik 才能连到该容器。

name: edge

networks:
  proxy:
    name: proxy

services:
  traefik:
    image: traefik:v3.5
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedByDefault=false
      - --providers.docker.network=proxy
      - --entryPoints.web.address=:80
      - --entryPoints.websecure.address=:443
      - --entryPoints.web.http.redirections.entryPoint.to=websecure
      - --entryPoints.web.http.redirections.entryPoint.scheme=https
      - --certificatesresolvers.le.acme.email=you@example.com
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.tlschallenge=true
      # while you iterate, point at staging so a mistake costs nothing:
      # - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
      - --api.dashboard=true
      - --log.level=INFO
      - --accesslog=true
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls.certresolver=le
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$REPLACE$$THIS

  gitea:
    image: gitea/gitea:1  # major-only pin keeps this demo copy-pasteable; pin an exact release in production
    restart: unless-stopped
    volumes:
      - ./gitea:/data
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.http.routers.gitea.rule=Host(`git.example.com`)
      - traefik.http.routers.gitea.entrypoints=websecure
      - traefik.http.routers.gitea.tls.certresolver=le
      - traefik.http.services.gitea.loadbalancer.server.port=3000

docker compose up -d,然后 docker compose logs -f traefik。每多一个应用,就是把 gitea 块复制一份,配上它自己的路由器名、自己的 Host() 和自己的内部端口。一个 在 Docker 里运行、带 TLS 和备份的 Nextcloud 安装 也是同样接入的:去掉它对外发布的端口,把它挂到 proxy 上,让路由器标签去处理主机名和证书。

这里有五个细节值得留意。

exposedByDefault=false 让容器在没有携带 traefik.enable=true 之前对 Traefik 不可见。省掉它,您启动的每一个容器(包括您为了查点东西而随手跑的一次性 postgres)都会被生成一条路由。

providers.docker.network=proxy 告诉 Traefik 当一个容器连接到多个网络时该用哪一个。省掉它,Traefik 可能挑到错误的容器 IP,表现出来就是一个看起来像应用故障的 502。

loadbalancer.server.port=3000 是容器内部的端口;Gitea 在那里监听 3000。请注意,没有任何应用容器对外发布端口,只有 Traefik 发布。

web 入口点上的重定向把明文请求变成一个跳转到 HTTPS 的 308。80 端口无论如何都保持开放:ACME 的 HTTP 挑战需要它,那些直接输入裸主机名的人也需要它。

basic-auth 哈希里成对出现的 $$ 是 Compose 的转义,不是打错了。用 htpasswd -nbB admin 'your-password'(软件包 apache2-utils)生成它,然后把每个 $ 都写成两个。

证书,以及 acme.json 陷阱

tlschallenge=true 选择的是 TLS-ALPN-01:Let's Encrypt 会连接到您机器的 443 端口,Traefik 在 TLS 握手内部应答挑战。另一种是 80 端口上的 HTTP-01,把 Traefik command: 列表里的 tlschallenge 那一行换成这两行:

      - --certificatesresolvers.le.acme.httpchallenge=true
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web

两种都行。两种都要求该主机名的公共 DNS 已经指向您的 VPS,因为证书颁发机构会解析这个名字并从外部发起连接。先创建 A(以及 AAAA)记录,用 dig +short git.example.com 确认,然后再启动 Traefik。

现在说那个能让人搭进去一整个晚上的陷阱。Traefik 把它的 ACME 账户密钥和每一张已签发的证书都放在同一个 acme.json 里。如果这个文件对组或其他人可读,Traefik 会打印一行与下面非常接近的内容并停止运行:

error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600

干净的解决办法就是上面那种:把目录做 bind-mount,让 Traefik 自己用正确的权限创建这个文件。如果您是用 touch 创建的 acme.json,您的 umask 会让它成为 644。在宿主机上修复它:

chmod 600 ./letsencrypt/acme.json
docker compose restart traefik

把这个目录和您的应用卷一起备份。丢了它是可以承受的,证书会重新签发,但一次性重新签发五个主机名会让您撞上速率限制。

在反复调试时使用 staging CA。取消 caserver 那一行的注释,把每条路由都调通,然后再把它注释掉并删除 acme.json,这样生产证书就会全新申请。生产环境的 Let's Encrypt 对同一组主机名每周允许五张重复证书,并且会对同一名字反复失败的验证进行限流。staging 签发的是不受信任的证书,您的浏览器会告警,而那个告警正是它成功的信号,同时它的限制宽松得多。

仪表盘是控制面,不是演示

大多数快速上手教程会设置 --api.insecure=true,它在 8080 端口上无需任何认证地提供仪表盘。在一台带公网 IP 的机器上,这等于把您的路由拓扑、主机名、中间件名称和后端端口交给任何扫描到它的人。

上面 traefik 服务上的那些标签就是替代方案:仪表盘像任何其他应用一样被路由,在一个真实主机名上、通过 TLS、置于 basicauth 之后。service=api@internal 就是把路由器接到 Traefik 内置 API 的那一环。再串上一个 IP 允许列表可以进一步收紧,中间件从左到右生效。如果您办公室的地址是动态的,就把范围设为 您在同一台 VPS 上自建的 WireGuard VPN 分配出来的子网,只通过隧道访问仪表盘:

- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-auth

Docker socket 就是 root

/var/run/docker.sock 是一个 API,它可以创建一个把宿主机的 / 挂载进来的容器。访问它等同于拥有这台机器的 root 权限,而 Traefik 需要它来读取标签。

保留挂载上的 :ro,但要清楚它到底买来了什么:它让socket 文件变成只读。它并不能阻止发往 Docker API 的 POST 请求经过它。真正的缓解办法是永远不把 socket 交给 Traefik,而是在中间放一个做过滤的代理:

  dockerproxy:
    image: tecnativa/docker-socket-proxy   # pin the current tag
    restart: unless-stopped
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      POST: 0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy

从 Traefik 上去掉 socket 卷,并让 provider 指向这个代理:

--providers.docker.endpoint=tcp://dockerproxy:2375

Traefik 保留对容器和网络的读取权限,同时失去创建任何东西的能力。

防火墙、端口,以及人人都会弄错的那条规则

开放两个端口,外加 SSH:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Docker 对外发布的端口会绕过 ufw。Docker 会插入自己的 iptables 规则,这些规则在 ufw 的链之前被求值,因此一个用 ports: ["3000:3000"] 启动的容器,即便旁边就摆着一条 ufw deny,也依然能从互联网访问到。防御手段是结构性的,而不是防火墙配置:只从 Traefik 发布端口,给其他每一个容器 networks: [proxy] 而别无其他。如果某个东西确实必须访问宿主机,就把它绑定到回环地址,"127.0.0.1:3000:3000"

排错:您真的会遇到的错误

404 page not found,由 Traefik 提供。没有路由器匹配上。按可能性从高到低排:容器缺少 traefik.enable=true(在设置了 exposedByDefault=false 的情况下);Host() 规则与您输入的名字不匹配;一个标签里的路由器名与另一个标签里的路由器名不一致(routers.gitea.rulerouters.gitea.entrypoints 必须是同一个词);或者您把主机名包在了引号里而不是反引号里。Traefik v3 要求匹配器内部使用反引号。

502 Bad Gateway。一个路由器匹配上了,但后端不可达。几乎总是因为容器不在 proxy 网络上,用 docker inspect -f '{{json .NetworkSettings.Networks}}' gitea 检查。另一个可能是错误的 loadbalancer.server.port:您给的是一个对外发布的端口,或者应用监听在别处。日志会点明这次尝试:dial tcp 172.18.0.5:8080: connect: connection refused

浏览器告警,而证书签发给了 TRAEFIK DEFAULT CERT该主机名没有任何证书,Traefik 端出了它的自签名占位证书。读一读 ACME 那几行:

unable to obtain ACME certificate for domains "git.example.com" ...
acme: error: 400 ... DNS problem: NXDOMAIN looking up A for git.example.com

DNS 还没有指向这台机器。修正记录,等过 TTL,重启 Traefik。

Invalid response from http://git.example.com/.well-known/acme-challenge/... 出现在 HTTP 挑战上:80 端口没能从外部到达 Traefik,通常是 VPS 前面某个 provider 级别的防火墙,而不是 ufw。

证书始终签发不出来,而您的 DNS 在 Cloudflare 上并开着橙色云朵。Cloudflare 在它的边缘终止 TLS,TLS-ALPN-01 无法穿过它完成。在签发期间把记录设为 DNS-only,或者改用带 API 令牌的 DNS-01 挑战。DNS-01 也是唯一能签发通配符证书的挑战。

重定向循环。Traefik 前面的某个东西已经终止了 TLS 并把明文转发到 :80;入口点的重定向又把它送回 HTTPS。去掉两个重定向中的一个。

让它持续运行

Docker 的服务单元必须设为开机启用(systemctl is-enabled docker),而 restart: unless-stopped 会在重启后把整套栈拉起来。如果想要一个明确的把手,一个运行 docker compose -f /srv/edge/compose.yml up -d 且带 RemainAfterExit=yes 的小型 systemd 单元,能给您 systemctl status edge 和顺序控制。

钉住 Traefik 的标签(traefik:v3.5,永远不要用 latest)。v2 到 v3 的升级改变了规则语法和 provider 名称,一个无人值守的 latest 会兴高采烈地重载一份它已经看不懂的配置。要有意识地升级:读迁移说明,改标签,docker compose up -d traefik,盯着日志。如果您还停在某个 v2 标签上,Traefik v2 到 v3 迁移指南 会带您走过每一处重命名、兼容模式,以及一个能保住证书的回滚方案。

备份 ./letsencrypt 和每个应用的数据卷。除此之外,Traefik 不持有其他任何您无法从 compose 文件重建的状态。

规模变大时会出什么问题

第一道天花板不是吞吐量,而是这台单机:一台 VPS 上的一个 Traefik 是五个应用的单点故障,而 acme.json 是平文件存储,两个 Traefik 实例同时写它会把它写坏。横向扩展意味着把证书存储从文件里挪出去,或者在别处终止 TLS。

第二道是长连接。服务器发送事件(SSE)、大文件上传和慢客户端会撞上入口点的响应超时;--entryPoints.websecure.transport.respondingTimeouts.readTimeout 及其 writeTimeoutidleTimeout 兄弟项就是这些旋钮。WebSocket 无需额外配置即可通过。

第三道是磁盘。--accesslog=true 写入 stdout,而 Docker 的 json-file 驱动会永远保留它,除非加以限制。在 Traefik 服务上设置 logging.options.max-size,或者把访问日志写到文件里并轮转它。

这些都不需要一个编排器。它确实需要一台由您掌控、带真实 IP 且 80 和 443 端口对全世界开放的服务器,一台单独的小 VPS 就是全部的依赖清单。

FAQ

如果我跑 Traefik,还需要 certbot 吗?

不需要。Traefik 的 ACME resolver 会为它路由的每个主机名申请并续期证书,并把这一切都存进 acme.json。当由 nginx 或其他服务器自己终止 TLS 时,certbot 仍然是合适的工具;对同一批主机名同时跑两者,只会白白消耗 Let's Encrypt 的速率限制。

为什么我的容器透过 Traefik 返回 404?

由 Traefik 提供的 404 意味着根本没有路由器匹配上这个请求。检查容器是否携带 traefik.enable=true(一旦设置了 exposedByDefault=false 就是必需的),Host() 的值是否与您输入的名字匹配,以及路由器名在该应用的每一个标签里是否完全一致。Traefik v3 还要求匹配器内部使用反引号,而不是引号。

这里的 404 和 502 有什么区别?

404 意味着路由从未发生;502 意味着一个路由器匹配上了,但后端拒绝了连接。502 的常见嫌疑是:容器没有挂到 proxy 网络上,以及 loadbalancer.server.port 指向了一个对外发布的端口,而不是应用在容器内部监听的端口。访问日志会点明 Traefik 拨号的确切地址。

把 Docker socket 以只读方式挂载就够了吗?

:ro 标志让 socket 文件变成只读,而不是它背后的 API,POST 请求依然会经过它,而 Docker API 访问等同于宿主机的 root 权限。更稳妥的安排是上面展示的 docker-socket-proxy 容器,它只向 Traefik 暴露容器和网络的读取,并彻底拦截写入。

Traefik 能签发通配符证书吗?

只能通过 DNS-01 挑战,并配上您 DNS 提供商的 API 令牌。TLS-ALPN-01 和 HTTP-01 各自只验证单个主机名,无法产出通配符。当像 Cloudflare 这样的 CDN 在您的 VPS 前面终止 TLS、而另外两种挑战始终无法完成时,DNS-01 也是那个答案。