Skip to content
Self-hosting

Operations

Daily operating checks for the review service: health, queue, logs, metrics, dashboards, and context services.

Health endpoints

/health
Liveness. Use for simple process checks.
/ready
Readiness. Use for orchestration because it waits for DB and migrations.
/metrics
Prometheus metrics for queues, jobs, HTTP requests, uptime, and AI usage.

Useful commands

docker compose ps
docker compose logs -f gittensory
curl http://localhost:8787/ready
curl http://localhost:8787/metrics
bash

Important log events

selfhost_listening
selfhost_migrations_applied
selfhost_ai_provider
selfhost_ai_review_plan
selfhost_embed_provider
selfhost_vectorize
selfhost_job_dead
selfhost_cron_error
review_context_fetch_failed
selfhost_webhook_enqueue_failed
selfhost_webhook_enqueue_binding_missing

Observability profile

The observability profile starts Prometheus, Alertmanager, Loki, Promtail, and Grafana with dashboards for infra, review activity, and AI usage.

Postgres installs also expose database internals through the bundled Postgres exporter: connection pressure, lock waits, long transactions, deadlocks, database/table growth, dead tuples, autovacuum activity, and backup freshness. Backup freshness appears when the backup profile is active.

When OpenTelemetry and Sentry are enabled, job audit logs and Sentry events include trace_id/span_id fields so an operator can jump from a failed job or issue to the matching trace in Grafana or Tempo.

docker compose --profile postgres --profile observability up -d
docker compose --profile postgres --profile observability --profile backup up -d
bash

Host clock sync (NTP)

A single NTP source is a silent single point of failure
GitHub App JWTs are signed with a timestamp from this process's clock, backdated 60 seconds for skew tolerance. If the host clock drifts past that margin, GitHub starts rejecting the JWT as not-yet-valid — every GitHub App request fails with a generic Bad credentials error, with no obvious link back to the clock. Configure at least two independent NTP sources on the host (not just in the container) so a single dead source can't silently take the whole clock out from under you.

Check sync health with chronyc sources (or ntpq -p on an ntpd host) — every configured source should show a nonzero Reach value; Reach: 0 means that source has never successfully synced. The gittensory_clock_skew_seconds gauge on the Clock Sync (NTP) row of the main Grafana dashboard tracks the live drift between this process and GitHub's server time, sampled from the Date header of the GitHub App's own installation-token mint calls — no extra network probe required. The bundled Prometheus rules alert at 60s (warning) and 120s (critical) drift, both well under the margin that actually breaks JWT auth.

Alerting — required for a 24/7 deployment

Alertmanager ships with a valid but silent default: every alert routes to a name-only receiver that discards it, so docker compose --profile observability up -d always starts clean even before you've configured anywhere to send notifications. This is intentional — the shipped config can't bake in a Slack/Discord/email destination that works for everyone — but it means nothing pages anyone until you enable a real receiver. Treat this as a required step, not an optional one, for any deployment you expect to run unattended.

Don't edit the committed alertmanager/alertmanager.yml in place — deploysgit pull this repo, so a local edit to a tracked file either blocks the next pull or gets silently overwritten by it. Instead, copy it to a gitignored alertmanager/alertmanager.local (matches the existing *.local ignore rule) and make your receiver/route changes there — the fastest verified path is uncommenting the discord receiver block and pointing the root route at it, using webhook_url_file: /etc/alertmanager/discord_url so the webhook URL itself lives in its own gitignored file next to it, never in a file docker-compose.yml or git ever tracks. Slack, email, and a generic webhook receiver (for PagerDuty or a custom handler) are also ready to uncomment in the same template. Then point Alertmanager at your local copy via docker-compose.override.yml:

services:
  alertmanager:
    command:
      - "--config.file=/etc/alertmanager/alertmanager.local"
      - "--storage.path=/alertmanager"
yaml

Restart with docker compose up -d --no-deps alertmanager to pick up both files. The whole alertmanager/ directory is mounted read-only into the container, so any gitignored file you add there (the local config, a secret file it references) shows up at the same path with no docker-compose.yml edit required.

