--- name: saiop-webapp-behind-traefik description: "Deploy a self-hosted web app on the SAIOP stack behind Traefik + Cloudflare, avoiding the recurring pitfalls hit deploying Portainer, NetBox/Neo4j, and Obsidian." version: 1.0.0 author: Claude Code (SAIOP ops session) license: MIT platforms: [linux] prerequisites: env_vars: [] commands: [docker, curl, dig] metadata: hermes: tags: [SAIOP, Traefik, Cloudflare, Docker, deployment, infrastructure] --- # Deploying a web app behind Traefik + Cloudflare on SAIOP Checklist distilled from real incidents during the Portainer, NetBox/Neo4j, and Obsidian deployments on this stack. Each item below caused a real outage or a multi-hour debugging session the first time; check all of them up front. ## When to use Any time a new container needs a public `*.atu.edu.gh` hostname through the existing Traefik + Cloudflare setup on the `saiops` VM. ## Checklist 1. **Never publish a host port directly** (`ports: ["8080:8080"]` on the app container). Docker's own iptables rules for published ports bypass UFW entirely — confirmed twice (Portainer, Gitea's SSH port). Always route through Traefik labels instead; no `ports:` block on the app service. 2. **Check for pre-provisioned secrets/DB before building from scratch.** This stack's `.env.master` and shared Postgres instance often already have a database/role and secrets created for a planned-but-not-yet-deployed service (confirmed for NetBox, n8n). `grep` `.env.master` for the service's name and `\l` the shared Postgres instance first. 3. **Check container UID before mounting host directories.** Don't assume `aiadmin`'s UID. `docker run --rm --entrypoint id ` first. Official Prometheus-ecosystem images often run as `nobody` (65534); LinuxServer.io images use `PUID`/`PGID` env vars — set them to match the actual owning user (this stack: `aiadmin` = 1001), not the LSIO default. 4. **Raw TCP protocols (anything not plain HTTP/S) cannot go through Cloudflare's free proxy.** If a service needs a non-HTTP port (e.g. Neo4j Bolt), either: (a) keep that one DNS record DNS-only and the main hostname proxied (split-hostname pattern — see NetBox/Neo4j skill), or (b) don't expose it publicly at all if it's only needed internally. 5. **Switching a previously-proxied hostname to DNS-only removes Cloudflare's CDN caching for that whole hostname, not just whatever you needed unblocked.** If large static assets (JS/CSS bundles) suddenly take 10-20s to load after such a switch, that's why — it's not a Traefik/app bug. Either keep the asset-serving hostname proxied and split out only the raw-protocol piece to a second hostname, or override `Cache-Control` via a Traefik `customResponseHeaders` middleware so Cloudflare can still cache despite an app-set `no-store` header. 6. **Self-hosted web-app bundles sometimes ship a CSP too strict for their own JS** (seen with Neo4j Browser needing `unsafe-eval`). If a page loads but renders blank with no obvious network error, check the browser console for CSP violations before assuming a deeper bug. 7. **Browsers require encrypted WebSocket (`wss://`) from an HTTPS page** — mixed-content policy blocks plaintext `ws://` even to a different host. If a backend protocol needs browser access over WebSocket (Neo4j Bolt, etc.), terminate TLS on that port at Traefik too (`tls=true` + `certresolver=letsencrypt` on the TCP router), not just the main HTTP route. 8. **When debugging "works on the server, fails for the client," isolate by distance before assuming the server is wrong:** test container-direct → through-proxy-on-localhost → through-proxy-externally. Every step here should be proven correct before trusting the next. Don't skip straight to blaming the client/network — but don't keep patching server config either once every layer up to "through-proxy-externally" has been proven clean. ## Don't do this - Don't add a host port publish "just to test" — it's the single most common way a new service silently bypasses the firewall on this box. - Don't assume a slow page load is a config bug before checking your own test connection's raw bandwidth against a known-good CDN endpoint (`curl https://speed.cloudflare.com/__down?bytes=2000000`).