A router priority cannot escape an entrypoint's HTTP→HTTPS redirection: Traefik applies it before routing. The device paths therefore listen on their own port, which never redirects. That turns out to be the better arrangement anyway. Nothing but the four device paths is reachable on 2300, and Traefik guarantees it rather than application code — a stronger property than refusing the other routes after the fact. Requires the matching `device` entrypoint in the shared traefik.yml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
114 lines
4.8 KiB
YAML
114 lines
4.8 KiB
YAML
# Production overlay: the application is published by an existing Traefik
|
|
# rather than on a host port.
|
|
#
|
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
|
|
#
|
|
# Traefik must already be running and own the external network named by
|
|
# TRAEFIK_NETWORK. Nothing here holds a secret: every value comes from .env,
|
|
# which is never committed.
|
|
#
|
|
# Using Nginx Proxy Manager instead? Delete the labels and the `edge` network,
|
|
# keep the published port from docker-compose.yml bound to 127.0.0.1, and point
|
|
# a proxy host at it. The forwarded headers matter either way: the application
|
|
# builds the image URL handed to the panel from them, so X-Forwarded-Proto and
|
|
# X-Forwarded-Host must both reach it or the device will be sent to the wrong
|
|
# scheme.
|
|
|
|
services:
|
|
db:
|
|
# The database is reached only over the compose network.
|
|
ports: !override []
|
|
|
|
app:
|
|
ports: !override []
|
|
networks:
|
|
- default
|
|
- edge
|
|
# The database is the only thing worth persisting; the application writes
|
|
# nothing to its own filesystem.
|
|
read_only: true
|
|
tmpfs:
|
|
- /tmp
|
|
labels:
|
|
traefik.enable: "true"
|
|
traefik.docker.network: ${TRAEFIK_NETWORK:-web}
|
|
traefik.http.routers.horaires.rule: Host(`${APP_DOMAIN:?Set APP_DOMAIN in .env}`)
|
|
traefik.http.routers.horaires.entrypoints: ${TRAEFIK_ENTRYPOINT:-websecure}
|
|
traefik.http.routers.horaires.tls: "true"
|
|
traefik.http.routers.horaires.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-myresolver}
|
|
traefik.http.routers.horaires.middlewares: horaires-hsts
|
|
traefik.http.services.horaires.loadbalancer.server.port: "3010"
|
|
# The admin is only ever served over TLS; say so to the browsers.
|
|
traefik.http.middlewares.horaires-hsts.headers.stsSeconds: "31536000"
|
|
traefik.http.middlewares.horaires-hsts.headers.stsIncludeSubdomains: "true"
|
|
|
|
# --- The panel, in clear, on four paths only ---
|
|
#
|
|
# The e-ink firmware carries a certificate-authority bundle fixed when it
|
|
# was built, so it cannot validate a chain rooted in an authority created
|
|
# afterwards — which is exactly the case with Let's Encrypt's ISRG Root YR
|
|
# (May 2026). The handshake fails before a request is ever sent, which is
|
|
# why neither Traefik nor the application sees anything at all.
|
|
#
|
|
# This router therefore serves the four device paths over plain HTTP. The
|
|
# administration stays on TLS. The trade-off is real and bounded: the
|
|
# device token travels in clear, it is used for nothing else, and it can
|
|
# be revoked from Paramètres → Appareils. The image URL is an unguessable
|
|
# content hash.
|
|
#
|
|
# It listens on its own entrypoint rather than on :80. Traefik applies an
|
|
# entrypoint's HTTP→HTTPS redirection before routing, so no router
|
|
# priority can escape it — the port has to be one that never redirects.
|
|
# That also makes the restriction structural: nothing but these four
|
|
# paths is reachable on 2300, and it is Traefik that guarantees it rather
|
|
# than application code.
|
|
#
|
|
# Requires the matching `device` entrypoint in the shared traefik.yml.
|
|
# Remove this block the day the firmware learns the new roots, and set
|
|
# DEVICE_ALLOW_HTTP=false — the application refuses plain requests
|
|
# without it.
|
|
traefik.http.routers.horaires-device.rule: >-
|
|
Host(`${APP_DOMAIN}`) && (PathPrefix(`/api/setup`) || PathPrefix(`/api/display`)
|
|
|| PathPrefix(`/api/log`) || PathPrefix(`/api/device/`))
|
|
traefik.http.routers.horaires-device.entrypoints: device
|
|
traefik.http.routers.horaires-device.service: horaires
|
|
|
|
backup:
|
|
# A nightly dump kept for two weeks. Small, boring, and the only thing
|
|
# standing between a bad migration and retyping a year of opening hours.
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
|
POSTGRES_USER: ${POSTGRES_USER:-horaires}
|
|
POSTGRES_DB: ${POSTGRES_DB:-horaires}
|
|
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
|
volumes:
|
|
- ./backups:/backups
|
|
entrypoint:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
while true; do
|
|
stamp="$$(date +%Y%m%d-%H%M%S)"
|
|
if pg_dump -h db -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
|
|
| gzip > "/backups/horaires-$$stamp.sql.gz"; then
|
|
echo "[backup] /backups/horaires-$$stamp.sql.gz"
|
|
else
|
|
echo "[backup] échec du dump $$stamp" >&2
|
|
rm -f "/backups/horaires-$$stamp.sql.gz"
|
|
fi
|
|
find /backups -name 'horaires-*.sql.gz' -mtime "+$$BACKUP_KEEP_DAYS" -delete
|
|
sleep 86400
|
|
done
|
|
|
|
networks:
|
|
edge:
|
|
external: true
|
|
name: ${TRAEFIK_NETWORK:-web}
|