Files
hermes-config/skills/self-hosting/docker-service-deployment/references/glitchtip-deployment.md
T
2026-07-12 10:17:17 -04:00

9.0 KiB

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

# /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)

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:

docker exec glitchtip-web-1 python manage.py migrate

Add to docker-compose environment:

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

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

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

# 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:

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)

// 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>@/<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.