Skip to content
Self-hosting

First release checklist

Everything to confirm before cutting orb-v0.1.0 — the first stable (non-prerelease) self-host image — after two beta cuts already validated the pipeline end to end. Run the smoke matrix against a candidate image before tagging any orb-vX.Y.Z or -rc/-beta prerelease; CI only exercises the plain SQLite + Redis + direct-App default.

Versioning and release trigger

orb-v0.1.0-beta.1 and orb-v0.1.0-beta.2 already exercised the full release pipeline — multi-arch build, provenance, SBOM, Sentry source-map upload and release validation, and GitHub Release creation — twice, successfully. Neither moved latest or produced an unmarked GitHub Release, because a prerelease tag never does. The natural next step is not a third beta: it is orb-v0.1.0, a plain X.Y.Z tag with no -rc/-beta suffix.

The release workflow (.github/workflows/release-selfhost.yml) resolves this distinction itself from the tag text, not from a separate flag — pushing orb-v0.1.0 runs through the identical build/provenance/SBOM/Sentry steps the two betas already proved out, but the PRERELEASE value it computes flips to false, which (per the release-image-tags guard added for this exact reason) is what allows the run to push the latest image tag and create a non-prerelease GitHub Release:

git fetch origin main
git tag orb-v0.1.0 origin/main   # only a commit reachable from main is accepted (verified in-workflow)
git push origin orb-v0.1.0
bash
Only a non-prerelease X.Y.Z tag ever moves latest or the repo's unmarked "Latest release" — a -rc/-beta tag runs the same pipeline but is always excluded from both. Confirm the tag has no prerelease suffix before pushing it; there is no undo for latest once an operator has pulled it.

Going forward, the scheme is ordinary semver under the orb-v prefix: orb-v0.1.1 for a patch, the next minor version for a feature bump, and an -rc.N/-beta.N suffix on any tag that should run the pipeline without touching latest or the default GitHub Release. This checklist and the smoke matrix below apply to every future cut, not just the first.

First-release checklist

Work through this list once, in order, before pushing the orb-v0.1.0 tag.

