# Local Docker stack

Self-hosted replacement for the remote `speed.elevaseo.app` service.
Everything stays on the host's loopback — nothing is exposed publicly.

## Services

| Service            | Image                                  | Host port        | Purpose                                  |
| ------------------ | -------------------------------------- | ---------------- | ---------------------------------------- |
| `postgres`         | `eleva-speed-postgres:latest` (custom) | `127.0.0.1:5433` | Main app DB (+ pg_cron, pg_net)          |
| `redis`            | `redis:8-alpine`                       | none (internal)  | BullMQ queue for the sitespeed worker    |
| `sitespeed-worker` | `eleva-speed-sitespeed-worker:latest`  | `127.0.0.1:3001` | HTTP wrapper around the sitespeed.io CLI |

The worker image is built **on top of** `sitespeedio/sitespeed.io:36.6.0-plus1`
so the CLI + headless Chrome + lighthouse stack are already bundled — no
docker socket mounting, no shelling out to another container.

## Bring it up

```bash
pnpm docker:up      # build (first time) + start all 3 services
pnpm docker:logs    # tail every service
pnpm docker:down    # stop everything (data volumes survive)
lazydocker          # interactive TUI inspection
```

For day-to-day dev (Next.js on the host + stack in Docker):

```bash
pnpm dev:docker     # = docker compose up -d && next dev
```

## Environment variables

Point Next.js at the local worker by adding this line to `.env.local`:

```bash
SITESPEED_API_URL=http://localhost:3001
# SITESPEED_API_KEY is unused locally (no auth on the worker — it only
# listens on 127.0.0.1). Leave the existing prod value or set it to "".
```

Postgres credentials are baked into `docker-compose.yml`:

- DB: `elevaspeed`
- User / password: `elevaspeed` / `devpassword`
- Connection string: `postgres://elevaspeed:devpassword@localhost:5433/elevaspeed`

## API contract (what Next.js talks to)

The worker mirrors the contract the existing Next.js routes expect
(`app/api/speed/test/route.ts`, `app/api/sites/[siteId]/test/route.ts`).

```http
POST /api/test
Content-Type: application/json

{
  "url": "https://example.com",
  "device": "desktop",   // or "mobile"
  "iterations": 1        // 1..5
}
```

Returns `202 { id, status: "queued" }`. Poll with:

```http
GET /api/test/:id
```

Returns the full `TestResult` (see `lib/sitespeed.ts`):

```jsonc
{
  "id": "abc123",
  "url": "https://example.com",
  "browser": "chrome",
  "device": "desktop",
  "iterations": 1,
  "status": "queued | running | completed | failed",
  "createdAt": "ISO",
  "completedAt": "ISO | null",
  "error": null,
  "metrics": { /* TestMetrics */ },
  "lighthouse": { /* LighthouseData */ },
  "har": "/results/abc123/.../browsertime.har",
  "reportUrl": "/results/abc123/.../index.html",
  "screenshots": { "afterPageCompleteCheck": "/results/.../after.png", ... }
}
```

All files referenced by URLs above are served at:

```http
GET /results/<...>
```

## Smoke test

```bash
# 1. Stack is up?
curl -s http://localhost:3001/health
# → {"ok":true,"ts":"..."}

# 2. Enqueue a run
TEST_ID=$(curl -s -X POST http://localhost:3001/api/test \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com","device":"desktop"}' | jq -r .id)
echo "test id: $TEST_ID"

# 3. Poll until completed (takes ~30-90s)
watch -n 5 "curl -s http://localhost:3001/api/test/$TEST_ID | jq '{status, error, metrics: .metrics.firstContentfulPaint}'"
```

## Debugging — why elevaseo.com renders as an error page

Common causes when the headless Chrome inside sitespeed gets a broken
page where a real browser sees normal content:

1. **Cloudflare / WAF blocking the bot UA.** Sitespeed default UA is
   `sitespeed.io/<version>`. Edit `services/sitespeed-worker/src/runner.ts`
   → `buildArgs()` and add:
   ```ts
   "--browsertime.userAgent",
   "Mozilla/5.0 (...real Chrome UA...)",
   ```
2. **JS challenge timing out.** Increase iterations or add
   `--browsertime.pageCompleteCheck`/`--browsertime.pageLoadStrategy normal`.
3. **Geo / IP block.** The container has the same egress IP as your host.
   If you VPN, the worker uses the VPN IP too.

To inspect the artefacts directly:

```bash
# List the output of a run
docker compose exec sitespeed-worker ls /data/results/<id>
# Pull the failed screenshot to the host
docker cp eleva-speed-sitespeed-worker:/data/results/<id>/pages/.../screenshots/largestContentfulPaint.png /tmp/
# Tail sitespeed CLI stdout for a specific job
docker compose logs -f sitespeed-worker | grep <id>
```

### Disable headless mode

Headless Chrome inside the container has no display, so `HEADLESS=false`
is a no-op there. To watch the render live, install sitespeed on the
host instead:

```bash
# Outside Docker — uses your host's Chrome
npx sitespeedio sitespeed.io https://elevaseo.com \
  --browser chrome \
  --browsertime.iterations 1 \
  --browsertime.headless false
```

## Queue maintenance

```bash
# Connect to the worker's Redis
docker compose exec redis redis-cli

# Inside redis-cli:
KEYS sitespeed:result:*       # all persisted test states
DEL sitespeed:result:abc123   # delete a specific one
KEYS bull:sitespeed-tests:*   # BullMQ internals
FLUSHDB                       # nuclear: drop the whole queue
```

## Reset the world

```bash
docker compose down -v        # also drops the volumes (pg-data, redis, results)
docker compose up -d --build  # full rebuild
```
