# Self-hosted Docker

Beacon ships as a Compose bundle: `docker-compose.yml`, `.env.example`, and HTTP on port **8000**. You do not need a GitHub checkout or `auth.json`. A naked `docker run` of the GHCR image is not enough — that image is PHP-FPM only (no published HTTP port, no Nginx, no Redis, no Horizon).

Current image: **`ghcr.io/apxcde/beacon:0.3.3-beta`**. Override with `BEACON_IMAGE` in `.env`.

## What `docker compose up` starts

- `app` — the Laravel PHP-FPM application container (`ghcr.io/apxcde/beacon`)
- `nginx` — the public HTTP entrypoint on `http://localhost:8000` (override with `BEACON_PORT`)
- `redis` — queue backend for Horizon
- `horizon` — queue worker and Horizon supervisor
- `scheduler` — Laravel scheduler loop
- `mysql` — optional MySQL service, enabled only when you start the `mysql` profile

The Docker setup uses SQLite by default so a fresh install does not need a separate database container. If you prefer MySQL, switch the database environment variables and enable the MySQL profile.

Inside Docker, the app defaults to `/var/www/html/storage/app/database/beacon.sqlite` when SQLite is enabled. That container-only path is intentionally kept out of Laravel's `DB_DATABASE` in `.env.example` (that value is for `php artisan serve`).

## First boot

Download the bundle from your [license portal](https://apexcode.dev/beacon), or curl the same files:

```bash
mkdir beacon && cd beacon
curl -fsSL https://apexcode.dev/beacon/docker-compose.yml -o docker-compose.yml
curl -fsSL https://apexcode.dev/beacon/env.example -o .env.example
cp .env.example .env

echo '<token-from-your-license-portal>' | docker login ghcr.io -u apxcde-distrib --password-stdin
docker compose up -d

docker compose cp ./beacon.license app:/var/www/html/storage/app/beacon.license
```

Beacon is at **http://localhost:8000**.

If you installed via Composer (`composer create-project apxcde/beacon` from `packages.apexcode.dev`), skip the curls — `docker-compose.yml` and `.env.example` are already in the project. Copy `beacon.license` to `storage/app/beacon.license`, then `cp .env.example .env` and `docker compose up -d` from that directory.

On first boot the app container will:

- create a persistent app key if one is not already set
- create the SQLite database file under `storage/app/database/beacon.sqlite` when SQLite is enabled
- wait for MySQL when `DB_CONNECTION=mysql`
- run `php artisan migrate --force`
- create the public storage symlink if needed

## Database modes

### SQLite default

No extra services are required:

```bash
docker compose up -d
```

The default compose environment points Laravel at:

- `DB_CONNECTION=sqlite`
- `DB_DATABASE=/var/www/html/storage/app/database/beacon.sqlite` (set by the container start script)

### MySQL profile

Start Beacon with the MySQL profile and override the Laravel database connection:

```bash
DB_CONNECTION=mysql \
MYSQL_DATABASE=beacon \
MYSQL_USER=beacon \
MYSQL_PASSWORD=secret \
MYSQL_ROOT_PASSWORD=root \
  docker compose --profile mysql up -d
```

If you only set `DB_CONNECTION=mysql`, the container startup script defaults `DB_DATABASE` to `MYSQL_DATABASE` so first boot still targets a valid MySQL schema name.

The PHP container already includes both `pdo_sqlite` and `pdo_mysql`, so switching between SQLite and MySQL is just an environment change.

The bundled MySQL container is internal to the compose network by default and does not publish port `3306` to your host, which avoids conflicts with any local MySQL instance you may already be running.

## Validate the install

1. Open `http://localhost:8000`
2. Complete the setup dashboard:
   - create the default workspace
   - save Twilio credentials
   - register a primary Twilio number
   - generate an API key
3. Confirm the health endpoint responds:

```bash
curl http://localhost:8000/up
```

4. Confirm the OpenAPI spec is reachable:

```bash
curl http://localhost:8000/openapi.yaml
```

5. Send a test API request using the generated key:

```bash
curl -X POST http://localhost:8000/api/text \
  -H "X-API-Key: beacon_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+15551234567",
    "message": "Hello from Beacon"
  }'
```

6. Connect an MCP client:

```bash
claude mcp add beacon http://localhost:8000/api/mcp \
  --transport http \
  --scope user \
  --header "X-API-Key: beacon_your_key_here"
```

## Useful commands

```bash
# stop the stack
docker compose down

# stop the stack and remove named volumes (including the image-copied code volume)
docker compose down -v

# inspect the PHP-FPM app logs
docker compose logs -f app

# inspect Nginx logs
docker compose logs -f nginx

# inspect Horizon logs
docker compose logs -f horizon
```

## Notes

- The MCP endpoint is served by the Laravel app at `/api/mcp`; there is no separate MCP container.
- Persistent app state lives in the `beacon_storage` and `beacon_redis` named volumes.
- `beacon_code` is populated from the image on first boot so Nginx can serve `/public` without a local checkout. After `docker compose pull`, remove that volume before up so hashed frontend assets match the new image: `docker compose down && docker volume rm beacon_beacon_code && docker compose up -d`.
- If you want to inject your own app key instead of using the generated one, set `APP_KEY` in `.env` before startup. Compose interpolates that value into the container environment; variables that are only in `.env` and not referenced in `docker-compose.yml` never reach Laravel.
- Override the published port with `BEACON_PORT` and set `APP_URL` to match (for example `BEACON_PORT=8080` and `APP_URL=http://localhost:8080`).
- Image updates during your update window land as new tags on `ghcr.io/apxcde/beacon` and as Composer packages on `packages.apexcode.dev`. See [licensing.md](./licensing.md).
