SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

Traefik reverse proxy: 5 app, một file Compose

Chạy 5 app sau một IP với Traefik v3 và Docker Compose: định tuyến bằng Host, tự cấp TLS Let's Encrypt, cùng lỗi acme.json khiến cấp certificate thất bại.

Một IP, năm app, một cổng 443

VPS của bạn có một địa chỉ IPv4 public và một cổng TCP 443. Bạn muốn chạy Gitea, một bản staging của app, một dashboard nội bộ, một trang trạng thái và một webhook receiver trên đó: năm hostname, một máy. Reverse proxy là process sở hữu :80 và :443, đọc header Host trong mỗi request rồi chuyển request đến đúng container. Traefik làm việc đó, đồng thời tự lấy và gia hạn certificate cho từng hostname mà bạn không cần tự chạy certbot. Nginx và Caddy cũng có thể TLS termination cho cả năm hostname này, nên nếu bạn vẫn chưa chọn proxy thì nên so sánh ba proxy về cách xử lý certificate và chi phí cấu hình cho từng app trước khi kết nối mọi thứ vào một proxy duy nhất.

Điểm khác giữa Traefik và một block server {} của nginx là nguồn cấu hình. Với nginx, bạn sửa file rồi reload; vòng đời certificate vẫn là một công việc riêng. Đây là workflow bạn dùng khi cấp certificate Let's Encrypt bằng certbot trên nginx, trong đó timer gia hạn nằm hoàn toàn bên ngoài web server. Docker provider của Traefik theo dõi Docker event stream và đọc label từ các container: khởi động một container có label chứa rule Host() thì container đó có thể được route trong vòng một giây; dừng container thì route biến mất. Đây cũng là điểm dễ gây lỗi. Cấu hình nằm trong label sẽ bị phân tán ở năm nơi cùng lúc. Một label sai sẽ không báo lỗi; container đơn giản là không được route và Traefik không nói gì.

Bốn thuật ngữ

  • Entrypoint là socket đang listen. Bạn sẽ định nghĩa hai entrypoint: web trên :80 và websecure trên :443.
  • Router so khớp request (Host(...)) và gắn request đó với một service. Traefik yêu cầu certificate cho từng router thông qua tls.certresolver.
  • Service là backend, gồm một container và cổng mà container đó listen bên trong Docker network.
  • Middleware nằm giữa router và service: basic auth, danh sách IP được phép, rewrite header và redirect.

Bốn thuật ngữ này là tên Traefik dùng cho những cấu hình mà trước đây bạn phải viết thủ công: router là một server_name, service là target proxy_pass, còn middleware là các chỉ thị về header và auth mà bạn tự đặt khi từng bước tạo server block reverse proxy trên nginx.

Cấu hình tĩnh (entrypoint, provider, ACME) được truyền cho Traefik qua command line hoặc trong traefik.yml. Khi thay đổi cấu hình này, bạn phải restart Traefik. Cấu hình động (router, service, middleware) được lấy từ container label và hot-reload. Nhầm lẫn hai loại cấu hình này là nguyên nhân thường gặp của lỗi “flag của tôi không có tác dụng”.

Tệp compose

Một Docker network dùng chung có tên proxy là nền tảng của cấu hình. Traefik chỉ truy cập được một container nếu cả hai cùng kết nối vào network đó.

name: edge

networks:
  proxy:
    name: proxy

services:
  traefik:
    image: traefik:v3.7
    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, sau đó đến docker compose logs -f traefik. Mỗi app bổ sung là một bản sao của block gitea, với tên router riêng, Host() riêng và port nội bộ riêng. Một bản cài Nextcloud chạy trong Docker với TLS và backup cũng tích hợp theo cách này: bỏ các port được publish, kết nối app vào proxy, rồi để các router label xử lý hostname và certificate.

Có 5 chi tiết trong cấu hình này đặc biệt quan trọng.

exposedByDefault=false khiến container không hiển thị với Traefik cho đến khi container có traefik.enable=true. Nếu bỏ mục này, mọi container bạn khởi động sau đó, kể cả postgres tạm thời dùng để kiểm tra, đều sẽ được tạo route.

providers.docker.network=proxy cho Traefik biết phải dùng network nào khi container được kết nối vào nhiều network. Nếu bỏ mục này, Traefik có thể chọn sai IP của container. Khi đó sẽ xuất hiện lỗi 502, dễ bị nhầm là lỗi của ứng dụng.

loadbalancer.server.port=3000 là port bên trong container; Gitea lắng nghe tại port 3000 ở đó. Lưu ý rằng không container ứng dụng nào publish port; chỉ Traefik publish port.

