4.3 KiB
name, description, version, author, license, platforms, prerequisites, metadata
| name | description | version | author | license | platforms | prerequisites | metadata | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| saiop-webapp-behind-traefik | Deploy a self-hosted web app on the SAIOP stack behind Traefik + Cloudflare, avoiding the recurring pitfalls hit deploying Portainer, NetBox/Neo4j, and Obsidian. | 1.0.0 | Claude Code (SAIOP ops session) | MIT |
|
|
|
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
-
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; noports:block on the app service. -
Check for pre-provisioned secrets/DB before building from scratch. This stack's
.env.masterand 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.masterfor the service's name and\lthe shared Postgres instance first. -
Check container UID before mounting host directories. Don't assume
aiadmin's UID.docker run --rm --entrypoint id <image>first. Official Prometheus-ecosystem images often run asnobody(65534); LinuxServer.io images usePUID/PGIDenv vars — set them to match the actual owning user (this stack:aiadmin= 1001), not the LSIO default. -
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.
-
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-Controlvia a TraefikcustomResponseHeadersmiddleware so Cloudflare can still cache despite an app-setno-storeheader. -
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. -
Browsers require encrypted WebSocket (
wss://) from an HTTPS page — mixed-content policy blocks plaintextws://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=letsencrypton the TCP router), not just the main HTTP route. -
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).