# Deploying ModernSignal to a real server — first-time setup

This walks through the very first deploy, from a bare Linux VPS to a
running app. Everything here assumes Ubuntu/Debian with `apt`; adjust
package names if you're on something else.

Replace `yourdomain.com` and `/var/www/modernsignal` everywhere below with
your real domain and install path.

## What's in this package

- The full application source (this session's working copy, including
  everything not yet committed to git — see the note at the end).
- `public/build/` — already-built production frontend assets (`npm run
  build` was run before packaging), so the server does **not** need
  Node/npm installed.
- `deploy/modernsignal_mysql.sql` — a full MySQL dump (schema + data) of
  the local dev database, plain SQL (`mysqldump`), importable with a
  single `mysql ... < file` — verified by actually importing it into a
  fresh database before packaging.
- `deploy/.env.production.example` — a production-shaped `.env` template
  with every key blanked out or set to a placeholder. **No real secrets
  are in this package anywhere** — API keys, mail credentials, and the
  local dev APP_KEY were all deliberately left out.
- `deploy/nginx.conf.example`, `deploy/modernsignal-horizon.service`,
  `deploy/crontab.example` — server config templates.

`vendor/` and `node_modules/` are **not** included — you'll run
`composer install` on the server itself (see below), which is far more
reliable than shipping vendor across machines/architectures.

## 1. Server prerequisites

```bash
sudo apt update
sudo apt install -y nginx mysql-server redis-server \
    php8.3-fpm php8.3-cli php8.3-mysql php8.3-redis php8.3-mbstring \
    php8.3-xml php8.3-curl php8.3-bcmath php8.3-zip php8.3-intl \
    certbot python3-certbot-nginx
```

MySQL 8.0.13+ is required (functional indexes — the app's migrations use
one). Composer itself: https://getcomposer.org/download/ (or `sudo apt
install composer` if your distro's version is recent enough).

## 2. Upload and extract

```bash
scp modernsignal-deploy-*.tar.gz you@yourserver:/tmp/
ssh you@yourserver
sudo mkdir -p /var/www/modernsignal
sudo tar -xzf /tmp/modernsignal-deploy-*.tar.gz -C /var/www/modernsignal
cd /var/www/modernsignal
```

## 3. Install PHP dependencies

```bash
composer install --no-dev --optimize-autoloader
```

## 4. Configure the environment

```bash
cp deploy/.env.production.example .env
nano .env   # fill in every blank: DB password, mail creds, AI API keys,
            # the real domain in APP_URL/SESSION_DOMAIN/GOOGLE_REDIRECT_URI
php artisan key:generate
```

Do **not** reuse the local dev `APP_KEY` — `key:generate` makes a fresh
one for this environment.

## 5. Create the database and import the dump

```bash
sudo mysql -e "CREATE DATABASE modernsignal CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'modernsignal'@'localhost' IDENTIFIED BY 'the-password-you-put-in-.env';"
sudo mysql -e "GRANT ALL PRIVILEGES ON modernsignal.* TO 'modernsignal'@'localhost'; FLUSH PRIVILEGES;"
mysql -u modernsignal -p modernsignal < deploy/modernsignal_mysql.sql
```

This imports every table already populated in local dev (brands,
AeoQuery/AeoQueryRun history, etc.) — including the `migrations` table
itself, so a fresh `php artisan migrate` afterward is a safe no-op check,
not a real step:

```bash
php artisan migrate --force
```

(If you'd rather start with an empty production database instead of the
dev data, skip the `mysql < deploy/modernsignal_mysql.sql` import
entirely and just run `php artisan migrate --force` against an empty
database — the schema itself doesn't depend on the dump.)

## 6. Storage, caches, permissions

```bash
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache

sudo chown -R www-data:www-data /var/www/modernsignal
sudo find /var/www/modernsignal/storage -type d -exec chmod 775 {} \;
sudo find /var/www/modernsignal/bootstrap/cache -type d -exec chmod 775 {} \;
```

Re-run the three `artisan *:cache` commands after every future deploy —
they bake the current `.env`/routes/views into fast-loading files, so a
stale cache after an env change is a common "why isn't this working"
trap. `php artisan optimize:clear` undoes all three if you need to debug.

## 7. Web server

```bash
sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/modernsignal.conf
sudo ln -s /etc/nginx/sites-available/modernsignal.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```

Point your domain's DNS A record at this server's IP before the next
step (certbot needs it resolvable).

```bash
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
```

Certbot rewrites the nginx config to add the HTTPS server block and
redirect HTTP → HTTPS automatically. After it succeeds, set
`SESSION_SECURE_COOKIE=true` in `.env` if it isn't already (the template
above already has it on) and re-run `php artisan config:cache`.

## 8. Queue workers (Horizon)

```bash
sudo cp deploy/modernsignal-horizon.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now modernsignal-horizon
sudo systemctl status modernsignal-horizon   # should show "active (running)"
```

This is what actually runs AEO monitoring, crawling, and every other
background job — nothing in this app queues work reliably without it.

## 9. Scheduler (cron)

```bash
sudo crontab -u www-data -e
# paste the one line from deploy/crontab.example, save
```

## 10. Smoke test

- Visit `https://yourdomain.com` — should load the login page.
- Log in with an existing account from the restored dump (or register a
  new one if you started with an empty database).
- Check `sudo systemctl status modernsignal-horizon` and
  `sudo tail -f storage/logs/laravel.log` while clicking around —
  confirm no fatal errors.
- From a brand's Prompts page, try "Run monitoring" and confirm a job
  actually appears in `http://yourdomain.com/horizon` (Horizon's own
  dashboard — protect this route in production if it isn't already
  gated behind auth/platform_admin).

## Future deploys (not this first one)

Once this is running, a normal deploy is: `git pull`, `composer install
--no-dev --optimize-autoloader`, `npm run build` (or ship prebuilt
assets again), `php artisan migrate --force`, the three `*:cache`
commands, then `php artisan horizon:terminate` (systemd restarts it,
letting in-flight jobs finish first).

## Note on database portability

This app originally only ran on Postgres. A handful of Postgres-specific
queries (case-insensitive `ILIKE` matches, a `pg_trgm` fuzzy-match
function, a couple of index/foreign-key migration quirks MySQL enforces
more strictly than Postgres) have been converted to work identically on
both — verified by running the full test suite against a real local
MySQL instance, not just reading the code. Local dev still runs on
Postgres; only this production deploy uses MySQL. If anything ever
needs both drivers supported going forward, search for
`getDriverName() === 'mysql'` to find every place that branches on it.

## Important note on git

This package was built directly from the working directory, **including
everything not yet committed to git** on branch `checkpoint-2026-09-11`
— none of the recent session's work (Claude engine integration, the
Prompts export feature, taxonomy changes, etc.) has been committed yet.
Deploying from an uncommitted tarball works fine for this one-time
upload, but going forward you'll want an actual commit/push so future
deploys can be `git pull`-based instead of manual tarballs. That's a
separate step — ask if you'd like help with it.
