From b64d44b62e1490b17f8aeefdce54d8024c04515b Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sat, 4 Jul 2026 18:01:18 -0700
Subject: [PATCH 1/4] docs(self-hosting): correct Grafana exposure claim,
document Caddy/Tailscale TLS profiles
security.tsx claimed Grafana's port was private by default alongside Prometheus/Qdrant/Ollama,
but its compose entry publishes 3000:3000 on every interface, unlike Qdrant's confirmed
127.0.0.1-only binding -- correct the claim and note the mitigation. The caddy and tailscale
compose profiles each had exactly one line of documentation anywhere; add a full TLS termination
walkthrough (Caddyfile contents, DNS prerequisites, removing the app's own port mapping,
bring-your-own reverse proxy, Tailscale's network_mode: host and tailnet reachability) and
cross-link it from github-app.tsx's PUBLIC_API_ORIGIN/relay-mode/webhook-check sections and from
quickstart's profile table. Also name and explain docker-proxy, the read-only Docker-socket proxy
that isolates Promtail's service discovery from the raw socket.
---
.../routes/docs.self-hosting-github-app.tsx | 21 ++-
.../routes/docs.self-hosting-quickstart.tsx | 7 +
.../src/routes/docs.self-hosting-security.tsx | 154 +++++++++++++++++-
3 files changed, 179 insertions(+), 3 deletions(-)
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..2aab90b941 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,14 @@ 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. If you don't yet, see{" "}
+ Security's TLS termination section for the two
+ shipped ways to get one: the caddy profile (a public domain with automatic
+ Let's Encrypt certs) or the tailscale profile (a private tailnet address, no
+ public port needed).
+ 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.
+ reviews.example.com here stands in for whatever fronts the app on real HTTPS —
+ the caddy profile's domain, an existing reverse proxy, or (for Tailscale
+ instances) the tailnet address on port 8787 instead of a public domain at all. See{" "}
+ Security's TLS termination section if you
+ haven't set one of those up yet.
+
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{" "}
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..cf2e22dbad 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:
set ORB_AIR_GAP=true for an instance that sends nothing.
+ --profile caddy and --profile tailscale are the two shipped ways
+ to get real HTTPS or a private, no-public-port address — see{" "}
+ Security's TLS termination section for the
+ full walkthrough of each (Caddyfile setup, DNS prerequisites, 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) or put it behind the Caddy or
+ Tailscale profile described below before running the observability profile
+ anywhere it isn't already firewalled.
+ /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 +127,132 @@ function SelfHostingSecurity() { untrusted advisory context.
+
+ A webhook endpoint and a stable PUBLIC_API_ORIGIN (see{" "}
+ GitHub App and Orb) both need real HTTPS.
+ The compose file ships two ways to get it without hand-rolling a reverse proxy, plus a third
+ option if you already run one.
+
+ 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, or the ACME HTTP-01 challenge Caddy runs on port 80
+ fails and it falls back to a self-signed cert.
+
+ 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 (or Tailscale, below) 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.
+
+ 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 instead of exposing
+ anything to the public internet. It runs with network_mode: host — Tailscale
+ needs host networking to advertise this machine's address on the tailnet — so once it's up,
+ the gittensory service is reachable at this host's tailnet IP on port{" "}
+ 8787, with no port published to the public internet at all.
+
+ As with Caddy, remove the gittensory service's own ports: mapping
+ — with the sidecar on host networking, GitHub webhook delivery and any operator access
+ should go through the tailnet address, not a publicly bound port left over from the default
+ stack. This 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,
From 7c6b5a4908dc835d676ccc860a1599642ab5ddcb Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sat, 4 Jul 2026 18:16:53 -0700
Subject: [PATCH 2/4] fix(docs): correct Tailscale port-mapping and Caddy
TLS-fallback claims
Unlike Caddy, Tailscale's sidecar (network_mode: host) doesn't replace
the app's listener -- it adds a new interface the existing 0.0.0.0
port-publish becomes reachable through. Telling operators to remove
the ports mapping, as for Caddy, makes the app unreachable everywhere,
tailnet included. Also corrects the Caddy TLS section: the shipped
Caddyfile has no fallback directive, so ACME failure for a real domain
does not silently substitute a self-signed cert -- only recognized
non-public hostnames like localhost get that automatically.
---
.../src/routes/docs.self-hosting-security.tsx | 43 ++++++++++++++-----
1 file changed, 33 insertions(+), 10 deletions(-)
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
index c45117d0ad..f5693eab9c 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
@@ -160,8 +160,13 @@ function SelfHostingSecurity() {
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, or the ACME HTTP-01 challenge Caddy runs on port 80
- fails and it falls back to a self-signed cert.
+ 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.)
@@ -236,21 +241,39 @@ function SelfHostingSecurity() {
The tailscale profile joins the stack to your tailnet instead of exposing
anything to the public internet. It runs with network_mode: host — Tailscale
- needs host networking to advertise this machine's address on the tailnet — so once it's up,
- the gittensory service is reachable at this host's tailnet IP on port{" "}
- 8787, with no port published to the public internet at all.
+ needs host networking to advertise this machine's address on the tailnet.
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.
+
+
- As with Caddy, remove the gittensory service's own ports: mapping
- — with the sidecar on host networking, GitHub webhook delivery and any operator access
- should go through the tailnet address, not a publicly bound port left over from the default
- stack. This 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.
+ 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.
https://reviews.example.com above is a placeholder — it assumes you already
- have a real domain terminating TLS. If you don't yet, see{" "}
- Security's TLS termination section for the two
- shipped ways to get one: the caddy profile (a public domain with automatic
- Let's Encrypt certs) or the tailscale profile (a private tailnet address, no
- public port needed).
+ 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.
+ Confirm you can reach the instance at all before checking GitHub's own webhook delivery: +
- reviews.example.com here stands in for whatever fronts the app on real HTTPS —
- the caddy profile's domain, an existing reverse proxy, or (for Tailscale
- instances) the tailnet address on port 8787 instead of a public domain at all. See{" "}
- Security's TLS termination section if you
- haven't set one of those up yet.
+ 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-security.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx index f5693eab9c..62a2d11dbf 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx @@ -129,22 +129,26 @@ function SelfHostingSecurity() {
- A webhook endpoint and a stable PUBLIC_API_ORIGIN (see{" "}
- GitHub App and Orb) both need real HTTPS.
- The compose file ships two ways to get it without hand-rolling a reverse proxy, plus a third
- option if you already run one.
+ 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.
gittensory service's compose entry has a direct{" "}
{`ports: ["\${PORT:-8787}:8787"]`} mapping with a comment marking exactly this:
- remove it once Caddy (or Tailscale, below) 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.
+ 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 —
From 0170668b37babde2980a24c90d6c5c636f5eca44 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sat, 4 Jul 2026 18:51:54 -0700
Subject: [PATCH 4/4] fix(docs): remove remaining no-public-port claims about
Tailscale
The FeatureRow summary, the section heading, its intro paragraph, the
Grafana-exposure note, and the quickstart page's profile pointer all
still claimed Tailscale removes public reachability on its own -- an
exhaustive grep found and corrected every instance, matching the
detailed explanation already added: the default port mapping keeps
the app reachable on every host interface until you additionally
firewall the host or use tailscale serve.
---
.../routes/docs.self-hosting-quickstart.tsx | 10 +++++-----
.../src/routes/docs.self-hosting-security.tsx | 19 +++++++++++--------
2 files changed, 16 insertions(+), 13 deletions(-)
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 cf2e22dbad..de8a35485e 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
@@ -214,11 +214,11 @@ OPT-IN, NOT REQUIRED FOR A TRIAL INSTANCE
set ORB_AIR_GAP=true for an instance that sends nothing.
- --profile caddy and --profile tailscale are the two shipped ways
- to get real HTTPS or a private, no-public-port address — see{" "}
- Security's TLS termination section for the
- full walkthrough of each (Caddyfile setup, DNS prerequisites, and when to pick one over the
- other).
+ --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) or put it behind the Caddy or
- Tailscale profile described below before running the observability profile
- anywhere it isn't already firewalled.
+ (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.
- The tailscale profile joins the stack to your tailnet instead of exposing
- anything to the public internet. It runs with network_mode: host — Tailscale
- needs host networking to advertise this machine's address on the tailnet.
+ 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.