Redirect trên entrypoint web mặc định là permanent. Vì vậy, một GET không mã hóa (đây là request mà trình duyệt gửi) sẽ nhận mã 301 chuyển sang HTTPS. Traefik chỉ trả mã 308 cho các method khác, chẳng hạn POST, để giữ nguyên method qua bước chuyển tiếp. Port 80 vẫn phải mở: ACME HTTP challenge cần port này, và người dùng cũng cần nó khi nhập hostname không có scheme.

Hai ký tự $$ liên tiếp trong hash basic-auth là cách Compose escape, không phải lỗi đánh máy. Tạo hash bằng htpasswd -nbB admin 'your-password' (package apache2-utils), sau đó nhân đôi mọi $.

Chứng chỉ và bẫy acme.json

tlschallenge=true chọn TLS-ALPN-01: Let's Encrypt kết nối đến máy chủ của bạn qua 443, còn Traefik trả lời challenge bên trong quá trình bắt tay TLS. Lựa chọn còn lại là HTTP-01 trên cổng 80. Hãy thay dòng tlschallenge trong danh sách command: của Traefik bằng hai dòng sau:

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

Cả hai cách đều hoạt động. Cả hai đều yêu cầu DNS public của hostname đã trỏ đến VPS của bạn, để máy chủ cấp chứng chỉ có thể phân giải tên và kết nối từ bên ngoài. Trước tiên hãy tạo bản ghi A (và AAAA), xác nhận bằng dig +short git.example.com, rồi khởi động Traefik.

Bây giờ là bẫy khiến nhiều người mất cả buổi tối. Traefik lưu account key của ACME và mọi chứng chỉ đã cấp trong một acme.json. Nếu file này cho phép group hoặc mọi user đọc, Traefik sẽ ghi một dòng gần giống như sau vào log rồi loại bỏ resolver. Traefik vẫn chạy và định tuyến, nhưng không yêu cầu chứng chỉ nào, nên mọi hostname đều được phục vụ bằng TRAEFIK DEFAULT CERT:

ERR The ACME resolve is skipped from the resolvers list error="unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600" resolver=le

Cách sửa đúng là cách nêu ở trên: bind-mount directory và để Traefik tự tạo file với mode phù hợp. Nếu bạn đã tạo acme.json bằng touch, umask đã tạo file với quyền 644. Hãy sửa trên host:

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

Hãy backup directory đó cùng với các app volume. Nếu chỉ mất một lần thì vẫn có thể khôi phục; chứng chỉ sẽ được cấp lại. Nhưng nếu xóa directory này trong mỗi lần redeploy, bạn sẽ chạm giới hạn 5 chứng chỉ cho cùng một tập hostname chính xác trong mỗi 7 ngày.

Dùng staging CA trong khi thử nghiệm. Bỏ comment dòng caserver, làm cho mọi route hoạt động, sau đó comment lại dòng này và xóa acme.json để yêu cầu chứng chỉ production mới. Let's Encrypt production cho phép 5 chứng chỉ trùng mỗi tuần cho cùng một tập hostname, đồng thời giới hạn các lần validation thất bại lặp lại đối với cùng một tên. Staging cấp các chứng chỉ không được tin cậy, nên trình duyệt sẽ cảnh báo. Cảnh báo đó cho biết cấu hình đã hoạt động, và staging có các giới hạn thoải mái hơn nhiều.

Dashboard là điểm điều khiển, không phải bản demo

Hầu hết quickstart đều đặt --api.insecure=true, khiến dashboard được phục vụ trên port 8080 mà không có authentication. Trên máy chủ có public IP, cấu hình này để lộ topology định tuyến, hostname, tên middleware và port backend cho bất kỳ ai quét mạng.

Các label trên service traefik ở trên là phương án thay thế: định tuyến dashboard như mọi app khác, qua hostname thực, dùng TLS và đứng sau basicauth. service=api@internal kết nối router với API tích hợp sẵn của Traefik. Có thể tăng mức bảo vệ bằng cách nối thêm IP allow-list theo thứ tự từ trái sang phải. Nếu địa chỉ văn phòng của bạn là dynamic, hãy đặt range thành subnet được cấp bởi một WireGuard VPN tự host trên cùng VPS và chỉ truy cập dashboard qua tunnel:

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

Một password basicauth dùng chung sẽ không còn phù hợp khi 5 app cần các account riêng. Cùng vị trí middleware đó có thể nhận forwardauth để chuyển việc quyết định cho Authentik, một single sign-on server tự host. Nhờ vậy, dashboard và mọi route khác đều nằm sau một lần đăng nhập duy nhất, có thể thu hồi quyền tại một nơi.

Docker socket có quyền root

/var/run/docker.sock là một API có thể tạo container mount / từ host. Có quyền truy cập vào API này tương đương với có quyền root trên máy, và Traefik cần nó để đọc labels.

