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). + Manual App creation (below) is still fully supported — for an air-gapped instance, a stricter change-review process, or simply a preference for reviewing every permission by @@ -243,7 +251,11 @@ ORB_RELAY_MODE=pull # or omit for push (the default) -- see "Choosing a relay m outage more gracefully (see the release checklist's known-warnings table below). Use push only once you already have a stable, publicly reachable HTTPS origin for this instance — the Direct App setup wizard, for instance, always requires one anyway, so an operator running - Direct App today has it available for brokered push mode too. + Direct App today has it available for brokered push mode too. See{" "} + Security's TLS termination section for how to + stand one up: the 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. Before enabling this for anyone outside a controlled managed-beta cohort, weigh: (1){" "} @@ -299,6 +311,13 @@ ORB_RELAY_MODE=pull # or omit for push (the default) -- see "Choosing a relay m code={`curl https://reviews.example.com/health curl https://reviews.example.com/ready`} /> +

+ 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). +

); } 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 60e9f87854..c45117d0ad 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx @@ -62,13 +62,37 @@ function SelfHostingSecurity() {

Network exposure

+

+ 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. +

Control-panel access

@@ -103,6 +127,132 @@ function SelfHostingSecurity() { untrusted advisory context.

+

TLS termination

+

+ 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. +

+ + +

Caddy: automatic HTTPS with Let's Encrypt

+

+ 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. +

+ + The 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. +

+ +

Already run a reverse proxy or load balancer?

+

+ 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. +

+ +

Tailscale: private-network access, no public port

+

+ 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 output boundary

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.

+ + Tailscale doesn't replace the 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.

Public output boundary

From 755c22b0d2d287f9ada2ea4cea1dd905c011b8f3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:37:00 -0700 Subject: [PATCH 3/4] fix(docs): scope the Caddy port-removal rule and stop implying Tailscale satisfies GitHub webhook delivery The earlier Caddy port-removal callout still named Tailscale as an alternative, directly contradicting the Tailscale section's own correction that keeping the mapping is required there. Also corrects docs.self-hosting-github-app.tsx: PUBLIC_API_ORIGIN and the wizard's webhook checks previously implied a Tailscale tailnet address could satisfy GitHub's webhook delivery requirement, which it cannot -- GitHub's servers can't reach a private tailnet address. A Tailscale- only instance needs brokered pull mode instead. --- .../routes/docs.self-hosting-github-app.tsx | 34 ++++++++++++------- .../src/routes/docs.self-hosting-security.tsx | 23 ++++++++----- 2 files changed, 36 insertions(+), 21 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 2aab90b941..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 @@ -78,11 +78,15 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh

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. Manual App creation (below) is still fully supported — for an air-gapped instance, a @@ -305,22 +309,28 @@ ORB_RELAY_MODE=pull # or omit for push (the default) -- see "Choosing a relay m scenario for the smoke tests that exercise both relay modes.

-

Webhook checks

+

Connectivity checks

+

+ 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() {

TLS termination

- 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.

The 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).

); 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 62a2d11dbf..f5ba5abfa6 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx @@ -71,9 +71,11 @@ function SelfHostingSecurity() { 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.
  • Put an auth layer in front of dashboards and internal admin routes.
  • @@ -148,7 +150,7 @@ function SelfHostingSecurity() { { title: "Tailscale (--profile tailscale)", description: - "A private-network sidecar — no public port at all, but also not reachable by GitHub's own webhook delivery. Use this for team/CI-only access, or alongside brokered pull mode.", + "Adds private tailnet reachability, but with the default port mapping left in place (required — see below), the app stays reachable on every host interface too, not just the tailnet; firewall the host or use tailscale serve for real no-public-port isolation. Also not reachable by GitHub's own webhook delivery — use this for team/CI-only access, or alongside brokered pull mode.", }, { title: "Bring your own reverse proxy", @@ -242,11 +244,12 @@ function SelfHostingSecurity() { client IP the same way the shipped Caddyfile does.

    -

    Tailscale: private-network access, no public port

    +

    Tailscale: adds tailnet reachability

    - 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.