Can I self-host Sentry?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called GlitchTip. It takes one prompt, a 2048 MB VPS, and about 120 minutes. That is $26 a month you stop paying Sentry — $312 a year on the Team plan, a metered rate, not a whole bill.

Why people pay for Sentry

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Sentry sells the minutes between a user hitting an exception and you reading the line of code that threw it. The SDKs are everywhere, the stack traces come back with your source maps applied, and tracing, profiling and session replay let you watch the request that failed instead of guessing at it. What you are renting is depth, and an event pipeline somebody else keeps up while your own site is the thing on fire.

Sentry plans and list prices
PlanList priceWhat it buys
DeveloperfreeFree, limited to one user. The pricing page lists 5k errors, 5 GB of logs, 5M spans and 50 replays a month.
Teamthe plan this page prices against$26/mo meteredUnlimited users. The base covers a fixed monthly allowance, listed as 50k errors, 5 GB of logs, 5M spans and 50 replays; anything above it is billed pay-as-you-go. The page shows the same figure for monthly and annual billing and offers further discounts for annual or prepaid at checkout.
Business$80/mo meteredSame included allowance as Team plus the advanced features, with the same pay-as-you-go overage. The page shows the same figure for monthly and annual billing.
Enterprisequote onlyQuote only. The page says custom volume, with a technical account manager and dedicated support.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by GlitchTip

One project, named before the prompt, so you know what you are about to install.

Error tracking that speaks the Sentry SDK protocol, so your code keeps its instrumentation and changes one DSN.

The only one here that keeps your instrumentation. GlitchTip speaks the Sentry SDK protocol, so the sentry-sdk already in your code stays, you change one DSN string, and errors land on your own hostname with no monthly event count deciding the bill. It is three containers and MIT licensed, and the honest half is what it does not do: no session replay, no profiling, and tracing that shows slow transactions rather than the deep performance product Sentry sells. If what you actually read every week is the issue stream, this is the trade to make.

The swap

You're paying

Sentry

$26/mo · $312/yr

is replaced by

You'd run

GlitchTip

ONE EVENING · ~120 min to running · 2048 MB RAM

Sentry Team · a metered rate, not a whole bill · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
20 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same GlitchTip: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

339 lines · 14,961 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install GlitchTip 6.2.3 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say why when you ask: `<DOMAIN>` becomes `GLITCHTIP_DOMAIN`, and every DSN this server hands out
is built from it, so changing it later means editing every application that reports here. Its A
record must already point at this server.

GlitchTip needs 2048 MB of RAM available and 20 GB free on /srv. Upstream recommends 512 MB for
GlitchTip alone; this install runs PostgreSQL 18 and Valkey beside it, and upstream's start
script lets the web worker reach 1024 MB before recycling it. Both architectures are published.
Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop. Disk is what bites
later: upstream puts a million events a month at roughly 30 GB, at the default 90-day retention.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/glitchtip /srv/glitchtip/backups
sudo install -d -m 700 /srv/glitchtip/postgres
sudo install -d -m 750 -o 5000 -g 5000 /srv/glitchtip/uploads
ls -la /srv/glitchtip
```

Assert: `ls -la` shows `backups` owned by the login user, `postgres` at mode `700` owned by
root, and `uploads` owned by uid `5000`. Leave the last two alone: the PostgreSQL image chowns
its own data directory on first start, and the GlitchTip image runs as uid 5000, which is the
account that writes source maps into `uploads`.

## 3. Secrets

Two secrets: the Django `SECRET_KEY` and the PostgreSQL password. Generate both on the server.
Do not print either, do not repeat them in your summary, and do not put them in any log line.

```bash
umask 077
cat > /srv/glitchtip/.env <<EOF
GLITCHTIP_DOMAIN=https://<DOMAIN>
ALLOWED_HOSTS=<DOMAIN>,localhost
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
SECRET_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/glitchtip/.env
umask 022
ls -l /srv/glitchtip/.env
```

Assert: the file exists with mode `-rw-------`. `SECRET_KEY` signs the session cookies, and
upstream logs a warning when it is left at its shipped placeholder. `ALLOWED_HOSTS` names the
one hostname Django will answer on, plus `localhost`, because the container health check calls
`http://localhost:8000/_health/` from inside itself and Django returns 400 to a Host header it
was not told about. `CSRF_TRUSTED_ORIGINS` is documented as required behind a reverse
proxy; without it the login form is rejected.

## 4. compose.yml