1. Smoke matrix green
Every applicable scenario in the matrix below passes against a locally built candidate image (docker buildx build --load -t gittensory:rc-candidate .).
2. Image-contents audit reviewed
The audit below (or a fresh re-read of the Dockerfile's runtime-prebuilt stage) confirms no source maps, .env, local auth, private config, secrets, or data volumes are baked in.
3. Sentry behavior confirmed
SENTRY_DSN unset boots cleanly with zero Sentry activity (initSentry short-circuits before importing @sentry/node); the release workflow still uploads source maps and validates the Sentry release regardless of what any operator's runtime DSN is set to.
4. Variant decision applied
Ship one default (full, INSTALL_AI_CLIS=true) image for this release — see the variant decision below.
5. Release notes drafted
The GitHub Release template below is filled in with what's supported, experimental, optional, and operator-owned for this version.
6. Tag pushed from a commit on main
git tag orb-v0.1.0 <sha-on-main> && git push origin orb-v0.1.0 — the workflow rejects any commit not reachable from main with "Self-host releases must be cut from a commit reachable from main."
7. Release environment approved
The release job runs under the release GitHub Environment; if reviewer approval is configured, approve the pending run so the build/push/Sentry/notes steps proceed.
8. Post-publish verification
docker pull the published orb-v0.1.0 and latest tags, confirm both resolve to the same digest, and re-run the fresh-install smoke scenario against the pulled (not locally built) image.

Smoke-test matrix

Every scenario below shares the same core check — scripts/smoke-selfhost.sh boots one container against a fresh Redis on an isolated network, waits for it to become healthy, and asserts on /health, /ready, /metrics, and startup log events. What changes per scenario is the env you pass in and which events you expect (or forbid).

ScenarioStepsPass criteria
Direct GitHub App (default)Run the base smoke command with no ORB_ENROLLMENT_SECRET./health, /ready ok; selfhost_migrations_applied logged; selfhost_orb_relay_register does NOT appear (relay is brokered-only).
Brokered — push modeSet ORB_ENROLLMENT_SECRET and a real, internet-reachable PUBLIC_API_ORIGIN.selfhost_orb_relay_register logged; selfhost_orb_relay_register_failed does NOT appear (failure here is error-level and release-blocking).
Brokered — pull modeSet ORB_ENROLLMENT_SECRET and ORB_RELAY_MODE=pull, no inbound origin needed.selfhost_orb_relay_register logged; a failed announce (warn-level) is tolerated since the drain loop keeps retrying.
Air-gapped / no telemetrySet ORB_AIR_GAP=true.No export attempt or export error logged; no outbound request to the collector URL at the network level.
AI provider (Claude Code / Codex / both)Set AI_PROVIDER to each supported value with real credentials.selfhost_ai_provider logged; selfhost_ai_cli_missing does NOT appear (release-blocking if it does — the image was built without INSTALL_AI_CLIS=true).
SQLite (default) / PostgresBase command covers SQLite; boot a Postgres container and set DATABASE_URL for the Postgres path.Both boot healthy and apply migrations; note in release notes which mode beta testers actually validated.
Redis (always-on) + optional Qdrant RAGBase command covers Redis; set QDRANT_URL against a booted Qdrant container for the RAG path.selfhost_redis_ready always logged; selfhost_vectorize logged only when QDRANT_URL is set.
Fresh installPull the published orb-v0.1.0 tag on a clean host (no prior volumes) and boot via compose.Container reports healthy; /ready returns 200 without any manual migration step.
Upgrade from a source-built deployOn an instance previously deployed via scripts/deploy-selfhost-prebuilt.sh, run scripts/deploy-selfhost-image.sh ghcr.io/<owner>/gittensory-selfhost:orb-v0.1.0.Only the gittensory service restarts (--no-deps); .env, data volumes, and gittensory-config/ are untouched; /ready returns 200 after the health-check wait.
Rollback to prior tagRe-run scripts/deploy-selfhost-image.sh pinned to the prior tag/digest (e.g. orb-v0.1.0-beta.2).Service restarts healthy on the older image; confirmed safe only when nothing since the prior tag added a forward-only migration the older code can't tolerate (see Updating and rolling back).
One-service app restartRe-run either deploy script against the same tag with other profile services (Postgres, Redis, Qdrant, Grafana) already up.Only the gittensory container recreates; profile-service containers and their volumes are never touched.
Sentry release validationConfirm the release workflow's "Validate Sentry release" step passed for this tag (source maps uploaded, release finalized, commits attached).review-enrichment/scripts/validate-sentry-release.mjs exits 0 within its 5-attempt retry-poll; the Sentry release id matches the baked GITTENSORY_VERSION.
Docs links resolveFollow every link in the release notes template below (setup guide, releases page, this checklist) from the published GitHub Release.Every linked docs page loads and matches the version being released.

The scenario-by-scenario commands below give exact env and expected/forbidden log events for each row above.

# Build (or use a published tag) once, then run each scenario against the same image:
docker buildx build --load -t gittensory:rc-candidate .
./scripts/smoke-selfhost.sh gittensory:rc-candidate
bash

Direct GitHub App mode (default)

No ORB_ENROLLMENT_SECRET — the container uses its own GitHub App private key. Telemetry export is always-on in this mode too; a clean run produces no export error.

# A private key is multiline PEM -- mount it as a file instead of an env value (SELFHOST_SMOKE_EXTRA_ENV
# is line-delimited and would truncate it). GITHUB_APP_PRIVATE_KEY_FILE is loaded into
# GITHUB_APP_PRIVATE_KEY at startup, same as every other *_FILE variable.
SELFHOST_SMOKE_EXTRA_VOLUMES="${TEST_APP_PRIVATE_KEY_PATH}:/run/secrets/github-app-private-key.pem:ro" \
SELFHOST_SMOKE_EXTRA_ENV="GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY_FILE=/run/secrets/github-app-private-key.pem" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_orb_export_error,selfhost_orb_relay_register" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
bash

selfhost_orb_relay_register must NOT appear here — relay registration is brokered-only and silently skips in direct mode (see GitHub App and Orb).

Brokered mode (private / managed-beta only)

ORB_ENROLLMENT_SECRET set — the container gets tokens from the central Orb instead of its own App key. Relay mode changes what "working" means: push mode (ORB_RELAY_MODE unset, the default) needs a real public PUBLIC_API_ORIGIN and a failed registration is release-blocking (logged at error); pull mode (ORB_RELAY_MODE=pull) needs no inbound endpoint at all and tolerates a failed registration (logged at warn) since the drain loop keeps retrying regardless. Run BOTH scenarios — they exercise genuinely different code paths, not just different env (see choosing a relay mode).

# Push mode (default) -- requires a real, internet-reachable PUBLIC_API_ORIGIN; the Orb
# SSRF-validates it server-side at registration time, so a loopback/private origin is rejected.
SELFHOST_SMOKE_EXTRA_ENV="ORB_ENROLLMENT_SECRET=${TEST_ENROLLMENT_SECRET}
PUBLIC_API_ORIGIN=https://selfhost-smoke.example" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_orb_relay_register" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_orb_relay_register_failed" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate

# Pull mode -- no PUBLIC_API_ORIGIN needed; the container polls the broker outbound instead of
# exposing an inbound endpoint. The right fit for NAT/tailnet operators with no public ingress.
SELFHOST_SMOKE_EXTRA_ENV="ORB_ENROLLMENT_SECRET=${TEST_ENROLLMENT_SECRET}
ORB_RELAY_MODE=pull" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_orb_relay_register" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_orb_relay_register_failed" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
bash

Air-gapped / no-telemetry mode

ORB_AIR_GAP=true disables the fleet-calibration export entirely. There is no "air-gap confirmed" log event — the export function returns before doing anything, so silence (no export error, no export attempt) is the signal. Confirm at the network level too: no outbound request to the collector URL.

SELFHOST_SMOKE_EXTRA_ENV="ORB_AIR_GAP=true" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_orb_export_error,selfhost_orb_relay_register" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
bash

AI provider: Claude Code / Codex / both

Each provider choice must log selfhost_ai_provider and must NOT log selfhost_ai_cli_missing (a CLI-subscription provider whose binary isn't on PATH silently produces no review output — this must be caught here, not in production).

# Claude Code only
SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=claude-code
CLAUDE_CODE_OAUTH_TOKEN=${TEST_CLAUDE_TOKEN}" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_ai_cli_missing" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate

# Codex only (requires the fail-closed opt-in)
SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=codex
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_ai_cli_missing" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate

# Codex primary, Claude Code fallback
SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=codex,claude-code
CODEX_AI_EFFORT=medium
CLAUDE_AI_EFFORT=medium
CLAUDE_CODE_OAUTH_TOKEN=${TEST_CLAUDE_TOKEN}
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_ai_cli_missing" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
bash
These need real credentials to reach a genuinely healthy /ready (it probes the configured AI provider). Where credentials aren't available for a given RC run, at minimum confirm selfhost_ai_cli_missing does NOT appear — that alone catches the release-blocking case (image built without INSTALL_AI_CLIS=true).

SQLite trial mode / Postgres production mode

SQLite is the default — the base smoke command above already covers it (no DATABASE_URL set). For Postgres, boot a Postgres container on the same network first and point DATABASE_URL at it.

docker network create gt-pg-smoke
docker run -d --name gt-pg --network gt-pg-smoke -e POSTGRES_PASSWORD=devpw -e POSTGRES_DB=gittensory postgres:16-alpine
SELFHOST_SMOKE_NETWORK=gt-pg-smoke \
SELFHOST_SMOKE_EXTRA_ENV="DATABASE_URL=postgres://postgres:devpw@gt-pg:5432/gittensory" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
docker rm -f gt-pg && docker network rm gt-pg-smoke
bash
SQLite is the trial/single-node default; recommend Postgres for production in release notes whenever this mode is what beta testers actually validated.

Redis cache + optional Qdrant RAG

Redis is always-on in every scenario above (the base script already boots it) — confirm selfhost_redis_ready appears with githubResponseCacheEnabled matching whatever GITHUB_CACHE_TTL_SECONDS you set. For the optional Qdrant RAG path, boot Qdrant on the same network and point QDRANT_URL at it.

SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_redis_ready" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate

# With Qdrant RAG:
docker network create gt-rag-smoke
docker run -d --name gt-qdrant --network gt-rag-smoke qdrant/qdrant:v1.18.2
SELFHOST_SMOKE_NETWORK=gt-rag-smoke \
SELFHOST_SMOKE_EXTRA_ENV="QDRANT_URL=http://gt-qdrant:6333" \
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_vectorize" \
./scripts/smoke-selfhost.sh gittensory:rc-candidate
docker rm -f gt-qdrant && docker network rm gt-rag-smoke
bash

Expected startup events

selfhost_listening
Always. HTTP server bound and accepting connections.
selfhost_migrations_applied
Always. The smoke script asserts this on every scenario.
selfhost_redis_ready
Always. Confirms the mandatory Redis dependency is reachable.
selfhost_ai_provider
Only when AI_PROVIDER is set. Confirms the provider chain resolved.
selfhost_vectorize
Only when QDRANT_URL is set. Confirms the Qdrant RAG backend is wired.
selfhost_orb_relay_register
Only in brokered mode. Confirms relay registration with the central Orb.

Known warnings: acceptable in beta vs. release-blocking

selfhost_orb_relay_register_failed (pull mode)
Acceptable in beta. Logged at warn — pull-mode relay still drains events outbound even when the announce fails.
selfhost_orb_relay_register_failed (push mode)
Release-blocking. Logged at error — a failed push-mode announce means the container looks alive but never receives events.
selfhost_ai_cli_missing
Release-blocking. A CLI-subscription provider that can't run silently produces zero review output in production.
selfhost_orb_export_error (isolated, one-off)
Acceptable in beta if transient (e.g. a single collector timeout) — the hourly retry recovers. Persistent recurrence across the whole smoke run is release-blocking.

Image-contents audit

The runtime-prebuilt target — what the release workflow actually builds and pushes (docker/build-push-action is invoked with target: runtime-prebuilt) — copies exactly three things on top of the runtime-base layer: the pre-bundled dist/server.mjs, the migrations/ SQL files, and config/examples/ (generic, safe reference templates — shipping them activates nothing, since GITTENSORY_REPO_CONFIG_DIR still points at an operator-mounted /config). Nothing else reaches that stage.

Source maps — NOT included
dist/server.mjs.map is produced during the build stage but only dist/server.mjs itself is COPYed into runtime-prebuilt (Dockerfile). The bundle's sourceMappingURL comment points at a file that does not exist in the image — the map is uploaded to Sentry in the release workflow instead and deliberately never ships.
.env / secrets — NOT included
.dockerignore excludes .env, .env.*, and .dev.vars (with .env.example explicitly re-allowed as a template). The Dockerfile never COPYs an env file at all; every secret is supplied at container run time via docker-compose.yml's env_file: .env or a mounted *_FILE path.
Local auth / subscription-CLI credentials — NOT included
The image bakes the Claude Code and Codex CLI binaries (when INSTALL_AI_CLIS=true) but no credentials. auth.json and **/.codex are in .dockerignore, and the Dockerfile symlinks /home/node/.codex to /data/codex — an operator-mounted volume — so any auth an operator sets up at runtime lands on their own persisted volume, never in an image layer.
Private repo config — NOT included
gittensory-config and **/gittensory-config are excluded via .dockerignore; only the generic config/examples/ reference templates are copied, and GITTENSORY_REPO_CONFIG_DIR is resolved against an operator-mounted /config at runtime.
Data volumes — NOT included
The SQLite database, Redis/Postgres/Qdrant data, and Grafana state are all named docker volumes declared in docker-compose.yml, mounted at runtime — none are part of the image's filesystem layers.
Deployment overrides — NOT included
docker-compose.override.yml(.example) and any host-specific compose profile config live outside the image entirely; the image only ever contains the application bundle plus its declared runtime dependencies.
apps/ (gittensory-ui) and test/ — excluded from the build context
Already in .dockerignore alongside the existing exclusions (shipped ahead of this checklist, in the same #1819 hardening pass): the self-host bundle's only entry point is src/server.ts and npm ci only reads the root package*.json, so the UI workspace app and the test suite are never read during the image build (~11MB of this repo's ~22MB tracked-file footprint kept out).
npm install cache — trimmed
npm cache clean --force already runs immediately after the AI-CLI global install in the same RUN layer, removing the ~180MB download cache (~/.npm/_cacache) that npm install -g leaves behind but nothing at runtime ever reads.

Net effect of the two .dockerignore/Dockerfile changes audited above (already shipped, not part of this checklist itself): the built image measured 754MB, down from 942MB before them. Re-verify the size on the actual published orb-v0.1.0 image as part of the checklist:

docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.1.0
docker images ghcr.io/jsonbored/gittensory-selfhost:orb-v0.1.0 --format '{{.Size}}'
bash
This audit is Dockerfile-derived, not a runtime scan. If a future dependency bump adds a postinstall step that writes somewhere unexpected, re-check the runtime-prebuilt stage's COPY/RUN steps directly rather than assuming this list still holds.

One default image, not full/minimal variants

INSTALL_AI_CLIS is already a Dockerfile build-arg toggle (default true), and INSTALL_VISUAL_REVIEW is a second, independent one (default false) — see custom images. That means the "minimal" image the requirement asks about is already buildable today by anyone who wants it, as a custom build.

For this first official release, publish only the one default (INSTALL_AI_CLIS=true) image under orb-v0.1.0. Reasons:

Every beta cut so far shipped this default
Both orb-v0.1.0-beta.1 and orb-v0.1.0-beta.2 published the full image; a first stable release that changes what's default would be validating something the betas never tested.
A second published tag doubles release surface for no proven demand
Publishing full and minimal both means twice the build/push/Sentry/SBOM matrix, twice the smoke-test matrix, and twice the tag-naming and docs surface to keep correct — before any operator has asked for a slimmer image.
The escape hatch already exists
An operator who wants a smaller image without the AI CLIs can build it themselves today: docker compose build --build-arg INSTALL_AI_CLIS=false gittensory. Nothing about shipping one default image forecloses adding a published minimal tag later.
Defer the full/minimal published-variant question, not the build-arg. If real operator demand for a smaller published tag shows up post-release, it's a follow-up release-workflow change (a second docker/build-push-action invocation with INSTALL_AI_CLIS=false and its own tag suffix), not a blocker for cutting orb-v0.1.0.

GitHub Release notes template

The release workflow's own "GitHub Release" step generates the notes body programmatically (see .github/workflows/release-selfhost.yml) — it does not use --generate-notes, specifically to avoid GitHub's 125,000-character release-body limit on a large commit history. The template below matches that generated body and extends it with the supported/experimental/optional/operator-owned breakdown this checklist calls for. Paste it into the release description in addition to (or in place of) the workflow-generated block when publishing orb-v0.1.0.

Gittensory Orb container image:

```bash
docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.1.0
```

Multi-arch (linux/amd64 + linux/arm64). See https://gittensory.aethereal.dev/docs/maintainer-self-hosting for setup.
Includes the Claude Code / Codex subscription CLIs by default; credentials stay runtime-only.
Sentry release id baked into the image: `gittensory-orb@0.1.0`.

## First stable release

This is the first non-beta self-host image, following orb-v0.1.0-beta.1 and orb-v0.1.0-beta.2.
The `latest` tag now points here.

## Supported

- Direct GitHub App mode (the container's own GitHub App private key).
- SQLite (trial/single-node) and Postgres (production) database backends.
- Redis cache (required in every mode).
- Claude Code and Codex AI providers, including a provider chain with fallback.
- Health/readiness endpoints (`/health`, `/ready`, `/metrics`) and the documented log-event contract.
- Rollback to a prior image tag and one-service (`gittensory` only) restart, via
  `scripts/deploy-selfhost-image.sh` / `scripts/deploy-selfhost-prebuilt.sh`.

## Experimental

- Brokered mode (`ORB_ENROLLMENT_SECRET`) — managed-beta / private use; both push and pull relay modes
  are smoke-tested but see less real-world traffic than direct App mode.
- Qdrant-backed RAG indexing.
- Visual review via an external Chrome sidecar (`INSTALL_VISUAL_REVIEW=true`).

## Optional

- Sentry error tracking — OFF by default; set your own `SENTRY_DSN` to enable. Release source maps
  are uploaded and validated for this image regardless of whether any operator enables runtime
  reporting.
- OpenTelemetry tracing/metrics export.
- Discord/Slack review-outcome notifications.
- The observability profile (Prometheus, Grafana, Loki, Alertmanager) and the backup profile.

## Operator-owned

- `.env`, `gittensory-config/`, and all data volumes (database, Redis, Qdrant, Grafana) — never
  overwritten by an update and never baked into the image.
- GitHub App credentials or `ORB_ENROLLMENT_SECRET`, AI-provider credentials, and any `SENTRY_DSN`.
- Resource limits and profile selection — see [Resource profiles](https://gittensory.aethereal.dev/docs/self-hosting-operations)
  for measured CPU/memory guidance per profile.
- Backup and restore procedure — see [Backup and scaling](https://gittensory.aethereal.dev/docs/self-hosting-backup-scaling).

Full changelog: compare against `orb-v0.1.0-beta.2`.
markdown

After every applicable scenario passes, continue with the normal upgrade flow to cut the tag and publish the image.