Giữ :ro trên mount, nhưng cần hiểu rõ tác dụng của tùy chọn này: nó chỉ làm cho socket file ở chế độ chỉ đọc. Nó không ngăn các request POST đến Docker API đi qua socket đó. Biện pháp giảm thiểu thực sự là không bao giờ cấp socket cho Traefik, mà đặt một filtering proxy ở giữa:

  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

Xóa socket volume khỏi Traefik và trỏ provider đến proxy:

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

Traefik vẫn có quyền đọc container và network, nhưng không còn khả năng tạo bất kỳ tài nguyên nào.

Firewall, port và rule mà mọi người thường cấu hình sai

Mở 2 port, cộng thêm SSH:

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

Các port được Docker publish sẽ bypass ufw. Docker chèn rule riêng vào iptables. Các rule này được xử lý trước các chain của ufw. Vì vậy, container khởi động với ports: ["3000:3000"] vẫn có thể được truy cập từ Internet dù đã có rule deny của ufw. Cách bảo vệ nằm ở cấu trúc triển khai, không phải ở cấu hình firewall: chỉ publish port từ Traefik, còn mọi container khác chỉ được cấp networks: [proxy] và không thêm gì khác. Nếu một dịch vụ thực sự phải truy cập host, hãy bind nó vào loopback, "127.0.0.1:3000:3000".

Khắc phục sự cố: các lỗi bạn thực sự sẽ gặp

