Security
The self-host stack holds maintainer credentials and policy. Keep those boundaries explicit.
Secret handling
- Never bake secrets
- Images should not contain .env files, private keys, API keys, webhook secrets, REES secrets, or CLI auth files.
- Prefer secret files
- Use FOO_FILE for multiline values and orchestrator-managed secrets where possible.
- Rotate deliberately
- Rotate GitHub webhook secrets, API tokens, REES secrets, and provider keys with a restart window and validation PR.
Private policy
Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules inGITTENSORY_REPO_CONFIG_DIR, not in public repo config.
GITTENSORY_REPO_CONFIG_DIR=/configNetwork exposure
- Expose the webhook endpoint only through TLS — see "TLS termination" below for the two shipped ways to get there.
- Prometheus, Qdrant, Ollama, and the database ports are private by default (bound to
127.0.0.1or only reachable on the compose network) — but Grafana is the exception. Its compose entry publishes3000:3000, which binds every interface, not just localhost. Bind it yourself (127.0.0.1:3000:3000in a compose override) — the reliable fix — before running theobservabilityprofile 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 ortailscale servestep. - Put an auth layer in front of dashboards and internal admin routes.
- Use
/readyfor 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.
Control-panel access
GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by ADMIN_GITHUB_LOGINS — a comma- or whitespace-separated, case-insensitive allowlist of GitHub logins.
ADMIN_GITHUB_LOGINS=your-github-login,a-second-maintainerMCP_READ_REPO_ALLOWLIST / MCP_ACTUATION_REPO_ALLOWLIST).AI credential boundaries
REES boundary
REES receives PR diff and file metadata. Use a private network URL when possible, requireREES_SHARED_SECRET, and remember that the engine treats REES output as untrusted advisory context.
TLS termination
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.
- Caddy (--profile caddy)
- 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.
- Tailscale (--profile tailscale)
- 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.
- Bring your own reverse proxy
- Skip both profiles and put an existing nginx/Traefik/ALB in front of the gittensory service's own port instead.
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. 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.)
DOMAIN=reviews.yourcompany.comThe 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:
{$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
}
}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.
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: adds tailnet reachability
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.
TS_AUTHKEY= # generate at tailscale.com/admin/settings/keys
TS_EXTRA_ARGS= # optional, e.g. --advertise-tags=tag:self-hostgittensory 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 output boundary
Public PR comments and checks must not leak secrets, private policy, provider credentials, private scoring context, or maintainer-only notes. For hosted and self-host boundaries, keep Privacy and security nearby.