Until you do, alerts are still visible without any extra setup: open Grafana and check the Alerts row on the main dashboard, which lists every currently-firing alert directly from Prometheus, independent of Alertmanager routing. Use this as your fallback check if you haven't wired up push notifications yet — it's exactly what the Dead jobs stay at zero routine check below is watching for.

Dead-lettered jobs also get one automatic revival attempt every 30 minutes (QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS), as long as the job hasn't already been revived more than a small, bounded number of extra times (QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS, default 3) — so a job that died from a bug that's since been fixed and redeployed recovers on its own within the next cycle, without needing direct database access. A job that keeps failing the same way eventually exhausts this budget and stays dead, which is exactly what the alert above is watching for.

Two different Discord/Slack integrations

Don't confuse these — they're unrelated features that happen to share the same two chat platforms:

Alertmanager → Discord/Slack (infra alerts)
Covered above. System/stack health: dead jobs, queue backlog, Postgres pressure, and similar operational alerts, routed by alertmanager/alertmanager.yml.
DISCORD_WEBHOOK_URL / SLACK_WEBHOOK_URL (per-PR outcomes)
A .env-configured webhook the review engine itself posts to whenever it publishes a review outcome (merged, closed, manual hold) on any repo you review — a product notification, not an infra alert.

DISCORD_WEBHOOK_URL is a global fallback Discord channel for any repo without its own webhook. DISCORD_REPO_WEBHOOKS is a per-repo override — a JSON map of owner/repo to a webhook URL — for routing different repos' notifications to different channels. Both are unset (no Discord notifications) by default.

.env
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
DISCORD_REPO_WEBHOOKS={"owner/repoA":"https://discord.com/api/webhooks/...","owner/repoB":"https://..."}

SLACK_WEBHOOK_URL posts the same per-action events (merged/closed/manual) as a Block Kit section to one Slack channel. Unlike Discord there is no per-repo map today — every repo shares this one webhook. Unset means no Slack notifications.

Resource profiles

Measured rows below come from a real production instance running the full profile set (qdrant + redis + observability + backup + postgres + ollama) at steady state —docker stats and docker system df snapshots, not a lab benchmark. Estimated rows are reasoned from that same baseline plus each service's declared deploy.resources.limits and image size in docker-compose.yml — they have not been measured directly and could be off, especially for CPU under real load. Treat estimates as a starting point for capacity planning, not a guarantee.

