Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a fresh
the token in an <code>x-setup-token</code> header or <code>Authorization: Bearer</code>
header instead; never place the setup token in the URL.
</p>
<Callout variant="note">
<code>https://reviews.example.com</code> above is a placeholder — it assumes you already
have a real domain terminating TLS. GitHub delivers webhooks to whatever{" "}
<code>PUBLIC_API_ORIGIN</code> you set here, so it must be an address GitHub's servers can
actually reach: the <code>caddy</code> profile (see{" "}
<Link to="/docs/self-hosting-security">Security</Link>'s TLS termination section) is the
shipped way to get one, or bring your own public reverse proxy. The <code>tailscale</code>{" "}
profile's private tailnet address does <strong>not</strong> 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.
</Callout>
<Callout variant="note">
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
Expand Down Expand Up @@ -243,7 +255,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{" "}
<Link to="/docs/self-hosting-security">Security</Link>'s TLS termination section for how to
stand one up: the <code>caddy</code> profile for a public domain, or note that{" "}
<code>tailscale</code>'s private tailnet address does not satisfy push mode's
internet-reachable requirement — pull mode is the right fit for a Tailscale-only instance.
</Callout>
<Callout variant="warn" title="Brokered mode operational risks">
Before enabling this for anyone outside a controlled managed-beta cohort, weigh: (1){" "}
Expand Down Expand Up @@ -293,15 +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.
</p>

<h2>Webhook checks</h2>
<h2>Connectivity checks</h2>
<p>
Confirm you can reach the instance at all before checking GitHub's own webhook delivery:
</p>
<CodeBlock
lang="bash"
code={`curl https://reviews.example.com/health
curl https://reviews.example.com/ready`}
/>
<p>
<code>reviews.example.com</code> here stands in for whatever you're checking from — the{" "}
<code>caddy</code> 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{" "}
<em>you</em> can reach the instance, not that <em>GitHub</em> 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{" "}
<code>PUBLIC_API_ORIGIN</code>).
</p>
<p>
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{" "}
<Link to="/docs/self-hosting-operations">Operations</Link> for log and metric checks.
</p>
</DocsPage>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>ORB_AIR_GAP=true</code> for an instance that sends nothing.
</Callout>
<p>
<code>--profile caddy</code> gets you real public HTTPS; <code>--profile tailscale</code>{" "}
adds private tailnet reachability (it does not remove the default public port on its own —
see the callout below) — see <Link to="/docs/self-hosting-security">Security</Link>'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).
</p>
</DocsPage>
);
}
185 changes: 183 additions & 2 deletions apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,39 @@ function SelfHostingSecurity() {

<h2>Network exposure</h2>
<ul>
<li>Expose the webhook endpoint only through TLS.</li>
<li>Keep Prometheus, Grafana, Qdrant, Ollama, and database ports private by default.</li>
<li>
Expose the webhook endpoint only through TLS — see "TLS termination" below for the two
shipped ways to get there.
</li>
<li>
Prometheus, Qdrant, Ollama, and the database ports are private by default (bound to{" "}
<code>127.0.0.1</code> or only reachable on the compose network) — but{" "}
<strong>Grafana is the exception</strong>. Its compose entry publishes{" "}
<code>3000:3000</code>, which binds every interface, not just localhost. Bind it yourself
(<code>127.0.0.1:3000:3000</code> in a compose override) — the reliable fix — before
running the <code>observability</code> profile anywhere it isn't already firewalled.
Running Tailscale alongside it does <strong>not</strong> narrow this on its own (see "TLS
termination" below); combining the two safely still needs the same firewall or{" "}
<code>tailscale serve</code> step.
</li>
<li>Put an auth layer in front of dashboards and internal admin routes.</li>
<li>
Use <code>/ready</code> for orchestrators, not as a public status surface.
</li>
</ul>
<p>
The <code>observability</code> profile also runs a <code>docker-proxy</code> service that
never appears in any dashboard or metric. It fronts the Docker socket for Promtail's
container log discovery: a plain <code>:ro</code> bind-mount of{" "}
<code>/var/run/docker.sock</code> 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. <code>docker-proxy</code> is the only container that touches the socket,
exposes just the read-only <code>/containers/*</code> and <code>/networks/*</code> 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.
</p>

<h2>Control-panel access</h2>
<p>
Expand Down Expand Up @@ -103,6 +129,161 @@ function SelfHostingSecurity() {
untrusted advisory context.
</p>

<h2>TLS termination</h2>
<p>
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 <em>publicly reachable</em> origin. If
GitHub itself needs to reach this instance (a direct App in push mode, per{" "}
<Link to="/docs/self-hosting-github-app">GitHub App and Orb</Link>), 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{" "}
<Link to="/docs/self-hosting-github-app">brokered, pull-mode</Link> instance that never
needs to receive an inbound webhook at all.
</p>
<FeatureRow
items={[
{
title: "Caddy (--profile caddy)",
description:
"A public HTTPS terminator with automatic Let's Encrypt certificates. Required for a direct App in push mode; use this when the instance needs a real internet-facing domain.",
},
{
title: "Tailscale (--profile tailscale)",
description:
"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",
description:
"Skip both profiles and put an existing nginx/Traefik/ALB in front of the gittensory service's own port instead.",
},
]}
/>

<h3>Caddy: automatic HTTPS with Let's Encrypt</h3>
<p>
The <code>caddy</code> profile runs Caddy 2 in front of the <code>gittensory</code> service,
terminating TLS on <code>80</code>/<code>443</code>/<code>443/udp</code> (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 <code>DOMAIN</code> at this host's public IP{" "}
<em>before</em> 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 <strong>not</strong> 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 <code>localhost</code>, below, is a
deliberately different case — Caddy issues its own internal-CA cert for those automatically,
since it can never get a real one.)
</p>
<CodeBlock filename=".env" code={`DOMAIN=reviews.yourcompany.com`} />
<p>
The shipped <code>caddy/Caddyfile</code> reverse-proxies to <code>gittensory:8787</code> on
the compose network, forwards the real client IP, enables compression, sets standard
security headers (HSTS, <code>X-Content-Type-Options</code>, <code>X-Frame-Options</code>, a
strict referrer policy), and logs as JSON to stderr:
</p>
<CodeBlock
filename="caddy/Caddyfile"
code={`{$DOMAIN} {
reverse_proxy gittensory:8787 {
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
}

encode zstd gzip

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}

log {
output stderr
format json
}
}`}
/>
<p>
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{" "}
<code>DOMAIN=localhost</code>; Caddy issues a self-signed cert and your browser will warn
about it, which is expected.
</p>
<Callout variant="warn" title="Remove the app's own port mapping">
The <code>gittensory</code> service's compose entry has a direct{" "}
<code>{`ports: ["\${PORT:-8787}:8787"]`}</code> mapping with a comment marking exactly this:
remove it once Caddy is your public listener, or the app stays reachable on{" "}
<code>:8787</code> 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{" "}
<em>opposite</em> treatment; see its own callout.)
</Callout>
<p>
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{" "}
<code>{`{$DOMAIN}`}</code> block at a file-based TLS directive (
<code>tls /path/to/cert /path/to/key</code>) instead of the automatic-HTTPS default; see{" "}
<a
href="https://caddyserver.com/docs/caddyfile/directives/tls"
target="_blank"
rel="noreferrer"
>
Caddy's <code>tls</code> directive docs
</a>{" "}
for the syntax.
</p>

<h3>Already run a reverse proxy or load balancer?</h3>
<p>
Skip the <code>caddy</code> profile entirely. Remove the same direct <code>ports:</code>{" "}
mapping from the <code>gittensory</code> service, keep it on the compose network (or publish{" "}
<code>8787</code> 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 <code>8787</code> and preserve the
client IP the same way the shipped Caddyfile does.
</p>

<h3>Tailscale: adds tailnet reachability</h3>
<p>
The <code>tailscale</code> profile joins the stack to your tailnet. It runs with{" "}
<code>network_mode: host</code> — Tailscale needs host networking to advertise this
machine's address on the tailnet. On its own, this only <em>adds</em> a reachable address;
see the callout below before assuming it also removes public reachability.
</p>
<CodeBlock
filename=".env"
code={`TS_AUTHKEY= # generate at tailscale.com/admin/settings/keys
TS_EXTRA_ARGS= # optional, e.g. --advertise-tags=tag:self-host`}
/>
<Callout variant="warn" title="Unlike Caddy, keep the app's port mapping">
Tailscale doesn't replace the <code>gittensory</code> service's listener the way Caddy does
— it adds a new network interface to the <em>host</em>. Docker's default{" "}
<code>{`ports: ["\${PORT:-8787}:8787"]`}</code> mapping publishes to all of the host's
interfaces, so once Tailscale is up, that same mapping is what makes port <code>8787</code>{" "}
reachable at the host's tailnet IP too —{" "}
<strong>
removing it, as you would for Caddy, makes the app unreachable everywhere, tailnet
included.
</strong>
</Callout>
<p>
The tradeoff: leaving the default <code>0.0.0.0</code>-bound mapping in place means{" "}
<code>8787</code> 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 <em>only</em> via the tailnet, either firewall the host to allow{" "}
<code>8787</code> solely from your tailnet's address range, or bind the app's mapping to{" "}
<code>127.0.0.1:8787:8787</code> and use{" "}
<a href="https://tailscale.com/kb/1242/tailscale-serve" target="_blank" rel="noreferrer">
<code>tailscale serve</code>
</a>{" "}
inside the <code>tailscale</code> container (it shares the host's loopback under{" "}
<code>network_mode: host</code>) to proxy that localhost-only port onto the tailnet — check
the pinned image's <code>tailscale serve --help</code> 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.
</p>

<h2>Public output boundary</h2>
<p>
Public PR comments and checks must not leak secrets, private policy, provider credentials,
Expand Down
Loading