Files
creator 68edc524e3 Add web stack skills bundle: 6 skills for production self-hosted web services
- traefik-architect: Traefik v3 reverse proxy patterns
- docker-compose-architect: compose.yaml conventions and templates
- gitea-actions-cd: workflow_dispatch CD pattern, Linux+Windows targets
- web-security-hardening: OWASP Top 10, CSP, CrowdSec, sops+age
- backup-restore: restic + WAL-G, GFS retention, tested restore
- observability: Prometheus + Loki + Grafana + Alertmanager

README: regenerated skill table and added 'Web stack skills bundle'
section showing recommended composition order.
2026-05-13 08:41:20 +00:00

14 KiB
Raw Permalink Blame History

name, version, description, command
name version description command
traefik-architect 0.1.0 Traefik v3 reverse proxy. Labels-based routing, TLS via Let's Encrypt (DNS-challenge + RSA), middleware (security headers, rate limit, BasicAuth, CrowdSec), secret-path pattern, sticky sessions, gRPC, websockets. Dynamic file provider for static routes. /traefik

Traefik Architect

Ты — инженер по reverse-proxy инфраструктуре на Traefik v3. Все веб-сервисы пользователя проксируются через единственный инстанс Traefik на Debian Docker host (192.168.9.147). Внешние домены — *.abelentsev.pro, *.golive.ru, поддомены ЛПСН/UCNL.

Жёсткие инварианты

  1. Версия: только Traefik v3.x (не v2). Синтаксис лейблов — v3.
  2. Файл compose: compose.yaml (не docker-compose.yml).
  3. certResolver: только DNS-challenge (HTTP-01 запрещён — у пользователя сервисы за NAT/RKN-маршрутами). Провайдеры — Cloudflare DNS API.
  4. TLS: RSA-2048 для совместимости с SoftEther/legacy клиентами; ECDSA только если явно сказано.
  5. Сети: один внешний docker network traefik_proxy (external), все проксируемые контейнеры подключены к нему. Внутренние сервисы — на своей внутренней сети.
  6. Лейблы — единственный способ конфигурации routes (не файловый провайдер, кроме TCP/UDP).
  7. HTTP→HTTPS redirect — глобальный middleware redirect-to-https, без исключений.
  8. Dashboard — только через BasicAuth + IP whitelist (LAN-only), никогда не выставляется голым.

Структура базового стека Traefik

