# GlitchTip Self-Hosted Deployment

Self-hosted Sentry-compatible error tracking. Deployed at `/opt/glitchtip/` on rayserver, proxied via nginx at `https://shopproquote.graj-media.com/glitchtip/`.

## Docker Compose

```yaml
# /opt/glitchtip/docker-compose.yml
services:
  db:
    image: postgres:15
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7
    restart: unless-stopped

  web:
    image: glitchtip/glitchtip:latest
    restart: unless-stopped
    depends_on: [db, redis]
    environment:
      DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres"
      REDIS_URL: "redis://redis:6379/1"
      SECRET_KEY: ${SECRET_KEY}
      EMAIL_URL: "consolemail://"
      PORT: 8000
      DEFAULT_FROM_EMAIL: "glitchtip@localhost"
      GLITCHTIP_EMBED_WORKER: "true"   # CRITICAL — see pitfalls
    ports:
      - "127.0.0.1:8001:8000"          # 8001 host — 8000 is taken by Portainer
    volumes:
      - uploads:/app/uploads

volumes:
  pgdata:
  uploads:
```

`.env` (mode 0600):
```
POSTGRES_PASSWORD=<openssl rand -hex 24>
SECRET_KEY=<openssl rand -hex 24>
```

## nginx Location (added to existing site block)

```nginx
location /glitchtip/ {
    # Sentry SDK envelope URL: /glitchtip/1/api/1/envelope/
    # This rewrite strips the project ID so GlitchTip gets /api/1/envelope/
    rewrite ^/glitchtip/[0-9]+/(api/.*)$ /$1 break;
    rewrite ^/glitchtip/[0-9]+/(store/.*)$ /api/1/$1 break;
    rewrite ^/glitchtip/(.*)$ /$1 break;
    proxy_pass http://127.0.0.1:8001;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
}
```

**Critical ordering:** The `api/` and `store/` rewrites MUST come BEFORE the general `^/glitchtip/(.*)$` rewrite. Nginx tries them first — if a request matches `/glitchtip/1/api/1/envelope/`, it hits the `api/` rewrite and becomes `/api/1/envelope/`. The general rewrite would incorrectly strip the path to `/1/api/1/envelope/`.

## First-Run Setup

### 1. Migrate

With `GLITCHTIP_EMBED_WORKER=true` (as specified in the compose file above), migrations auto-run on startup — the all-in-one start.sh calls `python manage.py migrate --no-input --skip-checks` automatically. No manual step needed.

If deploying without embedded worker, run manually:
```bash
docker exec glitchtip-web-1 python manage.py migrate
```

### 2. Suppress ALLOWED_HOSTS warning (optional but recommended)

Add to docker-compose environment:
```yaml
ALLOWED_HOSTS: "shopproquote.graj-media.com,127.0.0.1"
```
Without this, GlitchTip logs `RuntimeWarning: ALLOWED_HOSTS is the wildcard default. Restrict to known hostnames` on every startup. The warning is harmless in development but should be set for production deployments.

### 3. Create superuser via Django shell

```bash
docker exec glitchtip-web-1 python manage.py shell -c "
from django.apps import apps
U = apps.get_model('users', 'User')
u = U.objects.filter(email='admin@localhost').first()
u or U.objects.create_superuser(email='admin@localhost', password='<password>')
"
```

**Pitfall:** Direct module imports like `from organizations.models import Organization` fail with `RuntimeError: Model class doesn't declare an explicit app_label`. Always use `apps.get_model('app_label', 'ModelName')` in GlitchTip's shell — the app registry isn't fully loaded for direct imports.

### 3. Create organization + project via Django shell

```bash
docker exec glitchtip-web-1 python manage.py shell -c "
from django.apps import apps
Org = apps.get_model('organizations_ext', 'Organization')
OrgUser = apps.get_model('organizations_ext', 'OrganizationUser')
OrgOwner = apps.get_model('organizations_ext', 'OrganizationOwner')
Project = apps.get_model('projects', 'Project')
ProjectKey = apps.get_model('projects', 'ProjectKey')
User = apps.get_model('users', 'User')

u = User.objects.get(email='admin@localhost')
org, created = Org.objects.get_or_create(name='SPQ', slug='spq')
if created:
    ou = OrgUser.objects.create(organization=org, user=u, role=4)  # 4 = OWNER
    OrgOwner.objects.create(organization=org, organization_user=ou)

proj, _ = Project.objects.get_or_create(name='spq-frontend', slug='spq-frontend', organization=org, defaults={'platform': 'javascript-react'})
pk = ProjectKey.objects.filter(project=proj).first() or ProjectKey.objects.create(project=proj)
print(f'DSN: http://{pk.public_key}@127.0.0.1:8001/{proj.id}')
"
```

**Pitfall:** `OrgUser.role` is NOT NULL — you must pass `role=4` (OWNER). Omitting it raises `IntegrityError: null value in column "role"`.