404 page not found, do Traefik trả về. Không có router nào khớp. Theo thứ tự khả năng xảy ra: container thiếu traefik.enable=true (với exposedByDefault=false đã được đặt); rule Host() không khớp với hostname bạn đã nhập; tên router trong một label khác với tên router trong label khác (routers.gitea.rule và routers.gitea.entrypoints phải là cùng một từ); hoặc bạn đặt hostname trong dấu nháy đơn. Traefik v3 yêu cầu dùng dấu backtick hoặc dấu nháy kép được escape (\") bên trong matcher và sẽ từ chối dấu nháy đơn.

502 Bad Gateway. Một router đã khớp nhưng không thể kết nối đến backend. Gần như luôn là do container không nằm trong network proxy, hãy kiểm tra docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. Khả năng còn lại là loadbalancer.server.port không đúng: bạn đã khai báo port đã publish, hoặc app đang listen ở nơi khác. Log sẽ ghi rõ lần kết nối đó: dial tcp 172.18.0.5:8080: connect: connection refused.

Trình duyệt cảnh báo và chứng chỉ được cấp cho TRAEFIK DEFAULT CERT. Không có chứng chỉ cho hostname đó, nên Traefik đã trả về chứng chỉ tự ký tạm thời. Đọc các dòng 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 chưa trỏ đến máy chủ. Sửa record, chờ TTL hết hạn, rồi restart Traefik.

Invalid response from http://git.example.com/.well-known/acme-challenge/... khi dùng HTTP challenge: port 80 không chuyển tiếp được đến Traefik từ bên ngoài, thường là do firewall ở cấp nhà cung cấp đặt trước VPS, không phải ufw.

Chứng chỉ không bao giờ được cấp và DNS của bạn dùng Cloudflare với đám mây màu cam đang bật. Cloudflare thực hiện TLS termination tại edge của họ, nên TLS-ALPN-01 không thể hoàn tất qua đó. Chuyển record sang DNS-only trong lúc cấp chứng chỉ, hoặc chuyển sang DNS-01 challenge với API token. DNS-01 cũng là challenge duy nhất có thể cấp wildcard.

Vòng lặp redirect. Một thành phần phía trước Traefik đã thực hiện TLS termination rồi chuyển tiếp plaintext đến :80; redirect của entrypoint lại chuyển nó về HTTPS. Hãy bỏ một trong hai redirect.

Duy trì hoạt động

Unit của Docker phải được enable khi boot (systemctl is-enabled docker), và restart: unless-stopped sẽ khởi động lại stack sau khi reboot. Nếu muốn kiểm soát rõ ràng hơn, hãy tạo một systemd unit nhỏ chạy docker compose -f /srv/edge/compose.yml up -d với RemainAfterExit=yes để có systemctl status edge và kiểm soát thứ tự khởi động.

Cố định tag của Traefik (traefik:v3.7, không dùng latest), đồng thời chọn một branch vẫn còn phát hành phiên bản mới. Tính đến 2026-10-12, Traefik liệt kê v3.7 là branch v3 duy nhất còn được hỗ trợ. v3.5 đã phát hành phiên bản cuối là v3.5.6 vào ngày 7 tháng 11 năm 2025. Docker provider của branch này được hardcode ở API version 1.24, nhưng Docker Engine 29 trở lên từ chối version đó với client version 1.24 is too old, nên không container nào được route. Traefik 3.6.1 đã bổ sung cơ chế thương lượng Docker API version. Vì vậy, compose file ở trên dùng v3.7. Việc nâng cấp từ v2 lên v3 đã thay đổi cú pháp rule và tên provider. Một latest không có giám sát vẫn sẽ tự động reload một config mà nó không còn hiểu. Hãy nâng cấp có chủ đích: đọc migration note, tăng tag, docker compose up -d traefik, rồi monitor log. Nếu bạn vẫn đang dùng tag v2, hướng dẫn migration Traefik v2 lên v3 trình bày mọi thay đổi tên, compatibility mode và cách rollback mà vẫn giữ nguyên certificates.

Hãy backup ./letsencrypt và data volume của từng app. Traefik không lưu state nào khác mà bạn không thể dựng lại từ compose file.

Điều gì bị giới hạn khi mở rộng

Giới hạn đầu tiên không nằm ở throughput mà ở một máy duy nhất: một Traefik trên một VPS là điểm lỗi duy nhất cho 5 ứng dụng, còn acme.json là bộ nhớ dạng flat file nên 2 instance Traefik ghi vào đó sẽ làm hỏng dữ liệu. Muốn mở rộng sang nhiều máy, bạn phải chuyển nơi lưu certificate ra khỏi file hoặc thực hiện TLS termination ở một lớp khác.

Giới hạn thứ 2 là các kết nối tồn tại lâu. Server-sent events, file upload lớn và client chậm sẽ gặp timeout phản hồi của entrypoint; --entryPoints.websecure.transport.respondingTimeouts.readTimeout cùng với các tùy chọn liên quan là writeTimeout và idleTimeout. WebSocket đi qua mà không cần cấu hình thêm.

Giới hạn thứ 3 là disk. --accesslog=true ghi vào stdout, còn Docker json-file driver sẽ giữ log vô thời hạn nếu không đặt giới hạn. Đặt logging.options.max-size trên service Traefik hoặc ghi access log vào file rồi rotate file đó.

Không điều gì trong số này cần orchestrator. Bạn chỉ cần một server do mình kiểm soát, có IP public thực và mở cổng 80 cùng 443 cho Internet; một VPS nhỏ duy nhất là toàn bộ danh sách dependency.

FAQ

Tôi còn cần certbot nếu chạy Traefik không?

Không. ACME resolver của Traefik yêu cầu và gia hạn certificate cho mọi hostname mà nó định tuyến, rồi lưu toàn bộ vào acme.json. Certbot vẫn là công cụ phù hợp khi nginx hoặc server khác tự xử lý TLS termination; chạy cả hai cho cùng hostname chỉ làm tiêu tốn rate limit của Let’s Encrypt.

Vì sao container của tôi trả về 404 qua Traefik?

404 do Traefik trả về nghĩa là không có router nào match request. Kiểm tra container có traefik.enable=true hay không (bắt buộc khi đã đặt exposedByDefault=false), giá trị Host() có khớp với tên bạn nhập hay không, và tên router có giống nhau trong tất cả label của app đó hay không. Traefik v3 cũng yêu cầu dùng backtick hoặc escape double quote bên trong matcher; single quote sẽ bị từ chối.

404 và 502 ở đây khác nhau thế nào?

404 nghĩa là việc định tuyến chưa diễn ra; 502 nghĩa là router đã match nhưng backend từ chối kết nối. Các nguyên nhân 502 thường gặp là container chưa được gắn vào network proxy, hoặc loadbalancer.server.port trỏ đến port đã publish thay vì port mà app đang listen bên trong container. Access log cho biết chính xác địa chỉ mà Traefik đã kết nối.

Chỉ mount Docker socket ở chế độ read-only có đủ không?

Flag :ro chỉ làm file socket ở chế độ read-only, không giới hạn API phía sau nó; các request POST vẫn đi qua socket, và quyền truy cập Docker API tương đương quyền root trên host. Cách an toàn hơn là dùng container docker-socket-proxy được nêu ở trên. Container này chỉ cho Traefik đọc thông tin về container và network, đồng thời chặn hoàn toàn các thao tác ghi.

Traefik có thể cấp certificate wildcard không?

Chỉ thông qua challenge DNS-01 và cần API token của nhà cung cấp DNS. TLS-ALPN-01 và HTTP-01 chỉ xác thực một hostname nên không thể tạo wildcard. DNS-01 cũng là lựa chọn khi CDN như Cloudflare xử lý TLS termination phía trước VPS của bạn và 2 challenge còn lại không bao giờ hoàn tất.