Traefik en Docker Compose: 5 apps con una IP
Configura cinco aplicaciones con Traefik v3 y Docker Compose usando reglas Host y TLS de Let's Encrypt. Evita el error de permisos de acme.json que bloquea la emisión.
Una IP, cinco aplicaciones, un puerto 443
Tu VPS tiene una única dirección IPv4 pública y un único puerto TCP 443. Quieres alojar Gitea, una copia de staging de tu aplicación, un panel interno, una página de estado y un receptor de webhooks: cinco nombres de host en un solo servidor. Un reverse proxy es el proceso que controla :80 y :443, lee la cabecera Host de cada petición y la entrega al contenedor correcto. Traefik hace eso y obtiene y renueva un certificado para cada nombre de host sin que tengas que ejecutar certbot manualmente. Nginx y Caddy también terminarían TLS para esos cinco nombres de host sin problemas, así que, si aún no has elegido, conviene comparar los tres proxies según la gestión de certificados y el coste de configuración por aplicación antes de conectar todo a uno de ellos.
Lo que diferencia Traefik de un bloque server {} de nginx es el origen de su configuración. Con nginx editas un archivo y recargas el servicio, mientras que el ciclo de vida de los certificados sigue siendo una tarea independiente. Ese es el flujo que sigues cuando emites certificados de Let's Encrypt con certbot en nginx y el temporizador de renovación queda completamente fuera del servidor web. El proveedor Docker de Traefik supervisa el flujo de eventos de Docker y lee las labels de los contenedores: inicia un contenedor con una label de regla Host() y se puede enrutar hacia él en menos de un segundo; deténlo y la ruta desaparece. Ese también es el riesgo. La configuración almacenada en labels queda repartida en cinco lugares, y una label incorrecta no genera ningún aviso: el contenedor simplemente no recibe tráfico y Traefik no informa de nada.
Los cuatro conceptos
- Entrypoints son sockets de escucha. Definirá dos:
weben:80ywebsecureen:443. - Routers comparan una solicitud (
Host(...)) y la asocian con 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 IP permitidas, reescrituras de cabeceras y redirecciones.
Esos cuatro conceptos son los nombres que usa Traefik para tareas que, de otro modo, tendría que escribir manualmente: el router es un server_name, el servicio es un destino proxy_pass y los middlewares son las directivas de cabeceras y autenticación que configuraría al crear línea por línea un bloque server de proxy inverso de nginx.
La configuración estática (entrypoints, providers y ACME) se pasa en la línea de comandos de Traefik o en traefik.yml. Para cambiarla, debe reiniciar Traefik. La configuración dinámica (routers, services y middlewares) llega mediante etiquetas de los contenedores y se recarga automáticamente. Confundir ambas configuraciones es la causa habitual de que «mi flag no haga nada».
El archivo de Compose
Una red de Docker compartida llamada proxy actúa como base. Traefik sólo puede llegar a un contenedor si ambos están conectados a ella.
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=3000Primero docker compose up -d y después 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 ejecutada en Docker con TLS y copias de seguridad se integra de la misma forma: elimine sus puertos publicados, conéctela a proxy y deje que las etiquetas del router gestionen el nombre de host y el certificado.
Hay cinco detalles importantes.
exposedByDefault=false hace que un contenedor sea invisible para Traefik hasta que incluya traefik.enable=true. Si lo omite, todos los contenedores que inicie, incluido el contenedor temporal postgres que ejecutó para comprobar algo, tendrán una ruta generada.
providers.docker.network=proxy indica a Traefik qué red debe usar cuando un contenedor está conectado a varias. Si lo omite, Traefik puede elegir la IP de contenedor incorrecta. El resultado es un error 502 que parece un fallo de la aplicación.
loadbalancer.server.port=3000 es el puerto dentro del contenedor; Gitea escucha en 3000 allí. Observe que ningún contenedor de aplicación publica puertos; sólo lo hace Traefik.
La redirección del entrypoint web es permanente de forma predeterminada. Por tanto, una petición GET sin cifrar, que es la que envía un navegador, recibe una respuesta 301 hacia HTTPS. Traefik responde con 308 para otros métodos, como POST, de modo que el método se conserva durante la redirección. El puerto 80 permanece abierto de todos modos: el desafío HTTP de ACME lo necesita, y también los usuarios que escriben un nombre de host sin prefijo.
El $$ duplicado en el hash de basic-auth corresponde al escape de Compose; no es un error tipográfico. Genérelo con htpasswd -nbB admin 'your-password', del paquete apache2-utils, y duplique después cada $.
El certificado y la trampa de acme.json
tlschallenge=true selecciona TLS-ALPN-01: Let's Encrypt se conecta a su servidor por 443 y Traefik responde al desafío dentro del handshake TLS. La alternativa es HTTP-01, en el puerto 80. Sustituya la línea tlschallenge de la lista command: de Traefik por estas dos:
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=webCualquiera de las dos opciones funciona. Ambas requieren que el DNS público del nombre de host ya apunte a su VPS, que la autoridad certificadora pueda resolver el nombre y que pueda conectarse desde el exterior. Cree primero el registro A (y AAAA), confirme la resolución con dig +short git.example.com y después inicie Traefik.
Ahora viene la trampa que puede costar una tarde entera. Traefik guarda la clave de cuenta de ACME y todos los certificados emitidos en un único archivo acme.json. Si ese archivo se puede leer por el grupo o por cualquier usuario, Traefik registra una línea muy similar a esta y desactiva el resolver. Sigue ejecutándose y encaminando tráfico, pero nunca solicita ningún certificado. Por eso, todos los nombres de host reciben 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=leLa solución correcta es la indicada arriba: monte el directorio como bind mount y deje que Traefik cree el archivo con los permisos adecuados. Si creó acme.json con touch, la umask lo dejó con permisos 644. Corríjalo en el host:
chmod 600 ./letsencrypt/acme.json
docker compose restart traefikIncluya ese directorio en las copias de seguridad junto con los volúmenes de sus aplicaciones. Perderlo una vez se puede solucionar: los certificados se vuelven a emitir. Sin embargo, eliminarlo en cada redeploy puede llevarle al límite de cinco certificados por cada conjunto exacto de nombres de host cada 7 días.
Use la CA de staging mientras realiza las pruebas. Quite el comentario de la línea caserver, haga que todas las rutas funcionen, vuelva a comentarla y elimine acme.json para solicitar certificados de producción nuevos. Let's Encrypt en producción permite cinco certificados duplicados por semana para un conjunto idéntico de nombres de host y limita las validaciones fallidas repetidas para el mismo nombre. Staging emite certificados no confiables. El navegador muestra una advertencia. Esa advertencia confirma que el proceso funcionó y los límites son mucho más amplios.
El dashboard es una interfaz de control, no una demostración
La mayoría de las guías de inicio rápido establecen --api.insecure=true, que sirve el dashboard en el puerto 8080 sin autenticación. En un servidor con una IP pública, esto expone la topología de enrutamiento, los nombres de host, los nombres del middleware y los puertos de los backends a cualquiera que lo explore.
Las etiquetas del servicio traefik anterior son la alternativa: el dashboard se enruta como cualquier otra aplicación, con un nombre de host real, mediante TLS y detrás de basicauth. service=api@internal conecta el router con la API integrada de Traefik. Puede reforzarlo encadenando una lista de IP permitidas, aplicada de izquierda a derecha. Si la dirección de la oficina es dinámica, establezca el rango en la subred asignada por una VPN WireGuard que aloje usted mismo en el mismo VPS y acceda al dashboard únicamente mediante el túnel:
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authUna sola contraseña compartida en basicauth deja de ser una opción defendible cuando cinco aplicaciones necesitan sus propias cuentas. La misma posición de middleware acepta un forwardauth que delega la decisión en Authentik, un servidor de inicio de sesión único autoalojado. Así, el dashboard y todas las rutas asociadas quedan protegidos por un único inicio de sesión que puede revocar desde un solo lugar.
El socket de Docker tiene privilegios de root
/var/run/docker.sock es una API que puede crear un contenedor con un montaje de / desde el host. El acceso a ella equivale a tener privilegios de root en la máquina, y Traefik la necesita para leer las etiquetas.
Mantenga :ro en el montaje, pero explique claramente qué proporciona: hace que el archivo de socket sea de solo lectura. No impide que las solicitudes POST a la API de Docker pasen por él. La mitigación real consiste en no entregar nunca el socket a Traefik y colocar un proxy de filtrado entre ambos:
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 proveedor al proxy:
--providers.docker.endpoint=tcp://dockerproxy:2375Traefik conserva el acceso de lectura a los contenedores y las redes, pero pierde la capacidad de crear cualquier recurso.
Firewall, puertos y la regla que casi todos aplican mal
Dos puertos abiertos, además de SSH:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableLos puertos publicados por Docker omiten ufw. Docker inserta sus propias reglas de iptables, que se evalúan antes que las cadenas de ufw. Por eso, un contenedor iniciado con ports: ["3000:3000"] es accesible desde Internet aunque exista una regla ufw deny en ese mismo punto. La defensa es estructural, no una cuestión de configuración del firewall: publique puertos sólo desde Traefik y asigne a todos los demás contenedores networks: [proxy], sin nada más. Si algo necesita acceder realmente al host, enlácelo a loopback, "127.0.0.1:3000:3000".
Solución de problemas: errores que realmente verá
404 page not found, servido por Traefik. No coincidió ningún router. Por orden de probabilidad: al contenedor le falta traefik.enable=true (con exposedByDefault=false definido); la regla Host() no coincide con el nombre que escribió; el nombre del router de una etiqueta difiere del nombre del router de otra (routers.gitea.rule y routers.gitea.entrypoints deben ser la misma palabra); o escribió el nombre de host entre comillas simples. Traefik v3 acepta acentos graves o comillas dobles escapadas (\") dentro de los matchers y rechaza las comillas simples.
502 Bad Gateway. Un router coincidió, pero no se pudo acceder al backend. Casi siempre el contenedor no está en la red proxy; compruebe docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. La otra posibilidad es un loadbalancer.server.port incorrecto: indicó un puerto publicado o la aplicación escucha en otro puerto. El registro muestra el intento: dial tcp 172.18.0.5:8080: connect: connection refused.
El navegador muestra una advertencia y el certificado se emitió para TRAEFIK DEFAULT CERT. No existe ningún certificado para ese nombre de host y Traefik sirvió su certificado provisional 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 todavía no apunta al servidor. Corrija el registro, espere a que transcurra el TTL y reinicie Traefik.
Invalid response from http://git.example.com/.well-known/acme-challenge/... en el desafío HTTP: el puerto 80 no llega a Traefik desde el exterior. Normalmente se trata de un firewall del proveedor situado delante del VPS, no de ufw.
Los certificados nunca se emiten y su DNS está en Cloudflare con la nube naranja activada. Cloudflare termina TLS en su perímetro y TLS-ALPN-01 no puede completarse a través de Cloudflare. Configure el registro como DNS-only mientras se emite el certificado o cambie al desafío DNS-01 con un token de API. DNS-01 también es el único desafío que emite certificados comodín.
Bucle de redirección. Algo situado delante de Traefik ya termina TLS y reenvía tráfico sin cifrar al puerto :80; la redirección del entrypoint lo devuelve a HTTPS. Elimine una de las dos redirecciones.
Mantenerlo en ejecución
La unidad de Docker debe estar habilitada para iniciarse durante el arranque (systemctl is-enabled docker), y restart: unless-stopped vuelve a iniciar la pila después de un reinicio. Para controlar este proceso de forma explícita, una unidad pequeña de systemd que ejecute docker compose -f /srv/edge/compose.yml up -d con RemainAfterExit=yes permite definir systemctl status edge y el orden de inicio.
Fije la etiqueta de Traefik (traefik:v3.7, nunca latest) y use una rama que siga publicando versiones. A fecha de 2026-10-12, Traefik indica que v3.7 es la única rama v3 con soporte. v3.5 publicó su última versión, v3.5.6, el 7 de noviembre de 2025, y su proveedor de Docker tiene codificada la versión de API 1.24, que Docker Engine 29 y posteriores rechazan con client version 1.24 is too old; por tanto, ningún contenedor recibe tráfico. Traefik 3.6.1 añadió la negociación de la versión de la API de Docker, por lo que el archivo compose anterior usa v3.7. La actualización de v2 a v3 cambió la sintaxis de las reglas y los nombres de los proveedores. Un latest no supervisado volverá a cargar sin avisar una configuración que ya no entiende. Actualice de forma deliberada: lea las notas de migración, cambie la etiqueta, docker compose up -d traefik y supervise el registro. Si todavía usa una etiqueta v2, la guía de migración de Traefik de v2 a v3 explica cada cambio de nombre, el modo de compatibilidad y una reversión que conserva los certificados.
Haga una copia de seguridad de ./letsencrypt y del volumen de datos de cada aplicación. Traefik no conserva ningún otro estado que no pueda reconstruir a partir del archivo compose.
Qué falla al escalar
El primer límite no es el rendimiento, sino el servidor único: un Traefik en un solo VPS es un único punto de fallo para cinco aplicaciones, y acme.json es almacenamiento en un archivo plano; dos instancias de Traefik que escriban en él lo dañarán. Escalar horizontalmente implica trasladar el almacenamiento de certificados fuera de un archivo o terminar TLS en otro lugar.
El segundo problema son las conexiones de larga duración. Los eventos enviados por el servidor, las cargas de archivos grandes y los clientes lentos alcanzan los tiempos de espera de respuesta del entrypoint; --entryPoints.websecure.transport.respondingTimeouts.readTimeout y sus equivalentes writeTimeout y idleTimeout son los parámetros que se deben ajustar. WebSockets atraviesa el proxy sin configuración adicional.
El tercer problema es el disco. --accesslog=true escribe en stdout, y el controlador json-file de Docker conserva esos datos indefinidamente si no se establece un límite. Configure logging.options.max-size en el servicio de Traefik o escriba el registro de acceso en un archivo y rótelo.
Nada de esto requiere un orquestador. Sí requiere un servidor bajo su control, con una IP pública real y los puertos 80 y 443 abiertos a Internet. Un único VPS pequeño es toda la lista de dependencias.
FAQ
¿Sigo necesitando certbot si ejecuto Traefik?
No. El resolver ACME de Traefik solicita y renueva el certificado de cada nombre de host que enruta, y almacena todo en acme.json. Certbot sigue siendo la herramienta adecuada cuando nginx u otro servidor termina TLS por sí mismo; ejecutar ambos para los mismos nombres de host sólo consume los límites de solicitudes de Let's Encrypt.
¿Por qué mi contenedor devuelve 404 a través de Traefik?
Un 404 servido por Traefik significa que ningún router coincidió con la solicitud. Compruebe que el contenedor incluya traefik.enable=true (es obligatorio cuando se establece exposedByDefault=false), que el valor de Host() coincida con el nombre que escribió y que el nombre del router sea idéntico en todas las etiquetas de esa aplicación. Traefik v3 también requiere comillas invertidas o comillas dobles escapadas dentro del matcher; las comillas simples se rechazan.
¿Cuál es aquí la diferencia entre un 404 y un 502?
Un 404 significa que el enrutamiento no llegó a producirse; un 502 significa que un router coincidió, pero el backend rechazó la conexión. Las causas habituales de un 502 son que un contenedor no esté conectado a la red proxy y que loadbalancer.server.port apunte a un puerto publicado en lugar del puerto en el que la aplicación escucha dentro del contenedor. El registro de acceso indica la dirección exacta a la que Traefik intentó conectarse.
¿Es suficiente montar el socket de Docker como de sólo lectura?
La opción :ro hace que el archivo de socket sea de sólo lectura, pero no la API que hay detrás; las solicitudes POST siguen circulando por él, y el acceso a la API de Docker equivale a tener root en el host. La opción más segura es el contenedor docker-socket-proxy mostrado arriba, que expone a Traefik sólo las operaciones de lectura de contenedores y redes, y bloquea las escrituras por completo.
¿Puede Traefik emitir un certificado wildcard?
Sólo mediante el desafío DNS-01 y con un token de API de su proveedor de DNS. TLS-ALPN-01 y HTTP-01 validan un único nombre de host y no pueden generar un wildcard. DNS-01 también es la opción adecuada cuando una CDN como Cloudflare termina TLS delante de su VPS y los otros dos desafíos nunca se completan.