Traefik v3 con Docker Compose para 5 apps
Configura Traefik v3 con Docker Compose para gestionar 5 apps con una sola IP. Incluye routing por Host y solución al error de acme.json con Let's Encrypt.
Una IP, cinco apps, un puerto 443
Su VPS tiene una única dirección IPv4 pública y un único puerto TCP 443. Usted necesita ejecutar Gitea, una copia de staging de su app, un dashboard interno, una página de estado y un receptor de webhooks en ella: cinco hostnames en un solo servidor. Un reverse proxy es el proceso que gestiona los puertos :80 y :443, lee el header Host en cada petición y la redirige al contenedor correcto. Traefik realiza esta función y obtiene y renueva un certificado para cada hostname sin necesidad de ejecutar certbot manualmente.
Lo que diferencia a Traefik de un bloque server {} de nginx es el origen de su configuración. Con nginx, usted edita un archivo y recarga el servicio, pero la gestión de certificados sigue siendo una tarea independiente; este es el flujo de trabajo que se sigue al emitir certificados de Let's Encrypt con certbot en nginx, donde el temporizador de renovación reside fuera del servidor web. El provider de Docker de Traefik monitoriza el stream de eventos de Docker y lee labels de sus contenedores: si inicia un contenedor con una label de regla Host(), este será direccionable en un segundo; si lo detiene, la ruta desaparece. Este es también un riesgo. La configuración almacenada en labels se encuentra en cinco lugares a la vez, y una label incorrecta no genera errores visibles: el contenedor simplemente no es direccionado y Traefik no reporta fallos.
Los cuatro sustantivos
- Entrypoints son sockets de escucha. Debe definir dos:
weben:80ywebsecureen:443. - Routers coinciden con una petición (
Host(...)) y la vinculan a un servicio. Los certificados se solicitan por router mediantetls.certresolver. - Services son el backend: un contenedor y el puerto en el que escucha dentro de la red Docker.
- Middlewares se sitúan entre el router y el servicio: autenticación básica, listas de IPs permitidas, reescritura de headers y redirecciones.
La configuración estática (entrypoints, providers, ACME) se pasa por la línea de comandos de Traefik o en traefik.yml; cambiarla requiere reiniciar Traefik. La configuración dinámica (routers, services, middlewares) proviene de las labels de los contenedores y se recarga en caliente. Confundir ambos tipos es la causa habitual de que "un flag no surta efecto".
El archivo compose
Una red Docker compartida llamada proxy es la base. Traefik alcanza un contenedor solo si ambos están en ella.
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=3000docker compose up -d, luego docker compose logs -f traefik. Cada aplicación adicional es una copia del bloque gitea con su propio nombre de router, su propio Host() y su propio puerto interno. Una instalación de Nextcloud en Docker con TLS y backups se integra de la misma manera: elimine sus puertos publicados, conéctela a proxy y deje que las etiquetas del router gestionen el hostname y el certificado.
Cinco detalles son fundamentales.
exposedByDefault=false hace que un contenedor sea invisible para Traefik hasta que tenga traefik.enable=true. Si se omite, cada contenedor que inicie —incluyendo el postgres temporal que usó para una comprobación— tendrá una ruta generada automáticamente.
providers.docker.network=proxy indica a Traefik qué red usar cuando un contenedor está conectado a varias. Si se omite, Traefik podría elegir la IP incorrecta del contenedor, lo que resulta en un error 502 que parece un fallo de la aplicación.
loadbalancer.server.port=3000 es el puerto interno del contenedor; Gitea escucha en el 3000 allí. Note que ningún contenedor de aplicación publica puertos; solo lo hace Traefik.
La redirección en el entrypoint web convierte las peticiones en texto plano en un 308 a HTTPS. El puerto 80 permanece abierto: el challenge HTTP de ACME lo necesita, al igual que los usuarios que escriben un hostname sin protocolo.
El doble $$ en el hash de basic-auth es el escape de Compose, no un error tipográfico. Genérelo con htpasswd -nbB admin 'your-password' (paquete apache2-utils) y luego duplique cada $.
El certificado y la trampa de acme.json
tlschallenge=true selecciona TLS-ALPN-01: Let's Encrypt se conecta a su servidor por el puerto 443 y Traefik responde al desafío durante el handshake TLS. La alternativa es HTTP-01, en el puerto 80; reemplace la línea tlschallenge en la lista command: de Traefik por estas dos:
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=webAmbos métodos funcionan. Ambos requieren que el DNS público para el hostname ya apunte a su VPS; la autoridad de certificación resuelve el nombre y se conecta desde el exterior. Cree primero el registro A (y AAAA), confirme con dig +short git.example.com y luego inicie Traefik.
Ahora la trampa que hace perder una tarde de trabajo a los usuarios. Traefik guarda su clave de cuenta ACME y cada certificado emitido en un único acme.json. Si ese archivo tiene permisos de lectura para el grupo o para otros, Traefik imprimirá una línea muy similar a esta y se detendrá:
error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600La solución correcta es la mencionada arriba: realice un bind-mount del directorio y permita que Traefik cree el archivo con los permisos correctos. Si creó acme.json con touch, su umask le asignó permisos 644. Corríjalo en el host:
chmod 600 ./letsencrypt/acme.json
docker compose restart traefikRealice copias de seguridad de ese directorio con los volúmenes de su aplicación. Perderlo es manejable (los certificados se pueden reemitir), pero reemitir cinco hostnames a la vez le hará alcanzar los límites de tasa (rate limits).
Use la CA de staging mientras realiza pruebas. Descomente la línea caserver, logre que todas las rutas funcionen, luego coméntela y elimine acme.json para solicitar certificados de producción nuevos. Let's Encrypt permite cinco certificados duplicados por semana para un conjunto idéntico de hostnames, y limita las validaciones fallidas repetidas para el mismo nombre. Staging emite certificados no confiables —su navegador mostrará una advertencia, y esa advertencia es la señal de que funcionó— con límites mucho más permisivos.
El dashboard es una superficie de control, no una demo
La mayoría de las guías rápidas configuran --api.insecure=true, que sirve el dashboard en el puerto 8080 sin autenticación. Esto es peligroso en un servidor con IP pública, ya que expone la topología de red, nombres de host, nombres de middleware y puertos del backend a cualquier escáner.
Las etiquetas en el servicio traefik anterior ofrecen la alternativa: el dashboard se enruta como cualquier otra aplicación, con un hostname real, mediante TLS y detrás de basicauth. service=api@internal es lo que conecta el router con la API integrada de Traefik. Refuerce la seguridad añadiendo una lista de IPs permitidas (allow-list) aplicada de izquierda a derecha. Si la dirección de su oficina es dinámica, configure el rango con la subred asignada por una VPN WireGuard alojada en el mismo VPS para acceder al dashboard únicamente a través del túnel:
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authEl socket de Docker es root
/var/run/docker.sock es una API que puede crear un contenedor que monte / desde el host. El acceso a esta API equivale a tener privilegios de root en la máquina, y Traefik la necesita para leer las labels.
Mantenga el :ro en el mount, pero tenga claro su efecto: hace que el archivo del socket sea de solo lectura. Esto no impide las solicitudes de POST a la API de Docker a través de él. La mitigación real es no entregar el socket a Traefik y colocar un proxy de filtrado intermedio:
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:
- proxyElimine el volumen del socket de Traefik y apunte el provider al proxy:
--providers.docker.endpoint=tcp://dockerproxy:2375Traefik conserva el acceso de lectura a contenedores y redes, pero pierde la capacidad de crear cualquier elemento.
Firewall, puertos y la regla que todos interpretan mal
Dos puertos abiertos, más SSH:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableLos puertos publicados de Docker omiten ufw. Docker inserta sus propias reglas de iptables, las cuales se evalúan antes que las cadenas de ufw. Por lo tanto, un contenedor iniciado con ports: ["3000:3000"] es accesible desde internet aunque ufw tenga una regla deny activa. La defensa es estructural, no de configuración del firewall: publique puertos únicamente desde Traefik y asigne a cada uno de los demás contenedores networks: [proxy] y nada más. Si algo debe alcanzar al host obligatoriamente, conéctelo al loopback — "127.0.0.1:3000:3000".
Resolución de problemas: errores reales
404 page not found, servido por Traefik. Ningún router coincide. Por orden de probabilidad: el contenedor carece de traefik.enable=true (con exposedByDefault=false configurado); la regla Host() no coincide con el nombre ingresado; el nombre del router en una etiqueta es distinto al de otra (routers.gitea.rule y routers.gitea.entrypoints deben ser la misma palabra); o usó comillas en lugar de backticks para el hostname. Traefik v3 requiere backticks dentro de los matchers.
502 Bad Gateway. Un router coincidió pero el backend no es alcanzable. Casi siempre el contenedor no está en la red proxy; verifique docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. La otra causa es un loadbalancer.server.port incorrecto: proporcionó un puerto publicado o la aplicación escucha en otro lugar. El log indica el intento: dial tcp 172.18.0.5:8080: connect: connection refused.
El navegador advierte, y el certificado se emite para TRAEFIK DEFAULT CERT. No existe un certificado para ese hostname y Traefik sirvió su placeholder autofirmado. Revise las líneas de ACME:
unable to obtain ACME certificate for domains "git.example.com" ...
acme: error: 400 ... DNS problem: NXDOMAIN looking up A for git.example.comEl DNS aún no apunta al servidor. Corrija el registro, espere a que expire el TTL y reinicie Traefik.
Invalid response from http://git.example.com/.well-known/acme-challenge/... en el challenge HTTP: el puerto 80 no llega a Traefik desde el exterior; generalmente es un firewall a nivel de proveedor frente al VPS, no ufw.
Los certificados nunca se emiten y su DNS está en Cloudflare con la nube naranja activada. Cloudflare termina el TLS en su borde y el challenge TLS-ALPN-01 no puede completarse a través de él. Configure el registro como DNS-only durante la emisión, o cambie al challenge DNS-01 con un API token. DNS-01 es también el único challenge que emite certificados wildcard.
Bucle de redirección (Redirect loop). Algo frente a Traefik ya termina el TLS y reenvía texto plano al puerto :80; la redirección del entrypoint lo devuelve a HTTPS. Elimine una de las dos redirecciones.
Mantener el servicio en ejecución
La unidad de Docker debe estar habilitada para el arranque (systemctl is-enabled docker), y restart: unless-stopped restaura el stack tras un reinicio. Para un control explícito, una unidad pequeña de systemd que ejecute docker compose -f /srv/edge/compose.yml up -d con RemainAfterExit=yes proporciona systemctl status edge y control de orden.
Fije la etiqueta de Traefik (traefik:v3.5, nunca latest). La actualización de v2 a v3 cambió la sintaxis de las reglas y los nombres de los proveedores; una actualización automática latest recargará una configuración que ya no reconoce. Actualice de forma deliberada: lea las notas de migración, aumente la etiqueta, docker compose up -d traefik y revise el log. Si todavía utiliza una etiqueta v2, la guía de migración de Traefik v2 a v3 detalla cada cambio de nombre, el modo de compatibilidad y un rollback que conserva sus certificados.
Realice copias de seguridad de ./letsencrypt y del volumen de datos de cada aplicación. Traefik no contiene otro estado que no pueda reconstruirse desde el archivo compose.
Qué falla al escalar
El primer límite no es el rendimiento, es el servidor único: un Traefik en un VPS es un punto único de fallo para cinco apps, y acme.json es almacenamiento basado en archivos — dos instancias de Traefik escribiendo en él causarán corrupción. Escalar implica mover el almacenamiento de certificados fuera de un archivo, o terminar el TLS en otro lugar.
El segundo límite son las conexiones de larga duración. Los Server-sent events, las cargas grandes y los clientes lentos alcanzan los timeouts de respuesta del entrypoint; --entryPoints.websecure.transport.respondingTimeouts.readTimeout y sus hermanos writeTimeout y idleTimeout son los parámetros de ajuste. Los WebSockets funcionan mediante pass-through sin configuración adicional.
El tercero es el disco. --accesslog=true escribe en stdout, y el driver json-file de Docker guarda eso permanentemente si no se limita. Configura logging.options.max-size en el servicio de Traefik, o escribe el access log en un archivo y realiza una rotación.
Nada de esto requiere un orquestador. Sí requiere un servidor bajo tu control, con una IP real y los puertos 80 y 443 abiertos al mundo — un solo VPS pequeño es toda la lista de dependencias.
FAQ
¿Sigo necesitando certbot si uso Traefik?
No. El resolver ACME de Traefik solicita y renueva el certificado para cada hostname que gestiona, y almacena todo en acme.json. Certbot sigue siendo la herramienta adecuada cuando nginx u otro servidor gestiona el TLS directamente; ejecutar ambos para los mismos hostnames solo agotará los límites de tasa (rate limits) de Let's Encrypt.
¿Por qué mi contenedor devuelve un 404 a través de Traefik?
Un 404 servido por Traefik significa que ningún router coincidió con la solicitud. Verifique que el contenedor tenga traefik.enable=true (obligatorio una vez configurado exposedByDefault=false), que el valor de Host() coincida con el nombre ingresado, y que el nombre del router sea idéntico en todas las etiquetas de esa aplicación. Traefik v3 requiere el uso de backticks dentro del matcher, no comillas.
¿Cuál es la diferencia entre un 404 y un 502 en este caso?
Un 404 significa que el enrutamiento no ocurrió; un 502 significa que un router coincidió pero el backend rechazó la conexión. Los candidatos habituales a un error 502 son un contenedor que no está conectado a la red proxy, y un loadbalancer.server.port que apunta a un puerto publicado en lugar del puerto en el que la aplicación escucha dentro del contenedor. El log de acceso indica la dirección exacta a la que Traefik intentó conectar.
¿Es suficiente montar el Docker socket como solo lectura?
El flag :ro hace que el archivo del socket sea de solo lectura, pero no la API que lo respalda — las solicitudes POST siguen viajando a través de él, y el acceso a la API de Docker equivale a tener privilegios de root en el host. La configuración más segura es el contenedor docker-socket-proxy mostrado arriba, que solo expone la lectura de contenedores y redes a Traefik y bloquea las escrituras por completo.
¿Puede Traefik emitir un certificado wildcard?
Solo mediante el desafío DNS-01, utilizando un token de API de su proveedor de DNS. Los desafíos TLS-ALPN-01 y HTTP-01 validan un único hostname y no pueden generar un wildcard. DNS-01 es también la solución cuando un CDN como Cloudflare gestiona el TLS frente a su VPS y los otros dos desafíos fallan.