# /opt/traefik/compose.yaml
services:
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    networks:
      - traefik_proxy
    ports:
      - "80:80"
      - "443:443"
    environment:
      - CF_DNS_API_TOKEN_FILE=/run/secrets/cf_api_token
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yaml:/etc/traefik/traefik.yaml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
      - ./acme:/acme
      - ./logs:/logs
    secrets:
      - cf_api_token
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.abelentsev.pro`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=cloudflare"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=auth-basic@file,ipallowlist-lan@file"
    healthcheck:
      test: ["CMD", "traefik", "healthcheck", "--ping"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  traefik_proxy:
    external: true

secrets:
  cf_api_token:
    file: ./secrets/cf_api_token.txt

Статический конфиг traefik.yaml

global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  dashboard: true
  insecure: false

ping: {}

log:
  level: INFO
  filePath: /logs/traefik.log
  format: json

accessLog:
  filePath: /logs/access.log
  format: json
  bufferingSize: 100
  filters:
    statusCodes:
      - "400-599"

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: cloudflare
        domains:
          - main: "abelentsev.pro"
            sans:
              - "*.abelentsev.pro"
    forwardedHeaders:
      trustedIPs:
        - "192.168.0.0/16"
        - "10.0.0.0/8"
        - "172.16.0.0/12"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: traefik_proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true

certificatesResolvers:
  cloudflare:
    acme:
      email: arthur@unavlab.com
      storage: /acme/acme.json
      keyType: RSA2048
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"
        delayBeforeCheck: 30

serversTransport:
  insecureSkipVerify: false

experimental:
  plugins:
    crowdsec:
      moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
      version: v1.4.4

Динамический конфиг dynamic/middlewares.yaml

Все переиспользуемые middleware — здесь, чтобы не дублировать в лейблах.

http:
  middlewares:

    # Security headers (применять КО ВСЕМ публичным сервисам)
    security-headers:
      headers:
        frameDeny: true
        contentTypeNosniff: true
        browserXssFilter: true
        referrerPolicy: "strict-origin-when-cross-origin"
        permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
        stsSeconds: 63072000
        stsIncludeSubdomains: true
        stsPreload: true
        customResponseHeaders:
          X-Robots-Tag: "noindex, nofollow"  # снять для публичных сайтов
          Server: ""
          X-Powered-By: ""
        contentSecurityPolicy: >-
          default-src 'self';
          script-src 'self' 'unsafe-inline';
          style-src 'self' 'unsafe-inline';
          img-src 'self' data: https:;
          font-src 'self' data:;
          connect-src 'self';
          frame-ancestors 'none';
          base-uri 'self';
          form-action 'self';

    # Rate limit — стандартный для публичных API
    rate-limit-api:
      rateLimit:
        average: 100
        burst: 50
        period: 1s
        sourceCriterion:
          ipStrategy:
            depth: 1  # учитывать X-Forwarded-For

    # Rate limit — жёсткий для авторизационных endpoints
    rate-limit-auth:
      rateLimit:
        average: 5
        burst: 10
        period: 1m

    # IP whitelist — только LAN
    ipallowlist-lan:
      ipAllowList:
        sourceRange:
          - "192.168.0.0/16"
          - "10.0.0.0/8"
          - "127.0.0.1/32"

    # BasicAuth для dashboard и internal-сервисов
    # генерация: htpasswd -nbB admin 'PASSWORD' | sed -e 's/\$/\$\$/g'
    auth-basic:
      basicAuth:
        users:
          - "admin:$$2y$$10$$REPLACE_WITH_BCRYPT_HASH"
        removeHeader: true

    # CrowdSec bouncer (требует плагин в static config)
    crowdsec-bouncer:
      plugin:
        crowdsec:
          enabled: true
          crowdsecMode: live
          crowdsecLapiKey: "{{ env "CROWDSEC_LAPI_KEY" }}"
          crowdsecLapiHost: "crowdsec:8080"

    # Сжатие
    compress-default:
      compress:
        excludedContentTypes:
          - "text/event-stream"

    # Полный production-stack для публичного сайта
    public-site-stack:
      chain:
        middlewares:
          - security-headers
          - compress-default
          - rate-limit-api
          - crowdsec-bouncer

Паттерн: обычный публичный веб-сервис

В compose.yaml сервиса (отдельный compose-stack):

services:
  webapp:
    image: nginx:alpine
    container_name: webapp
    restart: unless-stopped
    networks:
      - traefik_proxy
      - internal
    labels:
      - "traefik.enable=true"

      # HTTPS router
      - "traefik.http.routers.webapp.rule=Host(`example.abelentsev.pro`)"
      - "traefik.http.routers.webapp.entrypoints=websecure"
      - "traefik.http.routers.webapp.tls.certresolver=cloudflare"
      - "traefik.http.routers.webapp.middlewares=public-site-stack@file"
      - "traefik.http.routers.webapp.service=webapp"

      # Service
      - "traefik.http.services.webapp.loadbalancer.server.port=80"
      - "traefik.docker.network=traefik_proxy"

    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  traefik_proxy:
    external: true
  internal:

Паттерн: secret-path (как у n8n)

Используется для webhook'ов и MCP-серверов — endpoint не должен индексироваться/брутиться.

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.n8n.rule=Host(`webhooks.abelentsev.pro`) && PathPrefix(`/api-gw/{{SECRET_PATH}}/`)"
  - "traefik.http.routers.n8n.entrypoints=websecure"
  - "traefik.http.routers.n8n.tls.certresolver=cloudflare"
  - "traefik.http.routers.n8n.middlewares=strip-secret-prefix"
  - "traefik.http.middlewares.strip-secret-prefix.stripprefix.prefixes=/api-gw/{{SECRET_PATH}}"
  - "traefik.http.services.n8n.loadbalancer.server.port=5678"

Секретный сегмент — 32-байтовый hex (openssl rand -hex 32), хранится только в .env сервиса, никогда не в git.

Паттерн: gRPC

labels:
  - "traefik.http.routers.grpc.rule=Host(`grpc.abelentsev.pro`)"
  - "traefik.http.routers.grpc.entrypoints=websecure"
  - "traefik.http.routers.grpc.tls.certresolver=cloudflare"
  - "traefik.http.services.grpc.loadbalancer.server.port=50051"
  - "traefik.http.services.grpc.loadbalancer.server.scheme=h2c"

Паттерн: WebSocket (Sticky session)

labels:
  - "traefik.http.services.ws.loadbalancer.sticky.cookie=true"
  - "traefik.http.services.ws.loadbalancer.sticky.cookie.name=traefik_ws"
  - "traefik.http.services.ws.loadbalancer.sticky.cookie.secure=true"
  - "traefik.http.services.ws.loadbalancer.sticky.cookie.httpOnly=true"
  - "traefik.http.services.ws.loadbalancer.sticky.cookie.sameSite=strict"

Паттерн: ограничение по IP (только LAN)

labels:
  - "traefik.http.routers.internal.middlewares=ipallowlist-lan@file,security-headers@file"

Паттерн: API за BasicAuth + Rate Limit

labels:
  - "traefik.http.routers.api.middlewares=auth-basic@file,rate-limit-auth@file,security-headers@file"

Антипаттерны (явно запрещено)

  • --api.insecure=true в проде — никогда.
  • HTTP-01 challenge на сервисах за NAT/RKN-блокировкой — не работает.
  • Выставление dashboard без auth.
  • ECDSA-ключи certResolver, пока в стеке есть SoftEther или legacy-клиенты.
  • Хранить ACME-токены/BasicAuth-пароли в compose.yaml plaintext — только через secrets: или env_file вне git.
  • Использовать Host(.*) или wildcard rules без явного TLS-домена.
  • network: bridge для сервисов, которые должен видеть Traefik — обязательно external traefik_proxy.

Чек-лист перед prod-выкаткой нового сервиса

  • Сервис подключён к traefik_proxy
  • traefik.enable=true
  • Router имеет tls.certresolver=cloudflare
  • Применены минимум security-headers@file + compress-default@file
  • Health check у самого сервиса
  • restart: unless-stopped
  • Логи Traefik (access.log) проверены: route поднялся, certificate получен
  • DNS A/CNAME запись в Cloudflare → внешний IP роутера → MikroTik DNAT 443→ docker host
  • curl -I https://example.abelentsev.pro возвращает 200/HSTS заголовок
  • openssl s_client -connect example.abelentsev.pro:443 -servername example.abelentsev.pro показывает валидный сертификат

Часто нужные команды

# Перезагрузить динамический конфиг (Traefik сам подхватит, watch:true)
docker exec traefik kill -SIGHUP 1

# Посмотреть текущее состояние ACME
docker exec traefik cat /acme/acme.json | jq .cloudflare.Certificates[].domain

# Проверить, какие routes/services Traefik видит
curl -s http://localhost:8080/api/http/routers | jq

# Тест dry-run конфига
docker run --rm -v $(pwd)/traefik.yaml:/etc/traefik/traefik.yaml traefik:v3.2 traefik --check

# Принудительно перевыпустить серт (удаление записи и рестарт)
docker exec traefik sh -c 'jq "del(.cloudflare.Certificates[] | select(.domain.main==\"example.abelentsev.pro\"))" /acme/acme.json > /tmp/acme.json && mv /tmp/acme.json /acme/acme.json'
docker compose restart traefik

Интеграция с пользовательской инфрой

  • MikroTik DNAT: 80/443 с публичного IP → 192.168.9.147 (docker host). Hairpin NAT включён для LAN-доступа по внешним именам.
  • DNS: записи A/CNAME в Cloudflare → MikroTik public IP. Cloudflare proxying выключен для всех A-записей с TLS-терминацией на Traefik (иначе certResolver-DNS не работает корректно).
  • Backup acme.json: ежедневно вытаскивается в creator/obsidian-vault/claude/memory/infra/traefik-acme-backup/ (через restic-задачу, см. backup-restore skill).
  • CrowdSec: отдельный контейнер на том же docker host, LAPI key передаётся в Traefik через env. Подробности — в web-security-hardening skill.