Both pre-cutover prerequisites are satisfied: - Port 80 NAT moved back to the docker host (192.168.0.9); verified externally that :80 is answered there. This is what lets the exchange-acme router forward /.well-known/acme-challenge/ to Exchange and replace the manual NAT repoint that every past win-acme renewal has needed. - traefik.shcnw.ru A 217.15.22.194 created, so the dashboard router is uncommented. The router was shipped disabled because ACME validates from the internet even for a LAN-only router — ipAllowList gates access, not issuance. That rule is now stated explicitly in the README rather than left as a one-off note, since it applies to every LAN-only host in this stack (start, portainer, cockpit). The earlier pre-flight already parsed this exact router (it got as far as attempting issuance for traefik.shcnw.ru and failing on NXDOMAIN), so the uncomment is not introducing anything unvalidated. Host restage of dynamic/services.yml is still PENDING — the 43009 tunnel went down before it could be copied. The host currently holds the previous revision, in which the dashboard router is absent. Everything else is identical, so a deploy before the restage would come up correctly minus the dashboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
traefik — edge reverse proxy for 43-meditsina
Single Traefik v3 instance replacing nginx-proxy-manager on the docker host
(192.168.0.9). It does three jobs on the one public :443 / :80:
| Job | Hosts | How |
|---|---|---|
| SNI pass-through (L4) | 24 mail.* / autodiscover.* names → Exchange 192.168.0.6:443 |
TLS terminates on Exchange → native NTLM/Negotiate survives → desktop Outlook can be set up from outside the office |
| TLS-terminate + Let's Encrypt (L7) | crm.*, 1c.exprinta.ru, start, portainer, cockpit |
Traefik terminates, auto-issues/renews, and preserves the real client IP so ipAllowList still works |
ACME forward (L7 on :80) |
the same 24 Exchange names, /.well-known/acme-challenge/ only |
forwarded to 192.168.0.6:80 so win-acme on SERVERMAIL renews itself without the manual NAT repoint |
Why this migration: an HTTP-terminating proxy (NPM = OpenResty) breaks connection-bound Windows auth — NTLM/Negotiate must complete on one TCP connection, and NPM spreads the legs across pooled upstream connections. Symptom reported by users: Outlook cannot be configured from outside the office, while OWA and mobile (stateless auth) keep working. Pass-through fixes it end-to-end.
It also removes a second, quantified defect: ~82,000 ActiveSync Cmd=Ping 504s in the retained
log window (≈3,400/day, 100% of all 504s). NPM's default proxy_read_timeout 60s truncates every
ActiveSync push heartbeat; under pass-through IIS owns the long-poll and there is no proxy timeout.
Full rationale and evidence:
nextcloud/diagnostics/2026-08-26-43-meditsina-docker-npm-to-traefik-migration-plan.md.
Modelled on the proven 19-nutrilent and 05-osk deployments.
Files
| Repo file | Purpose | Goes on host at |
|---|---|---|
docker-compose.yaml |
the stack — the only file Portainer pulls | pulled by Portainer GitOps |
traefik.yml |
static config (entrypoints, ACME, providers) | /mnt/containers/traefik/container-data/traefik.yml |
dynamic/services.yml |
dynamic config (routers/services/middlewares) — hot-reloads | /mnt/containers/traefik/container-data/dynamic/services.yml |
users/dashboard.htpasswd.example |
template only | real file created on host, gitignored |
Portainer pulls only
docker-compose.yaml. The config files must already be on the host at absolute paths — Portainer runs compose relative to its own container, so relative./mountsresolve to empty dirs and Traefik crash-loops. Keep host copies in sync with this repo on every change.
Two design decisions that differ from the 19-nutrilent stack
1. ACME uses TLS-ALPN-01, not HTTP-01. httpChallenge makes Traefik install its own handler on
:80 for /.well-known/acme-challenge/, which would compete with forwarding that same path to
Exchange for its renewal. TLS-ALPN runs entirely on :443 and leaves :80 free. The Exchange SNIs
are pass-through and never request a Traefik cert, so there is no overlap.
2. The http→https redirect is a normal router, not an entrypoint redirection. An
entrypoint-level redirect is installed as an internal router at near-maximum priority and would
swallow the ACME path before exchange-acme could match. It is therefore a priority: 1 catch-all
router in dynamic/services.yml, with exchange-acme at priority: 1000.
Host preparation — already done 2026-08-26
Recorded here for rebuilds. Staged and pre-flighted on docker (43); config checksums verified
against this repo.
ssh -p 43009 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@localhost
H=/mnt/containers/traefik/container-data
mkdir -p $H/dynamic $H/users $H/acme $H/logs
# copy traefik.yml and dynamic/services.yml from a checkout — write to a temp name and
# `mv` into place (atomic): a plain scp truncates-then-writes and the file watcher can
# read mid-write, throwing a transient `yaml: mapping key already defined`.
touch $H/acme/acme.json && chmod 600 $H/acme/acme.json # 600 or Traefik refuses to start
# dashboard basic-auth — htpasswd is NOT installed on this host, use openssl:
PW="$(openssl rand -base64 15)"
printf 'admin:%s\n' "$(openssl passwd -apr1 "$PW")" > $H/users/dashboard.htpasswd
chmod 600 $H/users/dashboard.htpasswd; echo "$PW" # store in the password manager
reverseproxy-nw already exists. SELinux is Enforcing — every mount carries :Z.
Pre-flight (safe: no published ports, cannot touch NPM's :443)
timeout 15 docker run --rm --name traefik-precheck --network reverseproxy-nw \
-v $H/traefik.yml:/etc/traefik/traefik.yml:ro,Z -v $H/dynamic:/etc/traefik/dynamic:ro,Z \
-v $H/users:/etc/traefik/users:ro,Z -v $H/acme:/etc/traefik/acme:Z \
-v $H/logs:/var/log/traefik:Z traefik:v3.3
: > $H/acme/acme.json && chmod 600 $H/acme/acme.json # precheck may register an ACME account
Result 2026-08-26: config parses, all routers/services/middlewares resolve, ChallengeTLSALPN
provider starts. The only errors are ACME issuance failures — expected, because NPM still holds
:443 so the ALPN challenge cannot be answered yet.
Before cutover — prerequisites (both satisfied 2026-08-26)
1. Port 80 NAT → docker host (192.168.0.9) — done 2026-08-26. Verified: :80 is answered by
the docker host. This is what lets exchange-acme replace the manual NAT repoint that every past
Exchange renewal has needed. Do not point :80 back at Exchange — it would break the L7
redirect and is no longer necessary.
2. traefik.shcnw.ru DNS — done 2026-08-26 (A 217.15.22.194), so the dashboard router is
enabled. Note the general rule: a LAN-only router still needs a public A-record, because ACME
validates from the internet; ipAllowList gates access, not issuance.
Deploy (Portainer GitOps)
- Push this
traefik/folder. - Portainer → Stacks → Add stack → Repository → this repo, branch
main, compose pathtraefik/docker-compose.yaml, envTZ=Europe/Moscow. Do not start it while NPM holds:443— Traefik will crash-loop on the port conflict.
Recommended: first start against Let's Encrypt STAGING
The one thing the pre-flight could not prove is that TLS-ALPN issuance works while a TCP pass-through router shares the same entrypoint (the SNI sets are disjoint, so it should — but it is unproven here). Validate it without burning production rate limits:
# in traefik.yml, uncomment:
# caServer: https://acme-staging-v02.api.letsencrypt.org/directory
# cut over, confirm all 5 L7 routers get a (staging) cert and Exchange pass-through works, then:
# re-comment caServer, truncate acme.json, restart the container -> production certs issue
Cutover
docker stop nginx-proxy-manager nginx-proxy-manager-db goaccess # keep DEFINED for rollback
# deploy/start the traefik stack in Portainer
Then repoint the edge :80 NAT to 192.168.0.9.
Rollback is instant — NPM's data and config are untouched:
docker stop traefik && docker start nginx-proxy-manager nginx-proxy-manager-db
Keep NPM stopped-but-defined for ~1 week, then decommission.
Verification
# 1. Pass-through is real L4 — the SNI serves EXCHANGE's own cert, not a Traefik cert
echo | openssl s_client -connect 127.0.0.1:443 -servername mail.pda.ae 2>/dev/null \
| openssl x509 -noout -issuer -subject -ext subjectAltName
# -> issuer Let's Encrypt, subject CN=mail.alisailina.com, 24 SANs (NOT a Traefik-issued cert)
# 2. An L7 host gets a Traefik-issued cert
echo | openssl s_client -connect 127.0.0.1:443 -servername 1c.exprinta.ru 2>/dev/null \
| openssl x509 -noout -issuer -subject
# 3. ipAllowList works BOTH ways (proves Traefik sees the real client IP)
curl -sk -o /dev/null -w '%{http_code}\n' --resolve start.shcnw.ru:443:127.0.0.1 https://start.shcnw.ru/ # 403
curl -sk -o /dev/null -w '%{http_code}\n' --resolve start.shcnw.ru:443:192.168.0.9 https://start.shcnw.ru/ # 200/302
# 4. Self-signed backends must not 502 (proves serversTransport is wired)
curl -sk -o /dev/null -w 'crm=%{http_code}\n' --resolve crm.shcnw.ru:443:192.168.0.9 https://crm.shcnw.ru/
# 5. The ACME forward reaches Exchange rather than being redirected to https
curl -sI http://mail.shcnw.ru/.well-known/acme-challenge/probe # -> from IIS, NOT a 301
curl -sI http://mail.shcnw.ru/ # -> 301 to https (catch-all)
# 6. Container stable, no real errors
docker inspect traefik --format 'RestartCount={{.RestartCount}} Running={{.State.Running}}'
docker logs traefik --since 5m 2>&1 | grep -iE "level=err" | grep -vi "connection reset by peer"
End-to-end — the actual test: configure a desktop Outlook profile off-network. It must
autodiscover and authenticate. Then confirm OWA and mobile ActiveSync still work, that ActiveSync
Ping now holds well past 60 s, and that an external browser is refused on the LAN-only hosts.
Benign noise:
Error while handling TCP connection … 192.168.0.6:443 … connection reset by peeron the pass-through router is normal L4 keep-alive churn, not a fault — provided there are zero TLS/SNI/handshake errors andRestartCountstays 0.
Backend map (what replaced what)
| Old NPM proxy host | Domain(s) | Backend | Path here |
|---|---|---|---|
| 1, 9, 11, 12, 13, 14 | 21 × mail.* / autodiscover.* |
192.168.0.6:443 |
TCP passthrough exchange |
| — | +3 names that had no NPM vhost and failed the handshake outright (mail/autodiscover.alisailina.com, autodiscover.neviol.ru) |
192.168.0.6:443 |
same router — fixed for free, they are on Exchange's cert |
| 2 | crm.shcnw.ru, crm.inkam.navy |
192.168.0.10:443 (Bitrix, self-signed) |
crm (+insecure transport) |
| 10 | 1c.exprinta.ru |
192.168.0.4:80 (Apache 2.4.23 Win64) |
onec |
| 3 | start.shcnw.ru |
flame:5005 |
start (LAN-only) |
| 5 | portainer.shcnw.ru |
192.168.0.9:9443 |
portainer (LAN-only) |
| 6 | cockpit.shcnw.ru |
192.168.0.9:9090 |
cockpit (LAN-only) |
| 4 | nginx.shcnw.ru (NPM admin UI) |
— | dropped — NPM is gone |
| 8 | nginxlogs.shcnw.ru (goaccess) |
— | dropped — see below |
Post-cutover follow-ups
- Retire goaccess. It parses NPM's logs, which go stale once NPM stops, and Exchange — 99.1% of
traffic — moves to L4 where there is no HTTP access log. Remove the
goacess-for-nginxstack and drop thenginxlogs.shcnw.ruDNS record. Use Traefik's native Prometheus metrics instead. - Drop the
nginx.shcnw.ruDNS record. - NPM's admin UI was published on
0.0.0.0:81, bypassing its own192.168.0.0/22ACL. That exposure disappears with NPM; confirm nothing at the edge still forwards:81. - Decide
rdp.inkam.navy— it resolves here and has a stale NPM cert but no vhost; publish or retire. - Exchange keeps renewing its own certificate. With
exchange-acmein place that becomes automatic; verify at the next renewal (due 2026-10-20, cert expires 2026-11-24) that win-acme succeeds without a NAT change.