Files
oikos/infrastructure/references/cert-sync-and-traefik-config.md
dtoro e04d943d5c fix VPS traefik backends after Phase 1+2 migration
- Jellyfin: 192.168.8.206 → 192.168.8.246 (stale after LXC 101 migration to strong)
- House: 192.168.8.212 → 192.168.8.244 (stale after LXC 129 migration to strong)
- Jellyseerr/qbit/sab: 192.168.8.132 → 192.168.8.245 (arriman on strong)
- Added migration pitfalls section to reference doc
2026-07-05 21:03:34 +02:00

10 KiB

Current cert sync script + traefik dynamic config

Snapshot of the two artifacts that control public service exposure as of 2026-07-05. Updated 2026-07-05: fixed Jellyfin backend from dead hubris IP (192.168.8.206) to new strong IP (192.168.8.246).

hubris-public-cert-sync.sh (PVE host, /usr/local/bin/)

#!/bin/bash
# Mirrors home caddy's LE certs for publicly-exposed hubris.network hostnames
# into the VPS traefik's /letsencrypt volume. Traefik file-watches the volume
# and hot-reloads.
#
# Why: netbird-proxy's HostSNI(*) TCP passthrough intercepts ACME TLS-ALPN-01
# challenges before traefik's allowACMEByPass can respond, so traefik can't
# obtain its own cert. Home caddy uses IONOS DNS-01 (no such conflict);
# we just mirror what it already has.
#
# Runs daily via hubris-public-cert-sync.timer.

set -euo pipefail

CADDY_LXC=121
CADDY_BASE=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory
VPS_HOST=root@100.122.165.149
VPS_DEST=/var/lib/docker/volumes/opt_netbird_traefik_letsencrypt/_data

# Map: source hostname -> "crt_filename key_filename" on the VPS.
# Stable names so traefik dynamic.yaml doesn't need edits on renewal.
declare -A HOSTS=(
  [artifacto.hubris.network]="fullchain.crt privkey.key"
  [blog.hubris.network]="blog.fullchain.crt blog.privkey.key"
  [trmnl.hubris.network]="trmnl.fullchain.crt trmnl.privkey.key"
  [sso.hubris.network]="sso.fullchain.crt sso.privkey.key"
  [media.hubris.network]="media.fullchain.crt media.privkey.key"
  [paperless.hubris.network]="paperless.fullchain.crt paperless.privkey.key"
)

TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

for host in "${!HOSTS[@]}"; do
  read -r crt_name key_name <<< "${HOSTS[$host]}"
  pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.crt" "$TMP/$crt_name"
  pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.key" "$TMP/$key_name"

  if ssh -o BatchMode=yes "$VPS_HOST" "test -f $VPS_DEST/$crt_name && diff -q - $VPS_DEST/$crt_name" < "$TMP/$crt_name" >/dev/null 2>&1; then
    echo "hubris-public-cert-sync: $host unchanged"
    continue
  fi

  scp -q -o BatchMode=yes "$TMP/$crt_name" "$TMP/$key_name" "$VPS_HOST:$VPS_DEST/"
  echo "hubris-public-cert-sync: shipped $host ($(openssl x509 -in "$TMP/$crt_name" -noout -enddate))"
done

Adding a new host

  1. Caddy must already have the cert (verify pct exec 121 -- ls "$CADDY_BASE/$host/")
  2. Add a line to the HOSTS array: [new-host.hubris.network]="nickname.fullchain.crt nickname.privkey.key"
  3. systemctl start hubris-public-cert-sync.service to sync immediately
  4. Verify certs landed: ssh "$VPS_HOST" "ls -la $VPS_DEST/nickname.*"
  5. Add matching tls.certificates entry in traefik dynamic config

traefik-dynamic.yaml (VPS, /opt/)

tcp:
  serversTransports:
    pp-v2:
      proxyProtocol:
        version: 2

tls:
  certificates:
    - certFile: /letsencrypt/fullchain.crt
      keyFile: /letsencrypt/privkey.key
    - certFile: /letsencrypt/blog.fullchain.crt
      keyFile: /letsencrypt/blog.privkey.key
    - certFile: /letsencrypt/trmnl.fullchain.crt
      keyFile: /letsencrypt/trmnl.privkey.key
    - certFile: /letsencrypt/sso.fullchain.crt
      keyFile: /letsencrypt/sso.privkey.key
    - certFile: /letsencrypt/media.fullchain.crt
      keyFile: /letsencrypt/media.privkey.key
    - certFile: /letsencrypt/paperless.fullchain.crt
      keyFile: /letsencrypt/paperless.privkey.key

