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

12 KiB
Raw Blame History

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

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

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

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:

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

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

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)

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)

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)

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.

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

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

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.


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:

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):

* * * * * 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:

[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:

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):

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;
}
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:

#!/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:

# 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.