1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# =============================================================================
# Ejemplo ejecutable: Traefik v3 + `errserve` + un backend que devuelve 4xx/5xx.
#
# cd examples/traefik
# podman-compose up -d # o: docker compose up -d
# ./check.sh # los tres curl de la tabla del README
# podman-compose down # o: docker compose down
#
# POR QUÉ LABELS DE DOCKER Y NO UN FICHERO DINÁMICO
# * Es el mecanismo que usa la guía oficial de `tarampampam/error-pages` para
# Traefik, así que quien llega desde allí reconoce la configuración tal cual.
# * Se verificó aquí **funcionando con podman**: el proveedor `docker` de
# Traefik habla con la API de podman (compatible) a través de su socket.
# * El precio es montar el socket del motor en el contenedor de Traefik. En
# este ejemplo es aceptable (quien puede escribir labels puede crear
# contenedores), pero NO es gratis: ver "El socket" en README.md. Si eso no
# te vale, el README documenta la variante con fichero dinámico, que no
# necesita socket y es idéntica en docker y en podman.
#
# El socket se monta con `CONTAINER_SOCKET`, cuyo valor por defecto es el de
# docker. En podman rootless hay que apuntarlo al del usuario:
# CONTAINER_SOCKET=$XDG_RUNTIME_DIR/podman/podman.sock podman-compose up -d
#
# Este compose NO construye la imagen: descarga `ghcr.io/atareao/errserve` (el
# paquete publicado). `ERRSERVE_IMAGE=<referencia>` cambia eso, por ejemplo para
# probar una etiqueta concreta o una imagen local hecha con `just build`.
# =============================================================================
name: errserve-traefik-example
services:
# ---------------------------------------------------------------------------
# Traefik: quien decide el estado HTTP cuando el middleware `errors` está
# delante de una aplicación (ver README.md).
# ---------------------------------------------------------------------------
traefik:
image: traefik:v3.6
container_name: errserve-demo-traefik
restart: unless-stopped
command:
- --entrypoints.web.address=:80
# El proveedor por labels. `exposedByDefault=false` es importante: en esta
# máquina hay más contenedores en el motor y sin esto Traefik publicaría
# routers para todos. Solo entra el que lleva `traefik.enable=true`.
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.endpoint=unix:///var/run/docker.sock
- --ping=true
- --log.level=INFO
- --accesslog=true
ports:
# Puerto alto en el host para no chocar con nada; dentro sigue siendo :80,
# que es el entrypoint `web`. Traefik no es el servicio que se publica: es
# el que publica a los demás.
- "8474:80"
volumes:
- ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:z
healthcheck:
test:
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
networks:
- net
# ---------------------------------------------------------------------------
# El servicio del proyecto. Es el que responde las páginas con el código real.
# ---------------------------------------------------------------------------
errserve:
# La imagen publicada, en GHCR: se descarga sola, no hay que construir nada.
# Con `ERRSERVE_IMAGE` se prueba otra etiqueta o una imagen local
# (`just build`), p. ej. `ERRSERVE_IMAGE=ghcr.io/atareao/errserve:0.3.0`.
image: ${ERRSERVE_IMAGE:-ghcr.io/atareao/errserve:latest}
container_name: errserve-demo-errors
restart: unless-stopped
environment:
# Los mismos valores por defecto que trae la imagen; explícitos para que
# el ejemplo se lea de un vistazo.
PORT: "8080"
LOG_LEVEL: "info"
LOG_FORMAT: "text"
CONTENT_DIR: "content"
DEFAULT_ERROR_PAGE: "404"
networks:
- net
labels:
traefik.enable: "true"
# --- Servicio ----------------------------------------------------------
# El puerto es el INTERNO del contenedor (8080), no uno del host: Traefik
# habla con el contenedor por la red del compose y no hace falta publicar
# nada con `ports:`.
traefik.http.services.errserve.loadbalancer.server.port: "8080"
# --- Middleware `errors` -----------------------------------------------
# Intercepta cualquier respuesta 4xx/5xx del router al que se aplique y
# pide el cuerpo a `errserve` con la plantilla de `query`. `/{status}`
# es lo que produce la petición `/404`, que es donde `errserve` lee el
# código: el contrato del proyecto depende de esa plantilla.
traefik.http.middlewares.errpages.errors.status: "400-599"
traefik.http.middlewares.errpages.errors.service: errserve
traefik.http.middlewares.errpages.errors.query: "/{status}"
# --- Router de respaldo (catch-all) ------------------------------------
# Un Host que no case con ningún otro router cae aquí, NO tiene upstream y
# su código de error no lo puede conservar nadie: lo decide `errserve`. Es
# el caso que justifica este proyecto (con `error-pages` sería un 200).
#
# La prioridad se pone a mano y baja: Traefik, sin prioridad explícita,
# ordena por longitud de la regla, y `HostRegexp(...)` es más larga que
# `Host(\`app.localtest.me\`)`, así que el catch-all ganaría a todo.
#
# OJO con la regla. La guía de error-pages y mil copias por ahí usan
# `HostRegexp(\`{host:.+}\`)`: es sintaxis de Traefik v2 (grupos con
# nombre al estilo gorilla/mux) y en v3 **no casa con nada** — Traefik
# responde su propio 404 en texto plano y nunca se llega al servicio de
# errores, que es justo el fallo que este proyecto existe para evitar.
# En v3 los matchers `*Regexp` usan la sintaxis de Go (ver
# doc.traefik.io/traefik/migrate/v2-to-v3-details/), así que va `.+\`
# a secas. Medido con v3.6: con `{host:.+}` el router está `enabled` pero
# no coincide con ninguna petición; con `.+\` coincide con todas.
# (Equivalente al gusto de v3: `PathPrefix(\`/\`)`.)
#
# Y la regla no es sólo `HostRegexp`: lleva una segunda conjunción,
# `!Path(\`/metrics\`)`, que no es cosmética. El respaldo atiende
# **cualquier** Host, así que sin ella publica también los *endpoints de
# servicio* del binario —y `/metrics` no es una página de error: es un
# endpoint de servicio, siempre `200`, que expone `errors_pages_total`, o
# sea el perfil de fallos de la infraestructura— a quien pregunte por un
# Host que no exista. Medido: sin la exclusión, `curl -H 'Host:
# nada.localtest.me' .../metrics` devolvía `200` con el contador; con ella,
# esa misma petición recibe el `404` de Traefik —texto plano, 19 B, sin
# `X-Robots-Tag`: cuando no casa ningún router, responde Traefik y
# `errserve` ni se entera— y en ese mismo Host el respaldo sigue sirviendo
# `404` y `503` reales. La coincidencia es exacta, la del endpoint:
# `/metrics/` y `/METRICS` no lo son y caen en el respaldo como cualquier
# otro path, sin datos del contador.
#
# Y una tercera conjunción cierra el leak de las páginas de tema: el nombre
# de cada tema **es** la ruta de su página, así que sin ella el respaldo
# publica las seis páginas crudas —con los marcadores `{{code}}`,
# `{{reason}}` y `{{description}}` a la vista— a quien pregunte por un Host
# que no exista. Cubre las tres formas de alcanzar esa página (fin de
# cadena, barra final o segmento detrás, y barra percent-encoded `%2F`, en
# las dos cajas) y deja fuera a propósito los dos alias `/default.html` y
# `/default.htm`, que el servicio intercepta y nunca responde `200`. El
# porqué y el precio de la regla están en «El leak de las páginas de tema»
# del README.md.
traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))
traefik.http.routers.errserve-catchall.entrypoints: web
traefik.http.routers.errserve-catchall.service: errserve
traefik.http.routers.errserve-catchall.middlewares: errpages
traefik.http.routers.errserve-catchall.priority: "1"
# El middleware aquí es inofensivo: la petición que el propio middleware
# hace al servicio de errores no vuelve a pasar por los routers, así que
# no hay recursión (verificado: el cliente recibe 404, no un bucle).
#
# Extra didáctico: un router divergente, con el mismo backend pero un
# middleware que pide SIEMPRE `/404`, para comprobar de un curl quién
# decide el estado (Traefik conserva el 503 del backend; el cuerpo es el
# de nuestra página 404).
traefik.http.middlewares.errfixed.errors.status: "400-599"
traefik.http.middlewares.errfixed.errors.service: errserve
traefik.http.middlewares.errfixed.errors.query: "/404"
# ---------------------------------------------------------------------------
# Backend de demostración: nginx con `return`, sin ficheros que buscar.
# / -> 404 /boom -> 503 /gone -> 410
# ---------------------------------------------------------------------------
backend:
image: nginx:1.29-alpine
container_name: errserve-demo-backend
restart: unless-stopped
networks:
- net
volumes:
# Es configuración, no datos: bind mount en solo lectura, justificado.
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro,z
labels:
traefik.enable: "true"
traefik.http.services.backend.loadbalancer.server.port: "80"
# Router normal, con el middleware `errors` delante.
traefik.http.routers.backend.rule: Host(`app.localtest.me`)
traefik.http.routers.backend.entrypoints: web
traefik.http.routers.backend.service: backend
traefik.http.routers.backend.middlewares: errpages
traefik.http.routers.backend.priority: "10"
# El mismo backend con el middleware divergente. Misma regla de longitud
# que `backend`, por eso lleva prioridad más alta.
traefik.http.routers.diverge.rule: Host(`diverge.localtest.me`)
traefik.http.routers.diverge.entrypoints: web
traefik.http.routers.diverge.service: backend
traefik.http.routers.diverge.middlewares: errfixed
traefik.http.routers.diverge.priority: "20"
# Un router de demostración para el `502`, el código con el que un proxy
# responde cuando el upstream NO responde. El servicio apunta a un puerto
# donde nada escucha **en este mismo contenedor**: la conexión se rechaza y
# Traefik emite 502, que el middleware `errpages` traduce a nuestra página.
#
# NO se construye parando el contenedor del backend: un servicio **sin
# servidores** disponibles da 503, no 502 (medido). Lo que separa los dos
# códigos es un hecho real, no una casualidad.
#
# Prioridad alta para que gane al router de la aplicación en este Host.
traefik.http.services.caido.loadbalancer.server.port: "9"
traefik.http.routers.caido.rule: Host(`caido.localtest.me`)
traefik.http.routers.caido.entrypoints: web
traefik.http.routers.caido.service: caido
traefik.http.routers.caido.middlewares: errpages
traefik.http.routers.caido.priority: "10"
networks:
# Red propia, no la default bridge: en la del compose los contenedores se
# resuelven por nombre y Traefik obtiene por la API la IP de cada uno.
net:
driver: bridge