http:
  routers:
    artifacto-public:
      rule: 'Host(`artifacto.hubris.network`) && (PathPrefix(`/p/`) || PathPrefix(`/static/`) || Path(`/healthz`))'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - artifacto-strip-sso
        - artifacto-ratelimit
      service: artifacto-public

    blog-public:
      rule: 'Host(`blog.hubris.network`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - blog-ratelimit
      service: blog-public

    trmnl-public:
      rule: 'Host(`trmnl.hubris.network`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - trmnl-ratelimit
      service: trmnl-public

    matrix-public:
      rule: 'Host(`matrix.hubris.network`) && !PathPrefix(`/.well-known/matrix/`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - matrix-ratelimit
      service: matrix-public

    matrix-wellknown:
      rule: 'Host(`matrix.hubris.network`) && (PathPrefix(`/.well-known/matrix/`) || PathPrefix(`/.well-known/acme-challenge/`))'
      entryPoints:
        - websecure
      priority: 20
      tls:
        certResolver: letsencrypt
      service: matrix-wellknown-svc

    house-public:
      rule: Host(`house.hubris.network`)
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - house-ratelimit
      service: house-public

    sso-public:
      rule: 'Host(`sso.hubris.network`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - sso-ratelimit
      service: sso-public

    media-public:
      rule: 'Host(`media.hubris.network`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - media-ratelimit
      service: media-public

    paperless-api-public:
      rule: 'Host(`paperless.hubris.network`) && PathPrefix(`/api/`)'
      entryPoints:
        - websecure
      priority: 20
      tls:
        certResolver: letsencrypt
      middlewares:
        - paperless-ratelimit
      service: paperless-public

    paperless-public:
      rule: 'Host(`paperless.hubris.network`)'
      entryPoints:
        - websecure
      priority: 10
      tls:
        certResolver: letsencrypt
      middlewares:
        - authentik-forwardauth
        - paperless-ratelimit
      service: paperless-public

  middlewares:
    artifacto-strip-sso:
      headers:
        customRequestHeaders:
          X-Artifacto-Gateway: ""
          X-Authentik-Username: ""
          X-Authentik-Groups: ""
          X-Authentik-Email: ""
          X-Authentik-Name: ""
          X-Authentik-Uid: ""
          X-Authentik-Jwt: ""
          X-Authentik-Meta-Jwks: ""
          X-Authentik-Meta-Outpost: ""
          X-Authentik-Meta-Provider: ""
          X-Authentik-Meta-App: ""
          X-Authentik-Meta-Version: ""

    admin-allowlist:
      ipAllowList:
        sourceRange:
          - "5.61.168.0/24"

    artifacto-ratelimit:
      rateLimit:
        average: 50
        period: 1s
        burst: 100
    blog-ratelimit:
      rateLimit:
        average: 100
        period: 1s
        burst: 200
    trmnl-ratelimit:
      rateLimit:
        average: 20
        period: 1s
        burst: 40
    matrix-ratelimit:
      rateLimit:
        average: 30
        period: 1s
        burst: 60
    house-ratelimit:
      rateLimit:
        average: 30
        period: 1s
        burst: 60
    sso-ratelimit:
      rateLimit:
        average: 30
        period: 1s
        burst: 60
    media-ratelimit:
      rateLimit:
        average: 30
        period: 1s
        burst: 60
    paperless-ratelimit:
      rateLimit:
        average: 20
        period: 1s
        burst: 40
    authentik-forwardauth:
      forwardAuth:
        address: "http://192.168.8.6:9000/outpost.goauthentik.io/auth/traefik"
        trustForwardHeader: true
        authResponseHeaders:
          - X-authentik-username
          - X-authentik-groups
          - X-authentik-email
          - X-authentik-name
          - X-authentik-uid
          - X-authentik-jwt
          - X-authentik-meta-jwks
          - X-authentik-meta-outpost
          - X-authentik-meta-provider
          - X-authentik-meta-app
          - X-authentik-meta-version

  services:
    artifacto-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.205:3100'
    blog-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.205:8080'
    trmnl-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.211:9851'
    matrix-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.242:8008'
    matrix-wellknown-svc:
      loadBalancer:
        servers:
          - url: 'http://matrix-wellknown:80'
    house-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.244:3000'
    sso-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.6:9000'
    media-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.246:8096'
    paperless-public:
      loadBalancer:
        servers:
          - url: 'http://192.168.8.130:8000'

Adding a new service — four blocks needed

  1. Routerhttp.routers.<name>-public with tls: {} (not certResolver)
  2. Middleware — rate limit, one per service
  3. Servicehttp.services.<name>-public with the backend URL
  4. tls.certificates — add a new - certFile/keryFile pair matching the cert sync HOSTS entry

Key file naming convention

Cert name Host Convention
fullchain.crt + privkey.key artifacto.hubris.network First service — no prefix
blog.fullchain.crt + blog.privkey.key blog.hubris.network {nickname}.fullchain.crt
trmnl.fullchain.crt + trmnl.privkey.key trmnl.hubris.network {nickname}.fullchain.crt
sso.fullchain.crt + sso.privkey.key sso.hubris.network {nickname}.fullchain.crt
media.fullchain.crt + media.privkey.key media.hubris.network {nickname}.fullchain.crt
paperless.fullchain.crt + paperless.privkey.key paperless.hubris.network {nickname}.fullchain.crt

⚠️ Critical — keep backends in sync after LXC migrations

When moving an LXC between Proxmox nodes, update both:

  1. Caddy (/etc/caddy/Caddyfile on LXC 121)
  2. VPS traefik (/opt/traefik-dynamic.yaml — via hubris bridge SSH)

Jellyfin migration from hubris to strong (2026-07-05) was fixed in Caddy but missed in VPS traefik — old IP 192.168.8.206 remained. This caused Bad Gateway for off-LAN users. Use Python-based editing (see references/traefik-config-editing.md) for accurate surgical fixes.