# Can I self-host Sentry?

**YES** — it's called GlitchTip. ONE EVENING setup · ~2 hours to running · 2 GB RAM minimum · $26/mo you stop paying ($312/yr on the Team plan) — a metered rate, not a whole bill.

GlitchTip authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/sentry/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

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

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

The page this mirrors: https://caniselfhostit.com/self-host/sentry/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