ProfileCPU (steady state)Memory (steady state)Basis
Minimal — app + redis only (no profile flags)~3% of one core~400–600MiBEstimated: app + redis measured in isolation from the full-profile snapshot (app 2.6% CPU / 365MiB; redis is idle-light and its 512MiB limit is never approached in the full-profile run either).
+ --profile postgres+14% of one core (highest single-service CPU consumer)+~200MiBMeasured: 14.24% CPU / 196MiB of its 2GiB limit — comfortable headroom on memory, but the largest CPU line item in the whole stack.
+ --profile qdrantLow single-digit %Well under its 2GiB limitMeasured (part of the full-profile snapshot's "everything else" low-CPU, under-limit group). Grows with RAG corpus size — expect this to climb on installs with many indexed repos.
+ --profile observabilityLow single-digit % per service, except Grafana/Tempo belowGrafana ~305MiB (60% of 512MiB); Tempo ~209MiB (20% of 1GiB); Prometheus/Loki/ Alertmanager/Promtail/otel-collector each well under their limitsMeasured. Grafana is the closest any service comes to its ceiling in production — worth watching if you add many custom dashboards or panels, but not currently a problem (40% headroom remains).
+ --profile ollamaNear-zero idle; spikes hard during inferenceModel-dependent, up to its 8GiB limitEstimated. Not part of the live production profile mix (that instance uses AI_PROVIDER=codex, not Ollama) — the 8GiB default limit is sized for a single loaded 7–8B quantized model per the compose comment, not measured against a running model. Idle Ollama with no model pulled is cheap; a loaded model can legitimately approach the limit, which is why it has the largest default ceiling in the file.
+ --profile backupNear-zero except during runsLow, bursts during dump/restoreMeasured as part of the full-profile snapshot (no dedicated resource limit is set for backup/backup-exporter — both are short-lived or idle-polling processes, not sustained consumers).
+ --profile runnersUnbounded by default — can starve the app under CI loadUnbounded by defaultEstimated, and explicitly a known risk, not a guess about typical usage: the runner service ships with no CPU/memory limit at all. Production experience already documented in docker-compose.override.yml.example found 3 uncapped runner containers starving the app for CPU on an 8-vCPU box under real CI load — see that file for the cpu_shares/cpus mitigation before co-locating runners with the review stack.
Full profile set (qdrant + redis + observability + backup + postgres + ollama, no active inference, no runners)Postgres (~14%) dominates; everything else low single-digit %No service near its limit except Grafana (~60%)Measured, in full, on a real production instance.

Disk

Measured on the same production instance: 48GB of 151GB used on the host root volume (32%) at steady state. docker system df breakdown:

Images
22.59GB total, 19.24GB (85%) reclaimable via prune.
Volumes
20.57GB total, 5.4GB (26%) reclaimable — this is real application state (databases, vector index, backups), so most of it is never pruned.
Build cache
6.39GB total, 3.55GB (56%) reclaimable.

The reclaimable image and build-cache space here is expected steady state, not a leak — this instance runs scripts/deploy-selfhost-prebuilt.sh, which rebuilds the image from the current git checkout on every deploy and intentionally keeps prior layers around in the build cache for faster rebuilds. The gittensory-docker-safe-prune systemd timer (below) already runs daily against this exact instance and reclaims it on a schedule, so this is not a number to chase down manually.

When a compose default might need to change

Every deploy.resources.limits.memory in docker-compose.yml is operator-overridable via .env (see the *_MEM_LIMIT variables in .env.example). Against the measured full-profile data above, none of the current defaults look miscalibrated enough to change: nothing sits consistently near its limit in a way that risks an OOM kill under normal load (Grafana's ~60% is the closest and still has real headroom), and nothing is so oversized relative to plausible usage that it should be lowered — including Ollama's comparatively large 8GiB ceiling, which is sized for holding one quantized model in memory, not idle overhead. The one real gap is --profile runners, which ships with no limit at all; that is a known, documented tradeoff (see the table above and docker-compose.override.yml.example) rather than an oversight, since the right ceiling depends entirely on the host's core count and how many runner replicas you run.

Capacity planning: how much disk for N repos at M PRs/month

The 151GB host above is one measured point, not a formula. It says nothing about how disk use grows as you register more repos or review more pull requests — for that you have to reason about which tables and volumes actually grow with activity, versus which are fixed overhead. Treat every number below as an order-of-magnitude estimate to plan around, not a guarantee.

review_audit (fixed overhead per PR, unbounded)
Roughly 2 rows per PR — one finalized gate decision plus one realized merge/close outcome — each a few small text columns (well under 1KB/row). It has no retention policy in src/db/retention.ts, so it grows forever. Don't trust a blanket MB-per-thousand-PRs estimate here; measure your own instance's actual growth with pg_total_relation_size('review_audit') (or the equivalent SQLite page count) after a known number of PRs, then extrapolate from that.
webhook_events (fixed overhead per delivery, unbounded)
One row per inbound GitHub webhook delivery — every push, comment, check-run update, and review event, not just PR opens — so it accrues considerably faster than review_audit for the same PR volume (commonly 5-15x, depending on how chatty a repo's CI and review activity are). Also absent from RETENTION_POLICY, so it also grows without bound. Still small per row; the growth to watch is row count over months, not any single row's size.
audit_events (bounded — 90-day retention)
One row per privileged/security-relevant action (recordAuditEvent in src/db/repositories.ts), pruned automatically: RETENTION_POLICY in src/db/retention.ts keeps 90 days and the prune-retention job runs daily at 03:00 UTC (src/index.ts), so this table's steady-state size is capped regardless of how long the instance has been running — it will not be a long-term capacity driver the way the two tables above are.
Postgres/SQLite backup dumps (scales with live DB size x retained copies)
scripts/backup.sh keeps the newest BACKUP_RETAIN copies per target (default 7 — see the backup and scaling doc's retention section), so total backup-volume usage is roughly (live database size) x (retained count), independent of repo count except through the database-size term. A growing, unpruned review_audit/webhook_events pair feeds directly into this multiplier: whatever they add to the live database, the backup volume carries N times over.

Putting it together: for a small install (a handful of repos, tens of PRs/month), all of this is noise against the ~20GB of fixed Docker/image/volume overhead measured above — you will not notice review_audit or webhook_events growth for a long time. The estimate gets real at higher volume: an install running hundreds of PRs/month across dozens of repos, left unattended for a year or more, is a plausible case where the unbounded tables above (and the backups that multiply them) become the dominant long-term disk driver rather than Docker images and build cache. There is no first-party tool yet to prune review_audit or webhook_events — if you operate at that scale, monitor their row counts directly (SELECT count(*) FROM review_audit, SELECT count(*) FROM webhook_events) rather than assuming steady state.

Docker resource hygiene

Every service in docker-compose.yml caps its own container logs (10MB × 3 rotated files) out of the box, so log growth alone won't fill your disk. Unused Docker images and build cache are a separate, larger disk-growth vector on a host that rebuilds or pulls images repeatedly over months — Docker does not reclaim either automatically.

Install the provided host-level timer to reclaim both on a schedule (anything unused for less than 7 days is left alone, so a recent deploy is never at risk):

sudo cp systemd/gittensory-docker-prune.service.example /etc/systemd/system/gittensory-docker-prune.service
sudo cp systemd/gittensory-docker-prune.timer.example /etc/systemd/system/gittensory-docker-prune.timer
sudo $EDITOR /etc/systemd/system/gittensory-docker-prune.service   # set WorkingDirectory / ExecStart to your path
sudo systemctl daemon-reload
sudo systemctl enable --now gittensory-docker-prune.timer
bash

Run it manually at any time with docker system df before and after to see what it reclaimed: sh scripts/selfhost-docker-prune.sh.

This should always prune containers, images, and build cache — never volumes. Pruning a volume deletes real application state (the database, backups, vector index, or a runner's registration and job data), not disposable build output, so it is never part of routine cleanup unless you intentionally want to delete that state.

Self-hosted runner temp storage

If you run --profile runners, keep every runner job's scratch/temp writes on the mounted runner-work volume, never the container's plain /tmp. A container's own /tmp lives in Docker's overlay/containerd snapshot storage — a CI job that writes high-volume temp data there (language toolchain caches, build artifacts, ad hoc mktemp calls) grows the host's Docker root storage directly, not the volume, so it is invisible to volume-scoped cleanup and can fill the disk out from under the whole stack. The shipped runner service points TMPDIR, TMP, and TEMP at /tmp/runner/tmp (a subdirectory of the mounted runner-work volume) and keeps RUNNER_WORKDIR at /tmp/runner on the same volume. A one-shot runner-tmp-init service creates that directory on the volume (and makes it world-writable, matching real /tmp permissions) before the runner container starts, so this works out of the box on a fresh volume with no manual steps.

Adding a second or third runner service in docker-compose.override.yml for higher CI throughput? Each one needs its own runner-work-style volume, its own init step, and the same temp env — YAML anchors don't cross separate compose files, so repeat the extension block in your override file:

x-runner-tmp-env: &runner-tmp-env
  TMPDIR: /tmp/runner/tmp
  TMP: /tmp/runner/tmp
  TEMP: /tmp/runner/tmp

services:
  runner-2-tmp-init:
    image: alpine:3.20
    profiles: ["runners"]
    volumes:
      - runner-work-2:/tmp/runner
    command: ["sh", "-c", "mkdir -p /tmp/runner/tmp && chmod 1777 /tmp/runner/tmp"]

  runner-2:
    image: myoung34/github-runner:ubuntu-jammy
    profiles: ["runners"]
    depends_on:
      runner-2-tmp-init:
        condition: service_completed_successfully
    environment:
      <<: *runner-tmp-env
      RUNNER_NAME: gittensory-runner-2
      RUNNER_SCOPE: ${RUNNER_SCOPE:-repo}
      REPO_URL: ${RUNNER_REPO_URL:-}
      RUNNER_TOKEN: ${RUNNER_TOKEN:-}
      RUNNER_WORKDIR: /tmp/runner
    volumes:
      - runner-work-2:/tmp/runner

volumes:
  runner-work-2:
yaml

Enabling Sentry (your own DSN)

Sentry is opt-in and off by default. Leave SENTRY_DSN unset for a complete no-op with negligible overhead — no events leave your box. When you want error tracking, point the runtime at a project you control in your own Sentry organization.

.env
# Your Sentry project DSN — never commit this to git
SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
SENTRY_ENVIRONMENT=production
SENTRY_SERVER_NAME=gittensory-us-east
SENTRY_RELEASE=gittensory-selfhost@2026.07.05
# Optional: sample review tracing spans (0.05 = 5%)
# SENTRY_TRACES_SAMPLE_RATE=0.05

Official release images bake GITTENSORY_VERSION as the default release id; override with SENTRY_RELEASE when you tag custom builds. Mount secrets with SENTRY_DSN_FILE instead of inline env when you prefer a file-backed DSN. After changing Sentry env, restart the gittensory service — there is no hot reload.

Community self-hosters should send events only to their own DSN. The shipped stack never phones home to a maintainer-owned project unless you configure one.

Sentry context taxonomy

Self-host Sentry events carry a small, scrubbed taxonomy so operators can filter by subsystem without opening raw payloads. Structured error logs forwarded from console.error use the JSON event slug as the issue type; direct captures use a kind or review operation tag instead.

Tags (indexed, low cardinality)
repo (also indexed when logs emit repository), pr, head_sha, kind, subsystem, jobType, operation, agent, decision_outcome, provider, model, monitor, installation_id_hash (never raw installation id), trace_id, span_id.
Contexts (full scrubbed detail)
gittensory (engine captures), review (failed reviews), log (structured console lines), sentry_monitor (cron failures), otel (active trace ids). Secrets, tokens, bodies, diffs, prompts, and review text are redacted before send.
Subsystems
webhook, queue, github, ai, gate, publish, scheduled, backup, relay — map to the engine paths named in issue #1824.

Cron monitor slugs follow gittensory-selfhost-{environment}-{loop} (for example gittensory-selfhost-production-scheduled-loop). Pair monitor alerts with queue depth, dead-job counts, and the matching structured log event.

Sentry alert classes and runbook

Tune Sentry alert rules for persistent failure classes, not one-off fail-open noise. The table below lists actionable signals, what they usually mean, and the first checks an operator should run. Prometheus/Grafana alerts in the observability profile cover the same failure modes from a metrics angle — use both when Sentry is enabled.

Alert classSentry signalThreshold guidanceFirst response
Dead-letter growthselfhost_job_dead, queue_pump_crashed, missed queue-dead-letter-revive monitorPage when dead jobs stay > 0 for >30m or the revive monitor misses twiceCheck Grafana dead-job panel, /metrics gittensory_jobs_dead_total, queue logs; replay from DLQ dashboard only after fixing root cause
Check-run permission gapscheck_run_post_denied grouped by repoAlert when the same repo hits ≥3 denials in 1h (transient GitHub blips are normal)Re-run activation, confirm Checks:write on the GitHub App, verify installation still has access to the repo
AI provider exhaustionclose_breaker_engaged, selfhost_ai_provider errors, review failures tagged operation=ai_reviewWarn on first breaker; page when breaker stays engaged >15m or error rate >25% of reviews in 1hCheck provider quotas, AI_* env, CLI availability (INSTALL_AI_CLIS), and Grafana AI usage panels
Relay / broker failuresorb_relay_drain, orb_relay_register, orb_broker_unavailable, missed relay monitorsPage when relay register/drain monitors miss twice or broker errors persist >10mVerify Orb enrollment secrets, outbound connectivity, and broker health; check relay logs without exposing enrollment material
Backup failuresselfhost_backup_advisory, backup container non-zero exits, stale backup freshness metricPage when backup freshness >2× BACKUP_INTERVAL_SECONDS or verify script fails twiceInspect docker compose logs backup, disk space, and backup docs; do not delete the last good backup after a failed run
Scheduled monitor missesSentry monitor alert on scheduled-loop, orb-export, or other wrapped loopsUse Sentry's built-in monitor failure thresholds (2 consecutive misses on most loops)Process may still be alive but cron work stopped — check selfhost_cron_error, queue pump logs, and restart the app container if the loop crashed without taking down the process

Sentry server name

SENTRY_SERVER_NAME sets a clean, human name for this instance in Sentry (for example gittensory-us-east). Unset defaults to the OS hostname — never the public-origin URL. Set this explicitly if you run more than one instance and want to tell their Sentry events apart at a glance instead of matching container hostnames.

Sentry tracing

Leave SENTRY_TRACES_SAMPLE_RATE unset or blank to disable trace export, or set a positive sample rate such as 0.05 to send sampled review spans to Sentry. The custom OpenTelemetry provider installs Sentry hooks for review-stage spans carrying repo, PR, operation, outcome, and hashed installation tags.

Sentry cron monitors

When SENTRY_DSN is set, the self-host runtime emits Sentry monitor check-ins for the recurring loops where silent stoppage matters most. Leaving SENTRY_DSN unset keeps monitor reporting off.

scheduled loop
The two-minute maintenance tick that fans out sweeps, backfills, and refresh jobs.
Orb export
The hourly outcome export loop used by brokered self-host deployments.
Orb relay drain
The pull-mode relay loop for installations that receive events outbound from Orb.
Orb relay register
The recurring retry loop that (re-)registers this instance with the relay broker.
Queue dead-letter revive
The 30-minute (by default) sweep that retries dead-lettered jobs still under the auto-retry ceiling.

Monitor loop slugs (the {loop} segment in the slug) are scheduled-loop, orb-export, orb-relay-drain, orb-relay-register, and queue-dead-letter-revive. A missed monitor means the process may still be alive but the recurring work is not checking in on schedule. Pair the monitor with queue depth, dead-job counts, and the structured error log for the same subsystem.

Re-gate sweeps (agent-regate-sweep)

Live PR review is webhook-driven, but open PRs still need periodic re-evaluation — the base branch moves, duplicate clusters resolve, settings change, and approved PRs can sit unmerged until CI re-runs. A scheduled sweep (agent-regate-sweep, every ~2 minutes on the maintenance tick) fans out lightweight agent-regate-pr jobs for the stalest open PRs per repo (cap SWEEP_MAX_PRS=3 by default, REST-budget sized).

What it recomputes
Gate verdict + auto-maintain actions for open, non-draft PRs the webhook has not refreshed recently. Does not re-run AI unless the live pipeline would.
How candidates are picked
Sorted by lastRegatedAt ascending (stalest first), skipping PRs a webhook touched within ~2 minutes. Dry-run suppresses GitHub writes but still advances lastRegatedAt so coverage keeps moving.
Backlog-convergence companion
A separate backlog-convergence-sweep repairs PRs whose public review surface (comment/check/label) never published for the current head — a case the main sweep can miss when a per-PR job dead-letters after fan-out.

Log markers: regate_sweep_throttled (sweep temporarily paused), regate_sweep_trigger_backlog_deferred (prior regate work still draining — avoids piling duplicate fan-outs). In metrics, break down deferrals by job_type=agent-regate-pr or agent-regate-sweep when GitHub rate-limit pressure spikes.

Routine checks

  • Queue pending count is not growing without processing.
  • Dead jobs stay at zero or are investigated promptly.
  • Webhook deliveries are recent and have 2xx responses, with no enqueue failures.
  • AI usage matches expected review volume and model/effort choices.
  • REES and RAG failures are visible and bounded.
  • Postgres connections, lock waits, slow transactions, dead tuples, and table growth are stable.
  • Backups are recent and restore-tested.

Updating and rolling back

Day-two operator flow: pull or build a new app image, restart only the gittensory service, verify /ready, and confirm the release id. Use Releases and images to pick a tag; use the checklists below so updates never overwrite operator-owned secrets, config, or data.

Operator-owned — deploy scripts never overwrite these
  • .env and any *_FILE secret mounts — deploy scripts only write back GITTENSORY_IMAGE (image path) or SENTRY_RELEASE / GITTENSORY_VERSION (source path).
  • ./gittensory-config/ bind mount — private per-repo .gittensory.yml policy.
  • Named data volumes — especially gittensory-data (SQLite DB, Codex/Claude auth under /data), gittensory-pg, qdrant-data, gittensory-backups, and Grafana's grafana-data.
  • Optional docker-compose.override.yml — still loaded via SELFHOST_COMPOSE_FILES when set, or automatically when present beside docker-compose.yml.
Restart gittensory only (normal app update)
Both deploy-selfhost-image.sh and deploy-selfhost-prebuilt.sh run docker compose up -d --no-deps gittensory. Redis, Postgres, Qdrant, Grafana, backup sidecars, and every volume stay running with their existing data.
Recreate a profile service (separate step)
Only when you deliberately change that service's image or major version — e.g. docker compose --profile postgres pull postgres && docker compose --profile postgres up -d postgres. Never required just to ship a new gittensory app build.

Preflight checklist

  1. Read release notes for migration or env changes — migrations are forward-only (see Rollback below).
  2. Take a fresh backup when the release may change schema — see Backup and scaling.
  3. Source path only: git pull and confirm git status is clean (no uncommitted local changes the build would silently pick up).
  4. Image path only: note the current tag or digest from docker inspect on the running gittensory container so rollback has a known-good target.
  5. Confirm routine health is green before you start — curl http://localhost:8787/ready and a quick docker compose ps.

Path 1: pull a published image

scripts/deploy-selfhost-image.sh pulls a tag or digest, restarts only the gittensory service, waits for it to report healthy via docker inspect's health status (configurable timeout, default 180s), and then persists the resolved image reference back to GITTENSORY_IMAGE in .env so the next plain invocation reuses it.

# Re-pull whatever GITTENSORY_IMAGE already resolves to (safe no-op restart if the tag is unchanged
# and nothing new was pushed under it)
./scripts/deploy-selfhost-image.sh

# Pin an exact release tag or content digest
./scripts/deploy-selfhost-image.sh ghcr.io/jsonbored/gittensory-selfhost:orb-v0.1.0
GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/deploy-selfhost-image.sh
bash

The pull always runs with --policy always, so re-running the script against an unchanged tag is safe: if the registry has nothing new, it just restarts the same image and the health-check wait passes immediately.

Path 2: build from the current git checkout

scripts/deploy-selfhost-prebuilt.sh is for a source-based deploy (this is how GITTENSORY_VERSION ends up as a short git SHA instead of an image tag). It builds the bundle inside a Dockerized Node container — the host itself never needs Node or npm installed — then restarts only the gittensory service the same way as the image path.

git pull
./scripts/deploy-selfhost-prebuilt.sh
bash

SENTRY_RELEASE defaults to gittensory-selfhost@<short git SHA of the current HEAD> unless you override it, so each deploy from a new commit gets a distinct release id automatically. When SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are all configured, the script also injects and uploads Sentry source maps for that release before restarting the service (set SELFHOST_SKIP_SENTRY_UPLOAD=1 to skip this even when those three are present).

Post-update checklist

  1. Wait for the deploy script's health wait to finish (or run the helper below if you updated manually with plain docker compose).
  2. curl http://localhost:8787/ready returns HTTP 200.
  3. docker compose ps gittensory shows healthy.
  4. Tail logs for selfhost_listening and, on first boot after a schema bump, selfhost_migrations_applied — not selfhost_job_dead.
  5. Confirm the release id — neither /health nor /ready exposes a version string; check .env and the running container image instead.
./scripts/selfhost-post-update-check.sh
# equivalent manual checks:
curl -sf http://localhost:8787/ready
docker compose ps gittensory
grep -E '^(GITTENSORY_IMAGE|GITTENSORY_VERSION|SENTRY_RELEASE)=' .env
docker inspect --format '{{.Config.Image}}' "$(docker compose ps -q gittensory)"
bash

If any check fails, see Troubleshooting.

Rollback: no dedicated command

There is no rollback script. Rolling back means re-running one of the two scripts above pointed at an older target:

  • Image-based: re-run deploy-selfhost-image.sh with the prior tag or digest (docker inspect on the running container, or your own deploy log, has the digest you were on before the update).
  • Source-based: git checkout the prior commit, then re-run deploy-selfhost-prebuilt.sh.
Migrations are forward-only
This repo has no down-migration convention — scripts/check-migrations.mjs only enforces a contiguous, non-colliding numbering, not a reverse path. If a migration has already run forward against the live database, rolling back the app code is not safe in general: older code can break against a newer schema (a dropped/renamed column, a NOT NULL column it never writes, a changed constraint), even though the migration itself succeeded. Before rolling back across a migration boundary, check whether everything the newer migration(s) did is purely additive (new nullable column, new table, new index) and, specifically, whether the code you're rolling back to actually still runs against that schema — additive is usually fine; anything the old code can't tolerate is not. Take a fresh backup first regardless — see Backup and scaling — and if in doubt, restore that backup to a scratch database and boot the older code against it before doing the same on the live instance.

Uninstalling and decommissioning

Tearing an instance down cleanly touches four independent things: the GitHub App installation, the data volumes, brokered-mode enrollment, and control-panel access. None of this is scripted today — do each step deliberately, in this order, and decide what to keep before you delete anything.

1. Revoke the GitHub App installation

Uninstalling stops GitHub from sending any further webhook events and immediately revokes the App's installation tokens — nothing on the self-host side needs to be told; there is no installation deleted webhook handler to run first. From the repo or org: Settings → Integrations → GitHub Apps → your App → Uninstall. Do this before stopping the container so you are not left with a dangling install pointed at a dead webhook URL.

If you only want to pause reviews without losing the App's configuration (permissions, webhook URL, private key), suspend the installation instead of uninstalling it — GitHub stops delivering events to a suspended install but keeps everything else intact for a later resume.

2. Decide what happens to the data volumes

Stopping the container does not delete anything — docker compose stop or docker compose down (without -v) leaves every named volume (gittensory-data, gittensory-pg, qdrant-data, gittensory-backups, grafana-data, and the rest declared in docker-compose.yml) on disk, along with the ./gittensory-config host directory (a bind mount, not a named volume, so it is never affected by -v either way). Pick one:

Keep (pause, don't decommission)
docker compose stop. Volumes and .env stay as-is; restarting later resumes with the same data. Use this if you might come back.
Export, then delete
Run the backup profile one last time (docker compose --profile backup up -d, then confirm with verify-backup.sh — see Backup and scaling) and copy the resulting archive off-host before removing anything.
Delete everything
docker compose down -v removes every named volume permanently — the review database, vector index, Grafana dashboards state, and any local backup archives in gittensory-backups go with it. This does not touch ./gittensory-config (delete that host directory yourself if it should go too).
down -v is irreversible without an existing backup
If you have not exported a backup off-host first, docker compose down -v permanently destroys review history, settings, and the vector index with no recovery path — the volumes are the only copy. See Backup and scaling before running it on an instance you care about.

3. Deregister from the Orb broker (brokered mode only)

If this instance runs in brokered mode (ORB_ENROLLMENT_SECRET is set — see GitHub App and Orb), be aware there is no self-service revocation endpoint today — the "Minimum broker safeguards" checklist on that page lists a revocation path as a prerequisite for a public brokered rollout that has not shipped yet. An enrollment record (orb_enrollments) lives in gittensory's own central database, not your container, and nothing in this codebase writes a revoked_at value to it outside of tests. Practical steps until that exists:

  • Uninstalling the GitHub App (step 1) stops new webhook traffic and installation-token issuance from reaching your instance in practice, even though the enrollment row itself stays marked enrolled centrally.
  • Stop the container and let ORB_ENROLLMENT_SECRET go with it — with nothing polling or listening, the secret is inert even if it still resolves to a valid enrollment.
  • If the secret may have leaked or you want it invalidated outright rather than just orphaned, treat this the same as any other suspected credential compromise: contact the Orb operator to have the enrollment revoked centrally, since there is no in-product way to do it yourself yet.

4. Remove ADMIN_GITHUB_LOGINS access

ADMIN_GITHUB_LOGINS is read fresh from the environment on every control-panel request (isAuthorizedGitHubSessionLogin in src/auth/security.ts) — it is never cached at startup or baked into an issued session. To remove someone's operator access, delete their login from the comma/whitespace-separated list in .env and restart the gittensory service so the process picks up the new value:

$EDITOR .env   # remove the login from ADMIN_GITHUB_LOGINS
docker compose up -d --no-deps gittensory
bash

This takes effect on their very next control-panel request after the restart — no signed-in session is grandfathered in, because authorization is re-checked against the current allowlist every time, not read from the session itself. If you are decommissioning the whole instance rather than removing one operator, this step is moot once the container is stopped.