diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
index 92311b00b0..2daeea5ea8 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
@@ -76,6 +76,18 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh
the token in an x-setup-token header or Authorization: Bearer
header instead; never place the setup token in the URL.
https://reviews.example.com above is a placeholder — it assumes you already
+ have a real domain terminating TLS. GitHub delivers webhooks to whatever{" "}
+ PUBLIC_API_ORIGIN you set here, so it must be an address GitHub's servers can
+ actually reach: the caddy profile (see{" "}
+ Security's TLS termination section) is the
+ shipped way to get one, or bring your own public reverse proxy. The tailscale{" "}
+ profile's private tailnet address does not work here — GitHub cannot
+ deliver webhooks to it. A Tailscale-only instance should use brokered pull mode instead (it
+ polls for work rather than receiving pushed webhooks) — see "Pull vs. push relay mode"
+ below.
+ caddy profile for a public domain, or note that{" "}
+ tailscale's private tailnet address does not satisfy push mode's
+ internet-reachable requirement — pull mode is the right fit for a Tailscale-only instance.
+ Confirm you can reach the instance at all before checking GitHub's own webhook delivery: +
+ reviews.example.com here stands in for whatever you're checking from — the{" "}
+ caddy profile's domain, an existing reverse proxy, or (if you're on the same
+ tailnet) a Tailscale instance's tailnet address on port 8787. This only confirms{" "}
+ you can reach the instance, not that GitHub can — a Tailscale-only
+ instance in push mode will pass this check and still never receive a real webhook, since
+ GitHub itself cannot reach a private tailnet address (see the callout above on{" "}
+ PUBLIC_API_ORIGIN).
+
After installing the App on a test repo, open a small PR and confirm the webhook delivery - appears in GitHub and a job appears in self-host logs. Continue with{" "} + appears in GitHub and a job appears in self-host logs — this is the check that actually + proves GitHub can reach you. Continue with{" "} Operations for log and metric checks.
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx index 8c72690829..de8a35485e 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx @@ -213,6 +213,13 @@ OPT-IN, NOT REQUIRED FOR A TRIAL INSTANCE contract, not a flag you turn on. The one way to disable it is the explicit air-gap flag: setORB_AIR_GAP=true for an instance that sends nothing.
+ --profile caddy gets you real public HTTPS; --profile tailscale{" "}
+ adds private tailnet reachability (it does not remove the default public port on its own —
+ see the callout below) — see Security's TLS
+ termination section for the full walkthrough of each (Caddyfile setup, DNS prerequisites,
+ hardening Tailscale for real isolation, and when to pick one over the other).
+
127.0.0.1 or only reachable on the compose network) — but{" "}
+ Grafana is the exception. Its compose entry publishes{" "}
+ 3000:3000, which binds every interface, not just localhost. Bind it yourself
+ (127.0.0.1:3000:3000 in a compose override) — the reliable fix — before
+ running the observability profile anywhere it isn't already firewalled.
+ Running Tailscale alongside it does not narrow this on its own (see "TLS
+ termination" below); combining the two safely still needs the same firewall or{" "}
+ tailscale serve step.
+ /ready for orchestrators, not as a public status surface.
+ The observability profile also runs a docker-proxy service that
+ never appears in any dashboard or metric. It fronts the Docker socket for Promtail's
+ container log discovery: a plain :ro bind-mount of{" "}
+ /var/run/docker.sock only protects the socket inode, not the Docker API behind
+ it, so handing Promtail the raw socket is effectively host root — enumerate every container,
+ read each one's environment and secrets, tail every log, or start a privileged container and
+ escape to the host. docker-proxy is the only container that touches the socket,
+ exposes just the read-only /containers/* and /networks/* endpoints
+ Promtail's service discovery needs, denies every mutating call outright, and sits alone on
+ its own Docker network shared only with Promtail — publishing no host port isn't enough on
+ its own, since the default compose network is reachable by every other service in the stack.
+
@@ -103,6 +129,161 @@ function SelfHostingSecurity() { untrusted advisory context.
++ These are the three shipped ways to get real HTTPS without hand-rolling a reverse proxy — + but only Caddy and bring-your-own-proxy give you a publicly reachable origin. If + GitHub itself needs to reach this instance (a direct App in push mode, per{" "} + GitHub App and Orb), Tailscale's private + tailnet address does not satisfy that — GitHub's servers can't reach it. Tailscale is the + right fit when only your own team/CI needs access, or as the transport for a{" "} + brokered, pull-mode instance that never + needs to receive an inbound webhook at all. +
+
+ The caddy profile runs Caddy 2 in front of the gittensory service,
+ terminating TLS on 80/443/443/udp (the last for
+ HTTP/3) and obtaining a Let's Encrypt certificate automatically for whatever domain you set.
+ It needs a real DNS record: point DOMAIN at this host's public IP{" "}
+ before starting the profile. The shipped Caddyfile has no fallback TLS directive,
+ so if the ACME HTTP-01 challenge fails (DNS not propagated yet, port 80 unreachable), Caddy
+ does not silently substitute a self-signed cert for a real domain — it logs
+ the failure and retries with backoff, and the site has no working HTTPS until DNS and ACME
+ both succeed. (A recognized non-public hostname like localhost, below, is a
+ deliberately different case — Caddy issues its own internal-CA cert for those automatically,
+ since it can never get a real one.)
+
+ The shipped caddy/Caddyfile reverse-proxies to gittensory:8787 on
+ the compose network, forwards the real client IP, enables compression, sets standard
+ security headers (HSTS, X-Content-Type-Options, X-Frame-Options, a
+ strict referrer policy), and logs as JSON to stderr:
+
+ Edit this file directly if you need a different upstream, extra headers, or a second site
+ block — Caddy re-reads it on container restart. For local testing without a real domain, set{" "}
+ DOMAIN=localhost; Caddy issues a self-signed cert and your browser will warn
+ about it, which is expected.
+
gittensory service's compose entry has a direct{" "}
+ {`ports: ["\${PORT:-8787}:8787"]`} mapping with a comment marking exactly this:
+ remove it once Caddy is your public listener, or the app stays reachable on{" "}
+ :8787 with no TLS, bypassing the proxy entirely and defeating the whole point
+ of adding it. (This rule is Caddy-specific — the Tailscale profile below needs the{" "}
+ opposite treatment; see its own callout.)
+
+ Prefer certificates you already manage — an internal CA, a wildcard cert issued elsewhere —
+ instead of Let's Encrypt? Mount your own cert and key into the container and point the{" "}
+ {`{$DOMAIN}`} block at a file-based TLS directive (
+ tls /path/to/cert /path/to/key) instead of the automatic-HTTPS default; see{" "}
+
+ Caddy's tls directive docs
+ {" "}
+ for the syntax.
+
+ Skip the caddy profile entirely. Remove the same direct ports:{" "}
+ mapping from the gittensory service, keep it on the compose network (or publish{" "}
+ 8787 bound to a private interface your existing proxy can reach), and terminate
+ TLS the way you already do for everything else — nginx, Traefik, an AWS ALB, a Cloudflare
+ Tunnel. Whatever fronts it just needs to forward to port 8787 and preserve the
+ client IP the same way the shipped Caddyfile does.
+
+ The tailscale profile joins the stack to your tailnet. It runs with{" "}
+ network_mode: host — Tailscale needs host networking to advertise this
+ machine's address on the tailnet. On its own, this only adds a reachable address;
+ see the callout below before assuming it also removes public reachability.
+
gittensory service's listener the way Caddy does
+ — it adds a new network interface to the host. Docker's default{" "}
+ {`ports: ["\${PORT:-8787}:8787"]`} mapping publishes to all of the host's
+ interfaces, so once Tailscale is up, that same mapping is what makes port 8787{" "}
+ reachable at the host's tailnet IP too —{" "}
+
+ removing it, as you would for Caddy, makes the app unreachable everywhere, tailnet
+ included.
+
+
+ The tradeoff: leaving the default 0.0.0.0-bound mapping in place means{" "}
+ 8787 is also still reachable from your LAN, and from the public internet if
+ this host has a public interface at all — Tailscale doesn't narrow that on its own. If you
+ want the instance reachable only via the tailnet, either firewall the host to allow{" "}
+ 8787 solely from your tailnet's address range, or bind the app's mapping to{" "}
+ 127.0.0.1:8787:8787 and use{" "}
+
+ tailscale serve
+ {" "}
+ inside the tailscale container (it shares the host's loopback under{" "}
+ network_mode: host) to proxy that localhost-only port onto the tailnet — check
+ the pinned image's tailscale serve --help for the exact current flags. This
+ profile is the right choice when the instance only needs to be reachable by your own team or
+ CI, and you'd rather not manage a domain or certificate at all.
+
Public PR comments and checks must not leak secrets, private policy, provider credentials,