Files

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
linux
env_vars commands
docker
curl
dig
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 <image> 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).