From 257c09d178b15d890fee82eaf0a074b4299b7ae1 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 11 Jun 2026 10:10:48 +0100 Subject: [PATCH] VPS deploy instructions --- docs/ops/deployment.md | 415 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/ops/deployment.md diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md new file mode 100644 index 0000000..f21afd3 --- /dev/null +++ b/docs/ops/deployment.md @@ -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 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= + +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= +``` + +`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= +MAIL_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.