> ## Documentation Index
> Fetch the complete documentation index at: https://snowseo.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Running the reference server

> Start your own beacon collector with one command, or a container, and point your sites at it.

## Start it

```bash theme={null}
BEACON_KEYS=$(openssl rand -hex 24) npx @snowseo/beacon-server
```

```
[beacon] listening on http://0.0.0.0:8787/v3/beacon/hits (1 key(s), ip mode: hash)
```

That is the whole thing. It refuses to start without at least one key, because a collector that accepts anything is worse than one that is down.

***

## Point your sites at it

The key is whatever you generated. The endpoint may be a bare origin: the ingest path is appended for you.

<CodeGroup>
  ```ts JavaScript theme={null}
  createBeacon({
    siteUrl: "https://example.com",
    dir: "dist",
    analytics: {
      key: process.env.BEACON_KEY!,
      endpoint: "https://beacon.example.com",
    },
  });
  ```

  ```php PHP theme={null}
  define('SNOWSEO_BEACON_ENDPOINT', 'https://beacon.example.com');
  define('SNOWSEO_BEACON_KEY', 'the key you generated');
  ```

  ```php WordPress theme={null}
  // wp-config.php, above the "stop editing" line
  define('SNOWSEO_BEACON_ENDPOINT', 'https://beacon.example.com');
  define('SNOWSEO_BEACON_KEY', 'the key you generated');
  ```
</CodeGroup>

***

## Docker

```bash theme={null}
BEACON_KEYS=$(openssl rand -hex 24) docker compose up -d
```

The [compose file](https://github.com/Snow-SEO/beacon/blob/main/packages/beacon-server/docker-compose.yml) runs SQLite on a named volume by default and ships a `postgres` profile:

```bash theme={null}
export BEACON_STORE=postgres
export BEACON_POSTGRES_URL=postgres://beacon:beacon@postgres:5432/beacon
docker compose --profile postgres up -d
```

***

## Configuration

| Variable                | Default        | Meaning                                      |
| ----------------------- | -------------- | -------------------------------------------- |
| `BEACON_KEYS`           | **required**   | Ingest keys, separated by whitespace or `;`. |
| `PORT`                  | `8787`         |                                              |
| `HOST`                  | `0.0.0.0`      |                                              |
| `BEACON_STORE`          | `sqlite`       | `sqlite`, `postgres` or `memory`.            |
| `BEACON_SQLITE_PATH`    | `beacon.db`    |                                              |
| `BEACON_POSTGRES_URL`   | `DATABASE_URL` | Also needs `npm install pg`.                 |
| `BEACON_IP_MODE`        | `hash`         | `hash`, `raw` or `discard`.                  |
| `BEACON_IP_SALT`        | random         | Set it, or hashes change on every restart.   |
| `BEACON_MAX_BODY_BYTES` | `4194304`      |                                              |

### Scoping a key to a site

Append `@` and a host list to restrict what a key may report for. Wildcards cover any depth of subdomain, and the apex.

```bash theme={null}
BEACON_KEYS="abc123@example.com,*.example.com  def456@other.test"
```

A key with no `@` is unrestricted. Scoping narrows the blast radius if a key leaks: a stolen key can then only pollute the site it was already reporting for.

***

## What happens to IP addresses

Verification runs against the raw address, always. It has to: the address is the one part of a request a crawler cannot dress up, and it is what the CIDR and reverse-DNS checks compare.

What gets **stored** is your choice:

| `BEACON_IP_MODE` | Stored                    | Use when                                                                            |
| ---------------- | ------------------------- | ----------------------------------------------------------------------------------- |
| `hash` (default) | HMAC-SHA256, 32 hex chars | You want to count distinct crawlers without retaining addresses. What SnowSEO does. |
| `raw`            | The address               | It is your own collector, your own data, and you have a reason.                     |
| `discard`        | `null`                    | You want verification but no address in the database at all.                        |

Deferred reverse-DNS holds the raw address in memory only for as long as the lookup takes.

<Note>
  Set `BEACON_IP_SALT`. Without it a random salt is generated at boot and the same crawler hashes differently after every restart, which makes "how many distinct addresses" meaningless across a deploy.
</Note>

***

## What it stores

Two tables. `beacon_hits` is one row per classified hit; `beacon_daily_stats` is a pre-aggregated rollup per `(date, agent)`, so a 500-hit batch costs one upsert per pair rather than 500.

Both are created on start unless you disable migration. The verification columns are the interesting ones:

| Column          | Values                                                  |
| --------------- | ------------------------------------------------------- |
| `verify_state`  | `signed`, `verified`, `unverified`, `spoofed_suspected` |
| `verify_method` | `web_bot_auth`, `cidr`, `reverse_dns`, or null          |
| `verified`      | boolean, true for `signed` and `verified`               |

`unverified` and `spoofed_suspected` are not the same thing and should not be charted together. The first means there was nothing to check against. The second means something checkable contradicts the claim.

***

## Deferred verification

Most hits are settled inline by a CIDR match, which does no I/O. Providers that publish no ranges but do publish an authenticating reverse-DNS suffix are settled **after** the response, because a DNS round-trip per hit is far too slow for a 500-hit batch.

That backfill updates both the hit and its daily rollup. Nothing is lost if it fails; the hit simply stays `unverified`.

***

## Embedding it instead

If you already have an app, you do not need the HTTP server. The pipeline is exported on its own:

```ts theme={null}
import { ingestBatch, runDeferredVerification } from "@snowseo/beacon-server";

const result = await ingestBatch(host, hits);
await myStore.save(result.rows, result.rollups);
await runDeferredVerification(result.deferred, myStore);
```

`ingestBatch` is the same function hosted SnowSEO runs in production. Implement the `HitStore` interface to persist wherever you like, or extend `SqliteHitStore`, `PostgresHitStore` or `MemoryHitStore`.
