203 lines
9.0 KiB
Markdown
203 lines
9.0 KiB
Markdown
# 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. |