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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ GITTENSORY_REVIEW_DRAFT=false
# For `docker compose up` self-hosting (NOT the Cloudflare Worker deploy). Copy this file to `.env`
# (gitignored), UNCOMMENT + fill the required Core secrets in section 2, then add the runtime values below.
# Every value here is a SAMPLE placeholder — never commit real secrets.
#
# RECOMMENDED over pasting secret values into this file at all: docker-compose.yml's native `secrets:`
# mounts (secrets/README.md) cover the Core secrets above plus TOKEN_ENCRYPTION_SECRET,
# DRAFT_TOKEN_ENCRYPTION_SECRET, SELFHOST_SETUP_TOKEN, ORB_ENROLLMENT_SECRET, and PAGERDUTY_ROUTING_KEY.
# Run `./scripts/selfhost-init-secrets.sh` once, then write each real value into its file under secrets/
# instead of uncommenting the var here — an inline .env value always takes priority if you set both, so
# migrating is safe to do one secret at a time.

# PUBLIC_API_ORIGIN=https://reviews.example.com # REQUIRED before the first-run setup wizards (GET /setup and
# # GET /orb/setup). The wizard embeds this origin in the GitHub App
Expand Down
11 changes: 10 additions & 1 deletion .env.selfhost.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
# =============================================================================
# 1. Required secrets — the app will not start without these
# =============================================================================
#
# RECOMMENDED: use native Docker Compose secret files instead of pasting values below. Run
# `./scripts/selfhost-init-secrets.sh` once, then write each real secret into its file under
# secrets/ (see secrets/README.md) — docker-compose.yml already wires the matching <NAME>_FILE
# var for every secret in this section, so if you go that route you can leave the plain vars below
# commented out entirely. Either path works; an inline value here always wins over a secret file if
# both are set. GITHUB_APP_PRIVATE_KEY_FILE below is the one that has always supported this — the
# rest of the secrets in this section now do too.
GITHUB_APP_ID=123456
GITHUB_APP_SLUG=my-gittensory-app
GITHUB_APP_PRIVATE_KEY_FILE=/run/secrets/github-app-private-key.pem # or GITHUB_APP_PRIVATE_KEY= inline
Expand All @@ -21,7 +29,8 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json
# The four secrets below are commented out ON PURPOSE — do not paste a placeholder string here.
# Generate a real random value for EACH ONE individually (e.g. `openssl rand -hex 32`) and uncomment
# it; the app's boot-time preflight check refuses to start with a known-placeholder or too-short
# value, so copy-pasting the same string into more than one of these will also fail preflight.
# value, so copy-pasting the same string into more than one of these will also fail preflight. Or
# skip uncommenting these entirely and write the value into secrets/<name>.txt instead (see above).
# GITHUB_WEBHOOK_SECRET= # the sole HMAC key GitHub webhook deliveries are verified against
# GITTENSORY_API_TOKEN= # server-to-server API bearer token — bypasses per-repo write checks
# GITTENSORY_MCP_TOKEN= # shared MCP bearer token
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ output/
.env.*
!.env.example
!.env.selfhost.example
# Docker Compose secret files: the actual secret VALUES an operator drops into
# secrets/<name>.txt for the native `secrets:` mount (docker-compose.yml), read via the existing generic
# <NAME>_FILE convention (src/selfhost/load-file-secrets.ts). Same exposure class as .env above -- never
# committed. README.md stays tracked (it's the setup doc, not a secret) via the negation below.
secrets/*
!secrets/README.md
docker-compose.override.yml
!docker-compose.override.yml.example
*.local
Expand Down
17 changes: 17 additions & 0 deletions apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ function SelfHostingSecurity() {
},
]}
/>
<p>
<code>docker-compose.yml</code> ships native Docker Compose <code>secrets:</code> mounts for
the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/internal-job
tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret,
and the PagerDuty routing key) — file-mounted at <code>/run/secrets/&lt;name&gt;</code>,
never exposed via <code>docker inspect</code> or <code>docker compose config</code> the way
a plain <code>environment:</code>/<code>env_file</code> value is. This is purely additive:
an inline <code>.env</code> value always takes priority if you set both, so you can migrate
one secret at a time, or not at all. See <code>secrets/README.md</code> for the full file
list.
</p>
<CodeBlock
filename="shell"
code={`./scripts/selfhost-init-secrets.sh # creates empty placeholder files (idempotent)
printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt
docker compose up -d --no-deps gittensory`}
/>

<h2>Private policy</h2>
<p>
Expand Down
57 changes: 57 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ services:
REDIS_URL: "${REDIS_URL:-redis://redis:6379}"
# Enables the GitHub GET-response cache when >0 (0 disables); not a per-entry TTL, see .env.example.
GITHUB_CACHE_TTL_SECONDS: "${GITHUB_CACHE_TTL_SECONDS:-20}"
# Native secret-file mounts (see the top-level `secrets:` block + secrets/README.md). Each points at
# the file Compose mounts below; src/selfhost/load-file-secrets.ts only reads it when the plain
# <NAME> variable above is unset, so an inline .env value always takes priority over these — purely
# additive, zero effect on an operator who has never touched secrets/.
GITHUB_APP_PRIVATE_KEY_FILE: "${GITHUB_APP_PRIVATE_KEY_FILE:-/run/secrets/github_app_private_key}"
GITHUB_WEBHOOK_SECRET_FILE: "${GITHUB_WEBHOOK_SECRET_FILE:-/run/secrets/github_webhook_secret}"
GITTENSORY_API_TOKEN_FILE: "${GITTENSORY_API_TOKEN_FILE:-/run/secrets/gittensory_api_token}"
GITTENSORY_MCP_TOKEN_FILE: "${GITTENSORY_MCP_TOKEN_FILE:-/run/secrets/gittensory_mcp_token}"
INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}"
SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}"
TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}"
DRAFT_TOKEN_ENCRYPTION_SECRET_FILE: "${DRAFT_TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/draft_token_encryption_secret}"
ORB_ENROLLMENT_SECRET_FILE: "${ORB_ENROLLMENT_SECRET_FILE:-/run/secrets/orb_enrollment_secret}"
PAGERDUTY_ROUTING_KEY_FILE: "${PAGERDUTY_ROUTING_KEY_FILE:-/run/secrets/pagerduty_routing_key}"
# Uncomment for Qdrant RAG vector store (--profile qdrant):
# QDRANT_URL: http://qdrant:6333
# Uncomment for Ollama AI (--profile ollama):
Expand All @@ -132,6 +146,19 @@ services:
# is deep-merged over (gitignored — never commit real policy; see config/examples/ for generic templates).
# Absent ⇒ Docker mounts an empty dir ⇒ defaults apply.
- ./gittensory-config:/config:ro
# Mounted read-only at /run/secrets/<name> (Compose's default target). See the top-level `secrets:`
# block above and secrets/README.md.
secrets:
- github_app_private_key
- github_webhook_secret
- gittensory_api_token
- gittensory_mcp_token
- internal_job_token
- selfhost_setup_token
- token_encryption_secret
- draft_token_encryption_secret
- orb_enrollment_secret
- pagerduty_routing_key
depends_on:
redis:
condition: service_healthy
Expand Down Expand Up @@ -938,6 +965,36 @@ services:
interval: 30s
retries: 5

# Native Docker Compose secrets: file-mounted (at /run/secrets/<name>), never
# exposed via `docker inspect`/`docker compose config` the way a plain `environment:`/`env_file` value is.
# Read by the app's existing generic <NAME>_FILE loader (src/selfhost/load-file-secrets.ts) via the
# `environment:` defaults set below on the gittensory service. Every file here is created empty (a no-op --
# an inline .env value always wins, see secrets/README.md) by `scripts/selfhost-init-secrets.sh`, which
# every deploy runs before `docker compose build/up` so a missing file source can never break a deploy for
# an operator who has not opted into this convention. Scoped to the always-on gittensory service's own
# highest-value secrets for now -- the same convention extends to any other service the same way.
secrets:
github_app_private_key:
file: ./secrets/github_app_private_key.pem
github_webhook_secret:
file: ./secrets/github_webhook_secret.txt
gittensory_api_token:
file: ./secrets/gittensory_api_token.txt
gittensory_mcp_token:
file: ./secrets/gittensory_mcp_token.txt
internal_job_token:
file: ./secrets/internal_job_token.txt
selfhost_setup_token:
file: ./secrets/selfhost_setup_token.txt
token_encryption_secret:
file: ./secrets/token_encryption_secret.txt
draft_token_encryption_secret:
file: ./secrets/draft_token_encryption_secret.txt
orb_enrollment_secret:
file: ./secrets/orb_enrollment_secret.txt
pagerduty_routing_key:
file: ./secrets/pagerduty_routing_key.txt

volumes:
gittensory-data:
gittensory-pg:
Expand Down
3 changes: 3 additions & 0 deletions scripts/deploy-selfhost-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ YAML
mapfile -t compose_args < <(compose_file_args)
compose_args+=(-f "$override_file")

echo "selfhost image deploy: ensuring secret placeholder files exist"
"$SCRIPT_DIR/selfhost-init-secrets.sh"

echo "selfhost image deploy: pulling $IMAGE"
docker compose "${compose_args[@]}" pull --policy always "$SERVICE"

Expand Down
6 changes: 6 additions & 0 deletions scripts/deploy-selfhost-prebuilt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ run_sentry_upload() {
sh -lc 'apt-get update >/dev/null && apt-get install -y --no-install-recommends ca-certificates git >/dev/null && git config --global --add safe.directory /work && (npx -y "$SENTRY_CLI_PACKAGE" releases new "$SENTRY_RELEASE" >/tmp/gittensory-sentry-release-new.log 2>&1 || true) && npx -y "$SENTRY_CLI_PACKAGE" releases set-commits "$SENTRY_RELEASE" --auto && npx -y "$SENTRY_CLI_PACKAGE" sourcemaps inject dist && node scripts/validate-selfhost-sourcemap.mjs && npx -y "$SENTRY_CLI_PACKAGE" sourcemaps upload --release="$SENTRY_RELEASE" dist && npx -y "$SENTRY_CLI_PACKAGE" releases finalize "$SENTRY_RELEASE" && chown -R "$HOST_UID:$HOST_GID" dist node_modules package-lock.json'
}

run_init_secrets() {
echo "selfhost deploy: ensuring secret placeholder files exist"
"$SCRIPT_DIR/selfhost-init-secrets.sh"
}

run_compose_deploy() {
local override_file
local -a compose_args
Expand Down Expand Up @@ -126,6 +131,7 @@ env_put SENTRY_RELEASE "$SENTRY_RELEASE"
env_put GITTENSORY_VERSION "$SENTRY_RELEASE"

run_node_build
run_init_secrets
run_sentry_upload
run_compose_deploy

Expand Down
51 changes: 51 additions & 0 deletions scripts/selfhost-init-secrets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Ensure every Docker Compose secret file docker-compose.yml's `gittensory` service references
# actually exists on disk, so `docker compose build`/`up` never fails on a missing `secrets:` source
# file -- Compose requires the file to exist even for an operator who has never touched this feature
# and is relying entirely on inline .env values (see secrets/README.md: an inline value always wins
# over the file, so a placeholder here is a pure no-op for that operator).
#
# IDEMPOTENT AND NON-DESTRUCTIVE: only ever creates a MISSING file, empty, chmod 600. Never touches a
# file that already exists (whether it's still an empty placeholder or a real secret an operator has
# since populated) -- safe to run on every deploy, unconditionally.
#
# Usage:
# ./scripts/selfhost-init-secrets.sh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."

SECRETS_DIR="secrets"

# Keep in sync with the `secrets:` table in docker-compose.yml and secrets/README.md.
SECRET_FILES=(
"github_app_private_key.pem"
"github_webhook_secret.txt"
"gittensory_api_token.txt"
"gittensory_mcp_token.txt"
"internal_job_token.txt"
"selfhost_setup_token.txt"
"token_encryption_secret.txt"
"draft_token_encryption_secret.txt"
"orb_enrollment_secret.txt"
"pagerduty_routing_key.txt"
)

mkdir -p "$SECRETS_DIR"

created=0
for name in "${SECRET_FILES[@]}"; do
path="$SECRETS_DIR/$name"
if [ ! -e "$path" ]; then
: >"$path"
chmod 600 "$path"
created=$((created + 1))
fi
done

if [ "$created" -gt 0 ]; then
echo "selfhost init-secrets: created $created empty placeholder file(s) in $SECRETS_DIR/"
else
echo "selfhost init-secrets: all secret files already present, nothing to do"
fi
68 changes: 68 additions & 0 deletions secrets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Docker Compose secret files

Native, orchestrator-managed secret storage for the self-host stack (`docker-compose.yml`'s
top-level `secrets:` block), per the "Prefer secret files" guidance on
[/docs/self-hosting-security](https://gittensory.aethereal.dev/docs/self-hosting-security).

## Why

Putting a real secret's *value* directly in `.env` means it's readable via `docker inspect`,
`docker compose config`, and any process on the host with access to the container's environment.
Mounting it as a **file** instead keeps the value out of both — the container only ever sees a
path, and the app reads the file's contents itself at startup.

## How it works

Every secret below is optional and additive. **Nothing here is required** — if you're not ready to
set up secret files, leave `.env` exactly as it is today (`GITHUB_APP_PRIVATE_KEY=...` etc. inline)
and this directory has no effect. `docker-compose.yml` sets a `<NAME>_FILE=/run/secrets/<name>`
default for every secret listed below, but the app's existing generic loader
(`src/selfhost/load-file-secrets.ts`) only ever reads the file when the plain `<NAME>` variable is
**not already set** — an inline `.env` value always wins. So the two mechanisms coexist safely:
migrate one secret at a time, or never migrate at all.

To use a secret file instead of an inline `.env` value:

1. Remove (or leave commented) the plain `<NAME>=...` line in `.env`.
2. Write the raw secret value into the matching file below, with no surrounding quotes and no
trailing newline requirement (the loader trims whitespace):
```sh
printf '%s' 'your-real-secret-value' > secrets/github_webhook_secret.txt
```
For the GitHub App private key specifically, write the full PEM file as-is:
```sh
cp /path/to/your-downloaded-key.pem secrets/github_app_private_key.pem
```
3. Restart the `gittensory` service (`docker compose up -d --no-deps gittensory`, or run
`./scripts/selfhost-update.sh`).

Every file below is `chmod 600`-worthy — the init script that creates the empty placeholders
(`scripts/selfhost-init-secrets.sh`) does this for you; keep that permission if you edit a file by
hand afterward.

## Files

| File | Env var | Purpose |
|---|---|---|
| `github_app_private_key.pem` | `GITHUB_APP_PRIVATE_KEY_FILE` | Your GitHub App's private key (PEM). |
| `github_webhook_secret.txt` | `GITHUB_WEBHOOK_SECRET_FILE` | HMAC key GitHub webhook deliveries are verified against. |
| `gittensory_api_token.txt` | `GITTENSORY_API_TOKEN_FILE` | Server-to-server API bearer token. |
| `gittensory_mcp_token.txt` | `GITTENSORY_MCP_TOKEN_FILE` | Shared MCP bearer token. |
| `internal_job_token.txt` | `INTERNAL_JOB_TOKEN_FILE` | Gates internal-only routes (e.g. `/v1/internal/*`). |
| `selfhost_setup_token.txt` | `SELFHOST_SETUP_TOKEN_FILE` | Unlocks the first-run `/setup` wizard. |
| `token_encryption_secret.txt` | `TOKEN_ENCRYPTION_SECRET_FILE` | AES-256-GCM master secret for maintainer BYOK keys at rest. |
| `draft_token_encryption_secret.txt` | `DRAFT_TOKEN_ENCRYPTION_SECRET_FILE` | AES-256-GCM secret for the contributor OAuth token (draft flow). |
| `orb_enrollment_secret.txt` | `ORB_ENROLLMENT_SECRET_FILE` | One-time enrollment secret for brokered Orb mode. |
| `pagerduty_routing_key.txt` | `PAGERDUTY_ROUTING_KEY_FILE` | PagerDuty Events API v2 routing key (experimental paging integration). |

This is not the full list of every secret-shaped env var the stack supports (AI provider API keys,
Discord/Slack webhooks, Postgres/Grafana credentials for their optional profiles, etc.) — it covers
the vars used by the always-on `gittensory` service. The same `<NAME>_FILE` convention works for any
of those too; add a matching `secrets:` entry in `docker-compose.yml` (or a
`docker-compose.override.yml`) if you want the same treatment for one of them.

## Never commit real files here

Everything in this directory except this README is gitignored. `scripts/selfhost-init-secrets.sh`
only ever creates **empty** placeholder files (so `docker compose build`/`up` never fails on a
missing file) — it never overwrites a file that already exists, so it's always safe to re-run.
Loading