Files
fuel-alert/docs/ops/deployment.md
2026-06-11 10:10:48 +01:00

416 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VPS Deployment Runbook — FuelAlert
How to deploy and run this app on the IONOS VPS (Nginx + PHP-FPM + MySQL + Redis).
Two parts:
- **First-time setup** (§1§7) — done once when provisioning the server.
- **Every deploy** (§8) — the short sequence you repeat each time you ship.
If something breaks after deploy, jump to **§10 Troubleshooting** — most live
problems are one of four things.
---
## 0. Server prerequisites
Install these once on the VPS:
| Software | Why | Notes |
|---|---|---|
| **PHP 8.4** + FPM | runs the app | extensions: `mbstring, pdo_mysql, redis, intl, bcmath, curl, xml, zip, gd` |
| **Composer 2** | PHP deps | |
| **Node 22 + npm** | builds the Vue SPA assets | only needed to run `npm run build` |
| **MySQL 8** | database | InnoDB |
| **Redis** | queue + cache | |
| **Nginx** | web server | serves `public/` |
| **Git** | pulls the code | |
| **Certbot** | HTTPS cert | Sanctum cookie auth requires HTTPS |
| **Supervisor** *or* systemd | keeps the queue worker alive | systemd shown below |
Quick check after install: `php -v`, `composer -V`, `node -v`, `redis-cli ping` (→ `PONG`), `mysql --version`.
---
## 1. Get the code
```bash
cd /var/www
git clone <your-gitea-repo-url> fuel-alert
cd fuel-alert
git checkout main # main = live (see §9 for tagging releases)
```
The app lives at `/var/www/fuel-alert`. Adjust paths below if you use another location.
---
## 2. Create the production `.env`
```bash
cp .env.example .env
php artisan key:generate # sets APP_KEY
```
Then edit `.env`. **The values below are the ones that matter for production**
see §11 for the full reference table.
### Critical — app
```dotenv
APP_NAME=FuelAlert
APP_ENV=production
APP_DEBUG=false # NEVER true on live — leaks stack traces + secrets
APP_URL=https://fuel-alert.co.uk
```
### Critical — SPA cookie/session auth (the #1 "login broke on live" trap)
This app is a Vue SPA using Sanctum cookie auth. If these don't match your real
domain over HTTPS, login/registration fail with 419/401 even though the rest of
the site looks fine:
```dotenv
SESSION_DRIVER=redis
SESSION_DOMAIN=.fuel-alert.co.uk
SESSION_SECURE_COOKIE=true
SANCTUM_STATEFUL_DOMAINS=fuel-alert.co.uk
```
### Critical — database / redis / queue / cache
```dotenv
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=fuel_alert
DB_USERNAME=fuel_alert
DB_PASSWORD=<strong-password>
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
QUEUE_CONNECTION=redis # a worker MUST run — see §6
CACHE_STORE=redis
```
### Critical — your own API gate
```dotenv
API_SECRET_KEY=<long-random-string>
```
`API_SECRET_KEY` gates the station-search API (`VerifyApiKey` middleware). The SPA
sends the matching key. If it's missing/wrong, `GET /api/stations` returns 401 and
the search page shows nothing.
### Mail (Ionos SMTP)
```dotenv
MAIL_MAILER=smtp
MAIL_HOST=smtp.ionos.co.uk
MAIL_PORT=587
MAIL_SCHEME=tls
MAIL_USERNAME=<ionos-mailbox>
MAIL_PASSWORD=<ionos-password>
MAIL_FROM_ADDRESS=hello@fuel-alert.co.uk
MAIL_FROM_NAME=FuelAlert
```
### External data APIs (the product needs these to have live data)
```dotenv
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
FRED_API_KEY=
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
EIA_API_KEY=
```
### Notification providers (fill when those channels go live)
```dotenv
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=
```
### Stripe — can be deferred
If launching free-only first, leave Stripe **test** keys and don't promote paid
plans. When you go live with payments, see §7.
```dotenv
CASHIER_CURRENCY=gbp
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_ANNUAL=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL=
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
```
> **Remember:** after ANY `.env` change on a cached production box, re-run
> `php artisan config:cache` or the change won't take effect (see §8).
---
## 3. Install dependencies & build
```bash
composer install --no-dev --optimize-autoloader
npm ci && npm run build # compiles the Vue SPA into public/build
```
`--no-dev` skips dev-only packages. `npm run build` is required — without it the
SPA has no compiled assets and you get a blank page / Vite manifest error.
---
## 4. Database: migrate + seed plans
```bash
php artisan migrate --force # --force = run in production non-interactively
php artisan db:seed --class=PlanSeeder --force # REQUIRED
```
> **Do not** run `migrate:fresh`, `migrate:reset`, or `db:wipe` on the server —
> they destroy data. Only `migrate` (forward) is safe.
`PlanSeeder` populates the `plans` table. The entire tier/entitlement system
(`PlanFeatures`) resolves through these rows — skip it and features misbehave for
every user. It's idempotent, so it's safe to re-run.
---
## 5. Storage link + production caches
```bash
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```
The cache commands make production fast. Trade-off: cached config ignores later
`.env` edits until you re-run `config:cache`.
### File permissions
The web user (usually `www-data`) must be able to write to two dirs:
```bash
sudo chown -R www-data:www-data storage bootstrap/cache
sudo find storage -type d -exec chmod 775 {} \;
sudo find storage -type f -exec chmod 664 {} \;
```
---
## 6. Background processes (the part everyone forgets)
The app is not just web requests. Two things must run continuously or the product
silently stops working.
### 6a. Scheduler (cron) — keeps prices & predictions fresh
The app schedules the entire data pipeline: `fuel:poll`, `oil:fetch`,
`forecast:llm-overlay`, `beis:import`, `forecast:resolve-outcomes`,
`forecast:evaluate-volatility`, `fuel:archive`, plus morning/evening WhatsApp jobs.
Without cron, live data goes stale.
Add ONE cron entry (`crontab -e` as the app user):
```cron
* * * * * cd /var/www/fuel-alert && php artisan schedule:run >> /dev/null 2>&1
```
Laravel's scheduler decides internally which task runs when — you only need this
one line.
### 6b. Queue worker — sends notifications, processes polling jobs
Notifications and polling run as queued jobs. No worker = nothing ever sends.
Run it as a systemd service so it restarts on crash/reboot.
Create `/etc/systemd/system/fuelalert-worker.service`:
```ini
[Unit]
Description=FuelAlert queue worker
After=network.target redis.service mysql.service
[Service]
User=www-data
Group=www-data
Restart=always
RestartSec=3
WorkingDirectory=/var/www/fuel-alert
ExecStart=/usr/bin/php artisan queue:work redis --queue=notifications,default --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
```
Enable it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now fuelalert-worker
sudo systemctl status fuelalert-worker # should be "active (running)"
```
> The `notifications` queue is listed first so alerts get priority over default jobs.
---
## 7. Nginx + HTTPS
Server block (`/etc/nginx/sites-available/fuel-alert`):
```nginx
server {
listen 80;
server_name fuel-alert.co.uk www.fuel-alert.co.uk;
root /var/www/fuel-alert/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* { deny all; }
client_max_body_size 20M;
}
```
```bash
sudo ln -s /etc/nginx/sites-available/fuel-alert /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d fuel-alert.co.uk -d www.fuel-alert.co.uk # HTTPS — required for secure cookies
```
`root` points at `public/`, never the project root. The SPA routing is handled by
Laravel's catch-all in `routes/web.php` via `index.php`, so the standard
`try_files … /index.php` block is all you need.
---
## 8. Every deploy (the repeatable sequence)
After the first-time setup, each deploy is just this. Save it as
`deploy.sh` in the project root (`chmod +x deploy.sh`) and run `./deploy.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/fuel-alert
php artisan down --render="errors::503" # maintenance mode (optional)
git fetch --tags
git checkout "${1:-main}" # ./deploy.sh v0.2.0 → deploy a tag; no arg → main
git pull --ff-only || true
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
# refresh caches (config:cache picks up any .env changes)
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart # workers reload the NEW code
php artisan up
php artisan about # sanity check: env=production, debug=false
```
> `queue:restart` is important: long-running workers keep the OLD code in memory
> until told to restart. Skip it and your new code won't run in queued jobs.
>
> Only run `db:seed --class=PlanSeeder --force` again if you changed plan/feature
> definitions — it's safe (idempotent) but usually unnecessary per deploy.
---
## 9. Tagging a release (rollback points)
`main` is live. Tag the commit you actually deploy so you have a named, verified
rollback point:
```bash
# locally, once the deploy is confirmed working:
git tag -a v0.1.0 -m "first live version"
git push origin v0.1.0
```
Roll back by deploying an older tag: `./deploy.sh v0.1.0`. List tags: `git tag`.
Bump the middle number for meaningful releases, the last for small fixes.
---
## 10. Troubleshooting — the four usual suspects
| Symptom | Likely cause | Fix |
|---|---|---|
| Login/register fails with **419** or **401** | SPA cookie domains wrong | Check `APP_URL`, `SESSION_DOMAIN`, `SANCTUM_STATEFUL_DOMAINS`, `SESSION_SECURE_COOKIE` in `.env`, then `config:cache` |
| Station search returns **401 / empty** | `API_SECRET_KEY` missing or mismatched | Set it in `.env`, `config:cache`, rebuild SPA if the key is baked into the build |
| Prices/predictions are **stale or empty** | scheduler cron not running | Verify the `* * * * *` cron line; test with `php artisan schedule:run` manually |
| Notifications **never arrive** | queue worker not running | `systemctl status fuelalert-worker`; check `storage/logs/laravel.log` |
| `.env` change **had no effect** | config is cached | `php artisan config:cache` |
| **Blank page** / "Vite manifest not found" | assets not built | `npm ci && npm run build` |
| **500** right after deploy | permissions on storage | re-run the `chown`/`chmod` in §5; check `storage/logs/laravel.log` |
Useful commands: `php artisan about` (env summary), `tail -f storage/logs/laravel.log`
(live errors), `redis-cli ping`, `sudo systemctl status fuelalert-worker`.
---
## 11. Environment variable reference
Keys that need real production values (from `.env.example`):
**App:** `APP_NAME` `APP_ENV=production` `APP_KEY` `APP_DEBUG=false` `APP_URL`
**Session/SPA:** `SESSION_DRIVER` `SESSION_DOMAIN` `SESSION_SECURE_COOKIE` `SANCTUM_STATEFUL_DOMAINS`
**Database:** `DB_CONNECTION` `DB_HOST` `DB_PORT` `DB_DATABASE` `DB_USERNAME` `DB_PASSWORD`
**Redis/queue/cache:** `REDIS_CLIENT` `REDIS_HOST` `REDIS_PORT` `REDIS_PASSWORD` `QUEUE_CONNECTION` `CACHE_STORE`
**Your API gate:** `API_SECRET_KEY`
**Mail (Ionos):** `MAIL_MAILER` `MAIL_HOST` `MAIL_PORT` `MAIL_SCHEME` `MAIL_USERNAME` `MAIL_PASSWORD` `MAIL_FROM_ADDRESS` `MAIL_FROM_NAME`
**Fuel data:** `FUEL_FINDER_CLIENT_ID` `FUEL_FINDER_CLIENT_SECRET` `FUEL_FINDER_BASE_URL` `FRED_API_KEY` `EIA_API_KEY`
**LLM:** `ANTHROPIC_API_KEY` `ANTHROPIC_MODEL` `LLM_PREDICTION_PROVIDER`
**Notifications:** `ONESIGNAL_APP_ID` `ONESIGNAL_API_KEY` `VONAGE_KEY` `VONAGE_SECRET` `VONAGE_WHATSAPP_FROM` `VONAGE_SMS_FROM`
**Stripe (deferrable):** `STRIPE_KEY` `STRIPE_SECRET` `STRIPE_WEBHOOK_SECRET` `CASHIER_CURRENCY` `STRIPE_PRICE_*`
### Stripe go-live (when payments launch)
1. Swap in live `STRIPE_KEY` / `STRIPE_SECRET` and all six `STRIPE_PRICE_*` IDs.
2. In the Stripe dashboard, add a webhook endpoint:
`https://fuel-alert.co.uk/stripe/webhook`
3. Copy its signing secret into `STRIPE_WEBHOOK_SECRET`.
4. `php artisan config:cache`.
5. Configure Stripe dashboard retries (days 1/3/5, cancel after final) for the
grace-period dunning flow.