VPS deploy instructions
This commit is contained in:
415
docs/ops/deployment.md
Normal file
415
docs/ops/deployment.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user