**Pitfall:** `ProjectKey` has no `dsn_secret` attribute. The DSN is constructed from `pk.public_key` (a UUID) + the project's numeric ID: `http://<public_key>@<host>/<project_id>`.

### 4. Verify event ingestion

```bash
# Send a test event via the Sentry store endpoint
curl -s -X POST http://127.0.0.1:8001/api/1/store/ \
  -H "X-Sentry-Auth: Sentry sentry_key=<public_key>,sentry_version=7" \
  -H "Content-Type: application/json" \
  -d '{"event_id":"<uuid-without-dashes>","timestamp":"2026-07-07T22:51:00Z","level":"error","message":"test","platform":"javascript"}'
# → {"event_id":"...","task_id":null}  HTTP 200
```

Then check the issues table:
```bash
docker exec glitchtip-web-1 python manage.py shell -c "
from django.apps import apps
I = apps.get_model('issue_events', 'Issue')
print(f'Issues: {I.objects.count()}')
"
```

If the count stays 0 after the API returned 200, the worker isn't running — see the `GLITCHTIP_EMBED_WORKER` pitfall below.

## Frontend Integration (@sentry/react)

```ts
// src/main.tsx — BEFORE createRoot, but dynamic-imported to keep initial bundle small
import { setErrorReporter } from './lib/userMessages';

const DSN = import.meta.env.VITE_GLITCHTIP_DSN as string | undefined;
if (DSN) {
  import('@sentry/react').then((Sentry) => {
    Sentry.init({
      dsn: DSN,
      environment: import.meta.env.VITE_ENV || 'development',
      tracesSampleRate: 0.1,
    });
    setErrorReporter((err, ctx) => {
      Sentry.captureException(err, { extra: ctx });
    });
  }).catch(() => { /* non-critical */ });
}
```

The `setErrorReporter` hook (already in `src/lib/userMessages.ts`) routes `reportError()` calls into Sentry. Dynamic import keeps @sentry/react out of the initial bundle — it only loads when the DSN env var is set (production) AND the first error triggers the import. Zero impact on dev or initial load.

`.env.production`:
```
VITE_GLITCHTIP_DSN=https://<public_key>@shopproquote.graj-media.com/glitchtip/<project_id>
```

The DSN uses the public HTTPS URL (via nginx proxy), not the internal `127.0.0.1:8001`, because the browser sends events directly.

## Pitfalls (GlitchTip v6.2.0)

- **GLITCHTIP_EMBED_WORKER=true is REQUIRED.** Without it, GlitchTip runs as web-only (SERVER_ROLE=web). The API accepts events (HTTP 200, returns event_id) but nothing processes them into issues — the Issue table stays empty. Set this env var in docker-compose.yml and recreate the container with --force-recreate.

- **nginx rewrite ORDERING matters.** The Sentry SDK sends events to /glitchtip/<project_id>/api/1/envelope/. The api/ rewrite MUST come BEFORE the general ^/glitchtip/(.*)$ rewrite. If the general rewrite hits first, the path becomes /1/api/1/envelope/ which GlitchTip doesn't serve (returns 400).

- **ALLOWED_HOSTS should be set** to suppress the startup warning. Without it, every container restart logs "RuntimeWarning: ALLOWED_HOSTS is the wildcard default". Set to shopproquote.graj-media.com,127.0.0.1 for the SPQ deployment.

- **Port 8000 is taken by Portainer** on rayserver. Use 127.0.0.1:8001:8000. Always ss -tlnp | grep :8000 before deploying.

- **With GLITCHTIP_EMBED_WORKER=true, migrations auto-run** on startup. The all-in-one start.sh calls python manage.py migrate --no-input --skip-checks automatically. No manual migrate step needed when using the compose file above.

- **Django shell imports need apps.get_model().** Direct from organizations.models import Organization raises RuntimeError. Use apps.get_model('organizations_ext', 'Organization') (note the _ext suffix on the app label).

- **OrgUser.role is NOT NULL.** Pass role=4 (OWNER) when creating. Without it: IntegrityError: null value in column "role".

- **ProjectKey.dsn_secret does not exist.** The DSN is http://<public_key>@<host>/<project_id>. Check pk._meta.get_fields() to see available attributes — public_key is the Sentry key, project.id is the numeric project ID.

- **Event ID must be a valid UUID (hex, no dashes) for the /api/1/store/ endpoint.** Non-UUID strings return 422 uuid_parsing error. Use python3 -c "import uuid; print(uuid.uuid4().hex)".

- **Sentry envelope format** (/api/1/envelope/): three newline-delimited JSON objects — header {"event_id":"...","sent_at":"..."}, item header {"type":"event"}, and the event payload. Content-Type: text/plain;charset=UTF-8. The store endpoint (/api/1/store/) is simpler for single events.