```bash
cat > /srv/glitchtip/compose.yml <<'EOF'
# GlitchTip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   install guide ....... https://glitchtip.com/documentation/install
#   sample compose ...... https://glitchtip.com/assets/compose.sample.yml
#   backend at v6.2.3 ... https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Three services. SERVER_ROLE all_in_one is upstream's own sample shape: one
# container applies the migrations, maintains the Postgres partitions, then
# serves with the background worker inside it. No separate worker, no migrate
# job. PostgreSQL 18 keeps its data under /var/lib/postgresql, where the
# official image declares its volume. Valkey gets no volume, matching
# upstream's sample: cache and task queue, worth having available rather than
# worth keeping. Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: glitchtip

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    restart: unless-stopped
    environment:
      POSTGRES_DB: glitchtip
      POSTGRES_USER: glitchtip
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/glitchtip/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the web container.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      retries: 12
    # No volume and no ports: cache and queue, reachable in-network only.

  web:
    image: glitchtip/glitchtip:6.2.3@sha256:95e0e2d6b1bc18446902ae0cb47910cc55d7c0d6756ee901b0cd8dce9f8ef5a9
    restart: unless-stopped
    env_file: /srv/glitchtip/.env
    environment:
      SERVER_ROLE: all_in_one
      DATABASE_URL: postgres://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
      VALKEY_URL: redis://valkey:6379
      # One account can be created while the user table is empty, then
      # self-signup closes. Step 7 asserts the door shut.
      ENABLE_USER_REGISTRATION: "False"
      # Django Admin and the OpenAPI schema default to on in the code and to
      # off in upstream's sample. Off here too: neither is needed to use this.
      ENABLE_ADMIN: "False"
      ENABLE_OPENAPI: "False"
    volumes:
      - /srv/glitchtip/uploads:/code/uploads
    healthcheck:
      test: ["CMD", "python", "healthcheck.py"]
      interval: 15s
      retries: 20
      start_period: 90s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8123.
      - "127.0.0.1:8123:8000"
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_healthy
EOF
cd /srv/glitchtip && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port. `SERVER_ROLE: all_in_one`
is upstream's own sample shape for a single server: the web container runs the migrations and
the partition maintenance itself, then serves with the worker embedded, so there is no fourth
container and nothing to run by hand after an upgrade.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-glitchtip
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# GlitchTip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://glitchtip.com/documentation/install and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also GLITCHTIP_DOMAIN and ALLOWED_HOSTS in .env, and every DSN this server
# hands out is built from it, so changing it later means editing every
# application that reports here.

<DOMAIN> {
	# GlitchTip sends its own Content-Security-Policy, so nothing here
	# touches that header. HSTS, nosniff and a same-origin frame rule are
	# the additions.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's nginx example raises client_max_body_size to 40M because
	# nginx caps a body at 1M. Caddy has no such cap, so nothing to raise.
	#
	# 8123 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8123
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-glitchtip, reload, and
report what it objected to. Caddy gets the certificate on the first request and renews it
itself, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8123 stays closed because compose binds it to 127.0.0.1; 5432 and 6379 stay
closed because compose publishes neither, so they have no host port to firewall. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for
8123, 5432 or 6379.

## 7. Start and verify

On a cold start the web container applies every migration and builds the event partitions
before it binds a port, so this takes minutes rather than seconds.

```bash
cd /srv/glitchtip
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/_health/; echo
curl -sS https://<DOMAIN>/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/0/organizations/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/
```

Assert all five, printing what you received for each. The loop ends on `200`. The health
endpoint prints `ok`. The settings line prints `"enableUserRegistration":true`, the door
standing open only because the user table is still empty. The organizations call prints `401`,
the answer to an API request carrying no credential, and the security assert here. `/admin/`
prints `404`, because `ENABLE_ADMIN` is False and Django Admin is not routed at all. If any of
the five misses, stop, run `docker compose logs --tail 40 web` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a `502` from Caddy while
the loop still runs is the migrations, a database that never reports healthy points at step 2,
and a `400` where a `200` was expected is `ALLOWED_HOSTS` in step 3 not matching the hostname. A
running container is not success.

The first screen at https://<DOMAIN> is a login card headed `Login`, with a `New to GlitchTip?`
line and a `Sign Up` link under the form.

STOP: tell the user to open https://<DOMAIN>, follow `Sign Up`, create their account with an
email address and a password they have saved in a password manager first, then the organization
GlitchTip asks for next, then a first project inside it, and wait.
Do not continue until they confirm. This is the only moment that account can be made, and this
install sends no mail, so a lost password has no reset link. The project's settings show its
DSN, and the first real event can only come from the user's own application with its sentry-sdk
pointed at that string, which is why this prompt does not send one.

Then prove the door shut:

```bash
curl -sS https://<DOMAIN>/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
```

Assert: `"enableUserRegistration":false`, and the user reloads https://<DOMAIN> and confirms the
`Sign Up` link is gone. Both must pass before you report success.

## 8. First backup and restore

Two artifacts: the database holds every account, project, issue and event; the config archive
holds what rebuilds the service around them.

```bash
cd /srv/glitchtip
docker compose exec -T postgres pg_dump -U glitchtip -d glitchtip | gzip > /srv/glitchtip/backups/glitchtip-db-$(date +%F).sql.gz
sudo tar -czf /srv/glitchtip/backups/glitchtip-config-$(date +%F).tar.gz -C /srv/glitchtip compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/glitchtip/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Valkey is not backed up: cache and queue,
not data. A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/glitchtip
scp vps:/srv/glitchtip/backups/* ~/backups/glitchtip/
```

To restore: `docker compose down`, `sudo rm -rf /srv/glitchtip/postgres`, recreate the
directories from step 2, untar the config archive into /srv/glitchtip so .env is back before
anything starts, `docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U glitchtip -d glitchtip`, then
`docker compose up -d`. Say why the two files travel together: the database was created with the
credential in that .env, so a dump restored without it is a database the new container cannot
open.

## 9. Updating later

GlitchTip develops on GitLab, and releases are tagged at
https://gitlab.com/glitchtip/glitchtip-backend/-/tags. The Docker Hub tag drops the leading `v`,
so `v6.2.4` there is `6.2.4` in the image line. Back up first, then edit the image line in
/srv/glitchtip/compose.yml to the new tag and its digest:

```bash
cd /srv/glitchtip
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

The web container migrates on the way up, so watch that log until it settles, then re-run
step 7's health check before calling the update done.

## 10. What will probably go wrong

The wait on the first start. I saw the web container sit at `starting`, opened https://<DOMAIN>,
got a `502` from Caddy, and went to read the Caddyfile looking for what I had typed wrong.
Nothing was wrong. The container was still applying migrations and building event partitions
against an empty database, and it binds its port only after that finishes. It took about four
minutes. Let the loop in step 7 run all forty times before concluding anything is broken.

## 11. Out of scope

- Do not configure SMTP and do not set `EMAIL_URL` or `DEFAULT_FROM_EMAIL`. With no mail
  transport GlitchTip turns email off on purpose: account verification and password reset leave
  the interface, and alerts go to a webhook recipient instead. That is the trade here, not an
  oversight to fix.
- Do not set `GLITCHTIP_ENABLE_DUCKDB` or configure S3 cold storage. That is a second storage
  backend and a bucket on another host, and PostgreSQL holds everything at this size.
- Do not set `ENABLE_ADMIN` back to True and do not run `manage.py createsuperuser`. The account
  made in step 7 administers this instance from the normal interface.
- Do not split the worker into its own container. `SERVER_ROLE: all_in_one` runs it inside the
  web process, which is upstream's own shape for a single server.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing GlitchTip 6.2.3 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. `<DOMAIN>` becomes `GLITCHTIP_DOMAIN`, and every DSN this server hands
out is built from it. Change it later and you edit the configuration of every application that
reports here. Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. On the disk number, take
20 GB as a floor rather than a target: the events are the product, and upstream puts a million
events a month at roughly 30 GB with the default 90-day retention. On RAM, upstream recommends
512 MB for GlitchTip alone, and this install runs PostgreSQL 18 and Valkey beside it.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/glitchtip /srv/glitchtip/backups
sudo install -d -m 700 /srv/glitchtip/postgres
sudo install -d -m 750 -o 5000 -g 5000 /srv/glitchtip/uploads
ls -la /srv/glitchtip
```

You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`uploads` owned by uid `5000`.

If you do not: leave those two alone on purpose. The PostgreSQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. The GlitchTip image runs as uid 5000, which is the account inside the container
that writes source maps into `uploads`; owned by you, those uploads fail with a permission
error.

## 3. Secrets

Two secrets: the Django `SECRET_KEY` and the PostgreSQL password. Both are generated here, on
the server, and both go straight into a file only you can read.

```bash
umask 077
cat > /srv/glitchtip/.env <<EOF
GLITCHTIP_DOMAIN=https://<DOMAIN>
ALLOWED_HOSTS=<DOMAIN>,localhost
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
SECRET_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/glitchtip/.env
umask 022
ls -l /srv/glitchtip/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the three lines that carry it with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/glitchtip/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both secrets, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the credential it was created with, so a changed `DB_PASSWORD` on an existing volume
produces an authentication failure in the GlitchTip log rather than anything about credentials.

Do not paste that file, either secret, or any command output containing them into this chat
window. The three plain lines matter too: `ALLOWED_HOSTS` is the only set of names Django will
answer on, and it carries `localhost` because the container health check calls
`http://localhost:8000/_health/` from inside itself. `CSRF_TRUSTED_ORIGINS` is what upstream
documents as required behind a reverse proxy, and without it the login form is rejected.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/glitchtip/compose.yml <<'EOF'
# GlitchTip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   install guide ....... https://glitchtip.com/documentation/install
#   sample compose ...... https://glitchtip.com/assets/compose.sample.yml
#   backend at v6.2.3 ... https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Three services. SERVER_ROLE all_in_one is upstream's own sample shape: one
# container applies the migrations, maintains the Postgres partitions, then
# serves with the background worker inside it. No separate worker, no migrate
# job. PostgreSQL 18 keeps its data under /var/lib/postgresql, where the
# official image declares its volume. Valkey gets no volume, matching
# upstream's sample: cache and task queue, worth having available rather than
# worth keeping. Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: glitchtip

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    restart: unless-stopped
    environment:
      POSTGRES_DB: glitchtip
      POSTGRES_USER: glitchtip
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/glitchtip/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the web container.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      retries: 12
    # No volume and no ports: cache and queue, reachable in-network only.

  web:
    image: glitchtip/glitchtip:6.2.3@sha256:95e0e2d6b1bc18446902ae0cb47910cc55d7c0d6756ee901b0cd8dce9f8ef5a9
    restart: unless-stopped
    env_file: /srv/glitchtip/.env
    environment:
      SERVER_ROLE: all_in_one
      DATABASE_URL: postgres://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
      VALKEY_URL: redis://valkey:6379
      # One account can be created while the user table is empty, then
      # self-signup closes. Step 7 asserts the door shut.
      ENABLE_USER_REGISTRATION: "False"
      # Django Admin and the OpenAPI schema default to on in the code and to
      # off in upstream's sample. Off here too: neither is needed to use this.
      ENABLE_ADMIN: "False"
      ENABLE_OPENAPI: "False"
    volumes:
      - /srv/glitchtip/uploads:/code/uploads
    healthcheck:
      test: ["CMD", "python", "healthcheck.py"]
      interval: 15s
      retries: 20
      start_period: 90s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8123.
      - "127.0.0.1:8123:8000"
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_healthy
EOF
cd /srv/glitchtip && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/glitchtip/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/glitchtip/compose.yml` and paste again in one go. `SERVER_ROLE: all_in_one` is
upstream's own sample shape for a single server: the web container runs the migrations and the
partition maintenance itself, then serves with the background worker embedded, so there is no
fourth container to sequence and nothing to run by hand after an upgrade.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-glitchtip
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# GlitchTip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://glitchtip.com/documentation/install and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also GLITCHTIP_DOMAIN and ALLOWED_HOSTS in .env, and every DSN this server
# hands out is built from it, so changing it later means editing every
# application that reports here.

<DOMAIN> {
	# GlitchTip sends its own Content-Security-Policy, so nothing here
	# touches that header. HSTS, nosniff and a same-origin frame rule are
	# the additions.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's nginx example raises client_max_body_size to 40M because
	# nginx caps a body at 1M. Caddy has no such cap, so nothing to raise.
	#
	# 8123 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8123
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-glitchtip /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request and renews it on its own,
so there is nothing to schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8123`, `5432` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8123`. 8123 is bound
to 127.0.0.1 by the compose file, and PostgreSQL and Valkey publish no host port at all, so
neither has a port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to
answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers
by default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled,
so something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

On a cold start the web container applies every migration and builds the event partitions before
it binds a port, so the first health check takes minutes rather than seconds.

```bash
cd /srv/glitchtip
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/_health/; echo
curl -sS https://<DOMAIN>/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/0/organizations/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/
```

You should see, in order: the loop reaching `200`, then `ok`, then
`"enableUserRegistration":true`, then `401`, then `404`.

If you do not: a `502` from Caddy while the loop is still running is the migrations, not a
broken proxy, so let all forty attempts run. A `400` where a `200` was expected is
`ALLOWED_HOSTS` in step 3 not matching your hostname. If the loop never reaches `200`, run
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy is
step 2 done wrong, and `docker compose logs --tail 40 web` second. The `401` is the one worth
understanding: it means the API is up and refusing a call with no credential, so seeing it is
good news. The `404` from `/admin/` means Django Admin is not routed at all, which is what
`ENABLE_ADMIN: "False"` in the compose file buys you.

The first screen at https://<DOMAIN> is a login card headed `Login`, with a `New to GlitchTip?`
line and a `Sign Up` link under the form.

Open https://<DOMAIN>, follow `Sign Up`, create your account with an email address and a
password you have saved in a password manager first, then the organization GlitchTip asks for
next, then a first project inside it. This is the only moment that account can be made:
`ENABLE_USER_REGISTRATION` is False, which upstream defines as self-signup closing once the
first user exists. This install also sends no mail, so a lost password has no reset link. The
project's settings show its DSN; point your application's sentry-sdk at that string and the
first real event arrives from your own code, which is the whole reason this server exists.

Then prove the door shut:

```bash
curl -sS https://<DOMAIN>/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
```

You should see: `"enableUserRegistration":false`. Reload https://<DOMAIN> and confirm the
`Sign Up` link is gone.

If you do not: a `true` here means the account was not actually created, so go back and finish
the sign-up form. A running container is not success; these two checks are.

## 8. First backup and restore

Two artifacts. The database holds every account, project, issue and event. The config archive
holds the files that rebuild the service around them.

```bash
cd /srv/glitchtip
docker compose exec -T postgres pg_dump -U glitchtip -d glitchtip | gzip > /srv/glitchtip/backups/glitchtip-db-$(date +%F).sql.gz
sudo tar -czf /srv/glitchtip/backups/glitchtip-config-$(date +%F).tar.gz -C /srv/glitchtip compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/glitchtip/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently. Valkey is not in the backup: it holds the
cache and the task queue, and upstream says that data is worth having available rather than
worth keeping.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/glitchtip
scp vps:/srv/glitchtip/backups/* ~/backups/glitchtip/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/glitchtip/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty issue list:

```bash
cd /srv/glitchtip
docker compose down
sudo rm -rf /srv/glitchtip/postgres
sudo install -d -m 700 /srv/glitchtip/postgres
sudo tar -xzf /srv/glitchtip/backups/glitchtip-config-$(date +%F).tar.gz -C /srv/glitchtip compose.yml .env uploads
docker compose up -d postgres
sleep 30
gunzip -c /srv/glitchtip/backups/glitchtip-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U glitchtip -d glitchtip
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `"enableUserRegistration":false`
from the last command, which means your account survived a database that was deleted and rebuilt.

If you do not: `role "glitchtip" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. The reason the config archive gets
untarred before PostgreSQL starts is that PostgreSQL reads `DB_PASSWORD` out of .env the moment
it initialises an empty directory, so a dump restored without its .env is a database the new
container cannot open. The two files travel together.

## 9. Updating later

GlitchTip develops on GitLab, and released versions are tagged at
https://gitlab.com/glitchtip/glitchtip-backend/-/tags. The Docker Hub tag drops the leading `v`,
so `v6.2.4` there is `6.2.4` in the image line. Take both backup artifacts first, then edit the
image line in /srv/glitchtip/compose.yml to the new tag and its digest.

```bash
cd /srv/glitchtip
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, because a container that answers `ok`
on health can still be failing to ingest events if a migration stopped halfway.

## 10. What will probably go wrong

The wait on the first start. I saw the web container sit at `starting`, opened https://<DOMAIN>,
got a `502` from Caddy, and went to read the Caddyfile looking for what I had typed wrong.
Nothing was wrong. The container was still applying migrations and building event partitions
against an empty database, and it binds its port only after that finishes. It took about four
minutes. Let the loop in step 7 run all forty times before concluding anything is broken.

## 11. Out of scope

- Do not configure SMTP and do not set `EMAIL_URL` or `DEFAULT_FROM_EMAIL`. With no mail
  transport GlitchTip turns email off on purpose: account verification and password reset leave
  the interface, and alerts go to a webhook recipient instead. That is the trade here, not an
  oversight to fix.
- Do not set `GLITCHTIP_ENABLE_DUCKDB` or configure S3 cold storage. That is a second storage
  backend and a bucket on another host, and PostgreSQL holds everything at this size.
- Do not set `ENABLE_ADMIN` back to True and do not run `manage.py createsuperuser`. The account
  made in step 7 administers this instance from the normal interface.
- Do not split the worker into its own container. `SERVER_ROLE: all_in_one` runs it inside the
  web process, which is upstream's own shape for a single server.

331 lines · 14,972 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install GlitchTip 6.2.3, with the PostgreSQL and Valkey it needs, under ~/selfhost/glitchtip,
answering at http://localhost:8123.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this install at all. Every DSN
this instance hands out begins with http://localhost:8123, which means "this computer" wherever
it is read. Code running here can report to it; a staging server, a phone app or a colleague's
laptop cannot, and nor can this machine while it sleeps.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This stack needs 2048 MB of RAM available
and 20 GB free on the home disk, and all three images are multi-arch. On macOS and Windows that
memory figure is the host's, and Docker Desktop takes its allocation out of it. Under either
floor, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/glitchtip/backups ~/selfhost/glitchtip/uploads
if [ "$(uname -s)" = "Linux" ]; then sudo chown 5000:5000 ~/selfhost/glitchtip/uploads; fi
ls -la ~/selfhost/glitchtip
```

Assert: `ls -la` shows `backups` and `uploads`. The GlitchTip image runs as uid 5000, so on
Linux that directory has to belong to 5000 or source-map uploads fail; on macOS and Windows the
fence is a no-op and Docker Desktop's file sharing owns that.

## 4. Secrets

Two secrets: the Django `SECRET_KEY` and the PostgreSQL password. Generate both here, print
neither, and keep both out of your summary and out of every log.

```bash
umask 077
cat > ~/selfhost/glitchtip/.env <<EOF
GLITCHTIP_DOMAIN=http://localhost:8123
ALLOWED_HOSTS=localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=http://localhost:8123
SECRET_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/glitchtip/.env
umask 022
ls -l ~/selfhost/glitchtip/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere.
`SECRET_KEY` signs the session cookies, and upstream logs a warning when it is left at its
shipped placeholder. On Windows those mode bits are advisory: NTFS does not enforce them, and
the user's own account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/glitchtip/compose.yml <<'EOF'
# GlitchTip · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   install guide ....... https://glitchtip.com/documentation/install
#   sample compose ...... https://glitchtip.com/assets/compose.sample.yml
#   backend at v6.2.3 ... https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Three services, paths relative to ~/selfhost/glitchtip/ so one file works on
# macOS, Linux and Windows. SERVER_ROLE all_in_one is upstream's sample shape:
# one container migrates, maintains the Postgres partitions, then serves with
# the worker inside it. The database is a named volume, not a bind mount,
# because the PostgreSQL image chowns its data directory to its own uid and
# Docker Desktop's Windows file sharing cannot allow that on a home bind mount.
# Valkey gets no volume, matching upstream. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: glitchtip

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    restart: unless-stopped
    environment:
      POSTGRES_DB: glitchtip
      POSTGRES_USER: glitchtip
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - glitchtip-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the web container.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      retries: 12
    # No volume and no ports: cache and queue, reachable in-network only.

  web:
    image: glitchtip/glitchtip:6.2.3@sha256:95e0e2d6b1bc18446902ae0cb47910cc55d7c0d6756ee901b0cd8dce9f8ef5a9
    restart: unless-stopped
    env_file: ./.env
    environment:
      SERVER_ROLE: all_in_one
      DATABASE_URL: postgres://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
      VALKEY_URL: redis://valkey:6379
      # One account can be made while the user table is empty, then
      # self-signup closes. Step 7 asserts the door shut.
      ENABLE_USER_REGISTRATION: "False"
      # Django Admin and the OpenAPI schema default to on in the code, off in
      # upstream's sample. Off here too: neither is needed to use this.
      ENABLE_ADMIN: "False"
      ENABLE_OPENAPI: "False"
    volumes:
      - ./uploads:/code/uploads
    healthcheck:
      test: ["CMD", "python", "healthcheck.py"]
      interval: 15s
      retries: 20
      start_period: 90s
    ports:
      # Loopback only: no other device on the wifi can reach 8123.
      - "127.0.0.1:8123:8000"
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  glitchtip-pgdata:
EOF
cd ~/selfhost/glitchtip && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. No hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8123 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/glitchtip/compose.yml
```

Assert: one line, `- "127.0.0.1:8123:8000"`. Neither database publishes a host port.

## 7. Start and verify

```bash
cd ~/selfhost/glitchtip
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8123/_health/; echo
curl -sS http://localhost:8123/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8123/api/0/organizations/
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8123/admin/
```

Assert all five, printing what you got. The loop ends on `200`. Health prints `ok`. The settings
line prints `"enableUserRegistration":true`, the door open only because the user table is empty.
The organizations call prints `401`, the answer to an API request with no credential. `/admin/`
prints `404`, because `ENABLE_ADMIN` is False. If any misses, stop, run
`docker compose logs --tail 40 web`, and name the cause: a database that never reports healthy
points at step 4, a web log still in migrations wants more time. On `port is already allocated`,
find what holds 8123 (`lsof -nP -iTCP:8123 -sTCP:LISTEN`, or `netstat -ano | findstr :8123` on
Windows) and stop until the user frees it: 8123 is inside every DSN.
A running container is not success.

The first screen at http://localhost:8123 is a login card headed `Login`, with a
`New to GlitchTip?` line and a `Sign Up` link under the form.

STOP: tell the user to open http://localhost:8123, follow `Sign Up`, create their account with a
password saved in a password manager first, then the organization GlitchTip asks for, then a
first project inside it, and wait. Do not continue until they confirm. It is the only moment
that account can be made, and no mail is configured, so a lost password has no reset link. The
project's settings show its DSN; the first event can only come from the user's own code pointed
at it, so this prompt does not send one.

Then prove the door shut:

```bash
curl -sS http://localhost:8123/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
```

Assert: `"enableUserRegistration":false`, and the user reloads the page and confirms the
`Sign Up` link is gone. Both must pass before you report success.

## 8. First backup and restore

Two artifacts: the database holds every account, project, issue and event, and the config
archive rebuilds the service around it.

```bash
cd ~/selfhost/glitchtip
docker compose exec -T postgres pg_dump -U glitchtip -d glitchtip | gzip > ~/selfhost/glitchtip/backups/glitchtip-db-$(date +%F).sql.gz
tar -C ~/selfhost/glitchtip -czf ~/selfhost/glitchtip/backups/glitchtip-config-$(date +%F).tar.gz compose.yml .env uploads
ls -lh ~/selfhost/glitchtip/backups/
```

Assert: both exist and are non-empty, and print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder their sync service
watches or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is written
`/d/Backups`. Assert: the user confirms both filenames are there. If they have neither, say
plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/glitchtip`, untar the config archive there first so
.env is back before any container starts: PostgreSQL reads `DB_PASSWORD` from it the moment it
initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs because
it drops the old volume on purpose, `docker compose up -d postgres`, wait 30 seconds, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U glitchtip -d glitchtip`,
then `docker compose up -d`. That is the whole disaster plan.

## 9. Updating later

GlitchTip develops on GitLab; releases are tagged at
https://gitlab.com/glitchtip/glitchtip-backend/-/tags, and the Docker Hub tag drops the leading
`v`. Back up first, then edit the image line to the new tag and digest:

```bash
cd ~/selfhost/glitchtip
docker compose pull
docker compose up -d
docker compose logs --tail 40 web
```

The web container migrates on the way up. Watch that log until it settles, then re-run step 7's
check.

## 10. What will probably go wrong

I rebooted this machine, ran the test meant to throw an error, and watched nothing arrive.
Nothing was broken: Docker Desktop had not started with the session, so nothing was listening on
8123, and the SDK swallowed the connection failure and carried on, which is what an error
reporter is supposed to do. That is the trap here: a silent tracker looks exactly like a day
with no bugs. Turn on Docker Desktop's start-at-login setting, and after a reboot run
`docker compose up -d` here before trusting an empty issue list.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `GLITCHTIP_DOMAIN` to this machine's LAN address and do not rebind 8123 to
  0.0.0.0 so another device can report to it. That puts an event-ingest endpoint on every
  network the user joins.
- Do not configure SMTP and do not set `EMAIL_URL`. With no mail transport GlitchTip turns email
  off on purpose: verification and reset leave the interface, and alerts go to a webhook.
compose.local.ymlthe services, pinned · local layout76 lines

authored from upstream docs, never pasted · 3,000 bytes

# GlitchTip · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   install guide ....... https://glitchtip.com/documentation/install
#   sample compose ...... https://glitchtip.com/assets/compose.sample.yml
#   backend at v6.2.3 ... https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Three services, paths relative to ~/selfhost/glitchtip/ so one file works on
# macOS, Linux and Windows. SERVER_ROLE all_in_one is upstream's sample shape:
# one container migrates, maintains the Postgres partitions, then serves with
# the worker inside it. The database is a named volume, not a bind mount,
# because the PostgreSQL image chowns its data directory to its own uid and
# Docker Desktop's Windows file sharing cannot allow that on a home bind mount.
# Valkey gets no volume, matching upstream. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: glitchtip

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    restart: unless-stopped
    environment:
      POSTGRES_DB: glitchtip
      POSTGRES_USER: glitchtip
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - glitchtip-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the web container.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      retries: 12
    # No volume and no ports: cache and queue, reachable in-network only.

  web:
    image: glitchtip/glitchtip:6.2.3@sha256:95e0e2d6b1bc18446902ae0cb47910cc55d7c0d6756ee901b0cd8dce9f8ef5a9
    restart: unless-stopped
    env_file: ./.env
    environment:
      SERVER_ROLE: all_in_one
      DATABASE_URL: postgres://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
      VALKEY_URL: redis://valkey:6379
      # One account can be made while the user table is empty, then
      # self-signup closes. Step 7 asserts the door shut.
      ENABLE_USER_REGISTRATION: "False"
      # Django Admin and the OpenAPI schema default to on in the code, off in
      # upstream's sample. Off here too: neither is needed to use this.
      ENABLE_ADMIN: "False"
      ENABLE_OPENAPI: "False"
    volumes:
      - ./uploads:/code/uploads
    healthcheck:
      test: ["CMD", "python", "healthcheck.py"]
      interval: 15s
      retries: 20
      start_period: 90s
    ports:
      # Loopback only: no other device on the wifi can reach 8123.
      - "127.0.0.1:8123:8000"
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  glitchtip-pgdata:

agent-readable mirror: /self-host/sentry.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned73 lines

authored from upstream docs, never pasted · 2,976 bytes

# GlitchTip · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   install guide ....... https://glitchtip.com/documentation/install
#   sample compose ...... https://glitchtip.com/assets/compose.sample.yml
#   backend at v6.2.3 ... https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Three services. SERVER_ROLE all_in_one is upstream's own sample shape: one
# container applies the migrations, maintains the Postgres partitions, then
# serves with the background worker inside it. No separate worker, no migrate
# job. PostgreSQL 18 keeps its data under /var/lib/postgresql, where the
# official image declares its volume. Valkey gets no volume, matching
# upstream's sample: cache and task queue, worth having available rather than
# worth keeping. Digests read 2026-08-06; all three are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: glitchtip

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    restart: unless-stopped
    environment:
      POSTGRES_DB: glitchtip
      POSTGRES_USER: glitchtip
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/glitchtip/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the web container.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      retries: 12
    # No volume and no ports: cache and queue, reachable in-network only.

  web:
    image: glitchtip/glitchtip:6.2.3@sha256:95e0e2d6b1bc18446902ae0cb47910cc55d7c0d6756ee901b0cd8dce9f8ef5a9
    restart: unless-stopped
    env_file: /srv/glitchtip/.env
    environment:
      SERVER_ROLE: all_in_one
      DATABASE_URL: postgres://glitchtip:${DB_PASSWORD}@postgres:5432/glitchtip
      VALKEY_URL: redis://valkey:6379
      # One account can be created while the user table is empty, then
      # self-signup closes. Step 7 asserts the door shut.
      ENABLE_USER_REGISTRATION: "False"
      # Django Admin and the OpenAPI schema default to on in the code and to
      # off in upstream's sample. Off here too: neither is needed to use this.
      ENABLE_ADMIN: "False"
      ENABLE_OPENAPI: "False"
    volumes:
      - /srv/glitchtip/uploads:/code/uploads
    healthcheck:
      test: ["CMD", "python", "healthcheck.py"]
      interval: 15s
      retries: 20
      start_period: 90s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8123.
      - "127.0.0.1:8123:8000"
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_healthy
Caddyfilethe hostname and TLS33 lines

authored from upstream docs, never pasted · 1,227 bytes

# GlitchTip · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://glitchtip.com/documentation/install and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also GLITCHTIP_DOMAIN and ALLOWED_HOSTS in .env, and every DSN this server
# hands out is built from it, so changing it later means editing every
# application that reports here.

<DOMAIN> {
	# GlitchTip sends its own Content-Security-Policy, so nothing here
	# touches that header. HSTS, nosniff and a same-origin frame rule are
	# the additions.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's nginx example raises client_max_body_size to 40M because
	# nginx caps a body at 1M. Caddy has no such cap, so nothing to raise.
	#
	# 8123 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8123
}
install.shthe same install, no agent165 lines

authored from upstream docs, never pasted · 7,889 bytes

#!/usr/bin/env bash
# GlitchTip · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=errors.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://glitchtip.com/documentation/install
#   https://glitchtip.com/assets/compose.sample.yml
#   https://gitlab.com/glitchtip/glitchtip-backend/-/tree/v6.2.3
#
# Two secrets are generated here, on this machine: the Django SECRET_KEY and the
# PostgreSQL credential. Both go into /srv/glitchtip/.env with mode 600 and
# neither is ever printed. Read them yourself with
#   sudo grep -E 'SECRET_KEY|DB_PASSWORD' /srv/glitchtip/.env
#
# DOMAIN_HOST becomes GLITCHTIP_DOMAIN, and every DSN this server hands out is
# built from it. Choose it once. Changing it later means editing every
# application that reports here.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/glitchtip}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. errors.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; GlitchTip plus PostgreSQL and Valkey wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# postgres stays root-owned at 700: the PostgreSQL image chowns its own data
# directory the first time it starts. uploads belongs to uid 5000, which is the
# account the GlitchTip image runs as and the only one that can write there.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 -o 5000 -g 5000 "$APP_DIR/uploads"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# ALLOWED_HOSTS carries localhost as well as the real hostname, because the
# container health check calls http://localhost:8000/_health/ from inside itself
# and Django answers 400 to a Host header it was not told about.
# CSRF_TRUSTED_ORIGINS is what upstream documents as required behind a proxy.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		GLITCHTIP_DOMAIN=https://${DOMAIN_HOST}
		ALLOWED_HOSTS=${DOMAIN_HOST},localhost
		CSRF_TRUSTED_ORIGINS=https://${DOMAIN_HOST}
		SECRET_KEY=$(openssl rand -hex 32)
		DB_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-glitchtip"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and none of 8123, 5432 or 6379 is one of them -------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8123, 5432 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# SERVER_ROLE all_in_one means the web container applies the migrations and
# builds the event partitions before it binds a port, so the first start takes
# minutes rather than seconds.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/_health/"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/_health/" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/_health/ answered ${code:-nothing}. Check: docker compose logs --tail 40 web"

curl -sS "https://${DOMAIN_HOST}/_health/" | grep -qx 'ok' \
	|| die "/_health/ answered 200 without the body ok. Check: docker compose logs --tail 40 web"

# The API must refuse a call carrying no credential.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/0/organizations/" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

# Django Admin is disabled in compose.yml, so it must not be routed at all.
admin="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/admin/" || true)"
[ "$admin" = "404" ] || die "/admin/ returned ${admin}, not 404. ENABLE_ADMIN is not taking effect."

# Self-signup is off in compose.yml, and upstream opens it only while the user
# table is empty. This script cannot create the first account, so true is the
# correct answer here and the summary below tells you how to close it.
signup="$(curl -sS "https://${DOMAIN_HOST}/api/settings/" | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*' || true)"
[ "$signup" = '"enableUserRegistration":true' ] || die "/api/settings/ reported ${signup:-nothing}. Expected the sign-up door open on an empty instance."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U glitchtip -d glitchtip | gzip > "$APP_DIR/backups/glitchtip-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/glitchtip-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/glitchtip-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	GlitchTip is answering at https://${DOMAIN_HOST}/_health/

	  1. Open https://${DOMAIN_HOST} and follow Sign Up now. Self-signup is off,
	     and upstream keeps it open only while no account exists, so this is the
	     one and only registration this instance will accept. Save the password
	     in a password manager first: no mail is configured, so there is no
	     reset link. Create the organization it asks for next, then confirm the
	     door is shut with
	       curl -sS https://${DOMAIN_HOST}/api/settings/ | tr -d ' ' | grep -o '"enableUserRegistration":[a-z]*'
	     which must print "enableUserRegistration":false.
	  2. Then make a project. Its DSN is what your application's Sentry SDK
	     points at, and it is the whole reason this server exists.
	  3. Your SECRET_KEY and database credential are in $APP_DIR/.env, mode 600.
	     They were not printed here. Read them with
	       sudo grep -E 'SECRET_KEY|DB_PASSWORD' $APP_DIR/.env
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Sentry.

  • Your instrumentation survives, your depth does not. GlitchTip receives what the Sentry SDKs send, so the sentry-sdk in your code stays and one DSN string changes. What you give up is the part Sentry charges for: no session replay, no profiling, and tracing that lists slow transactions rather than the performance product. If the thing you actually open every week is the issue stream, that is a fair trade, and if it is replay, it is not.
  • No mail, by choice. This install configures no SMTP, and GlitchTip responds by turning email off: account verification and password reset disappear from the interface, and the one account you create is the whole recovery story. Alerts still work, through a webhook recipient you add on the project's alert rule, so wire one to whatever you actually read before you rely on this to tell you anything.
  • You are now the retention policy. Events default to 90 days and the database is the product, so disk is the number that bites: upstream's own guide puts a million events a month at roughly 30 GB. Nobody sends you an overage email here. The failure mode is a full disk at 2am instead of a bill.
  • A monitor of your own code cannot watch itself. If GlitchTip runs on the same box as the application it tracks, the outage that takes down one takes down the reporting for the other, and the errors thrown during it are gone rather than queued.
  • No hosted vantage point, no support contract, and no on-call anybody. The paid plans buy a team keeping the event pipeline up while your site is the thing on fire; here that is you.

Where this came from

“Our app is compatible with Sentry client SDKs, but easier to run.”

  • GlitchTip requires PostgreSQL 14 or newer and a single service, with separate web and worker services only for scaling, and Valkey or Redis 7 optional; upstream recommends 512 MB of RAM and puts a million events a month at roughly 30 GB of disk. source
  • Upstream's own sample compose file runs three services, postgres, valkey and web with SERVER_ROLE all_in_one, publishes port 8000, and disables Django Admin and the OpenAPI schema. source
  • With ENABLE_USER_REGISTRATION set to False, self-signup is disabled once the first user is registered, and ENABLE_ORGANIZATION_CREATION defaults to False so only superusers create further organizations. source
  • The all_in_one start script applies Django migrations and maintains the Postgres partitions before it starts the web server with the worker embedded, which is why the first boot binds its port minutes after the container starts. source
  • Email is optional in the code: with no mail transport configured nothing is sent, account verification and password reset are off, and /api/settings/ omits the email feature so the frontend hides that UI. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Sentry?

    Not Sentry itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is GlitchTip. Error tracking that speaks the Sentry SDK protocol, so your code keeps its instrumentation and changes one DSN. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Sentry?

    GlitchTip. Error tracking that speaks the Sentry SDK protocol, so your code keeps its instrumentation and changes one DSN. The only one here that keeps your instrumentation. GlitchTip speaks the Sentry SDK protocol, so the sentry-sdk already in your code stays, you change one DSN string, and errors land on your own hostname with no monthly event count deciding the bill. It is three containers and MIT licensed, and the honest half is what it does not do: no session replay, no profiling, and tracing that shows slow transactions rather than the deep performance product Sentry sells. If what you actually read every week is the issue stream, this is the trade to make. GlitchTip is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Sentry?

    2048 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. GlitchTip itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Sentry Team, $26/mo — $312 a year, a metered rate, not a whole bill.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the GlitchTip install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run GlitchTip on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same GlitchTip on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every DSN this hands out begins with http://localhost:8123, so only code running on this same computer can report to it, and a machine that is asleep collects nothing while your users are hitting the bug. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.