# VotersAlert Ubuntu + aaPanel Deployment Guide

This guide deploys VotersAlert as a dual-stack application on an Ubuntu server managed by aaPanel.

- **Frontend:** TanStack Start SSR app in the repository root, built with Bun/Vite from the root `package.json` and served by a Nitro Node server.
- **Backend:** Laravel 12 API in `backend/`, served by PHP 8.4+ PHP-FPM.
- **Database:** MySQL from aaPanel.
- **Workers:** Laravel queue workers for campaign delivery jobs, including `ProcessCampaignDeliveryJob`.

Example placeholders used below:

```text
DOMAIN=votersalert.example.com
APP_PATH=/www/wwwroot/votersalert
DB_NAME=votersalert
DB_USER=votersalert_app
FRONTEND_PORT=3000
```

Replace placeholders with real values. Never commit real `.env` files or share secret values.

---

## 1. Production routing model

Use one public domain and let Nginx split traffic by path:

```text
https://DOMAIN/               -> TanStack Start SSR on 127.0.0.1:3000
https://DOMAIN/api/*          -> Laravel backend/public/index.php
https://DOMAIN/sanctum/*      -> Laravel backend/public/index.php, if used
https://DOMAIN/storage/*      -> Laravel backend/public/storage, if linked
```

Laravel webhook URLs:

```text
Paystack:     https://DOMAIN/api/payments/paystack/webhook
Flutterwave:  https://DOMAIN/api/payments/flutterwave/webhook
Delivery:     https://DOMAIN/api/delivery/{provider}/webhook
```

Only ports `80` and `443` should be public. Keep the SSR server bound to `127.0.0.1`.

---

## 2. aaPanel prerequisites

Install these from aaPanel **App Store**:

1. Nginx
2. MySQL 8.x or compatible MariaDB
3. PHP 8.4 or newer
4. Composer
5. Node.js Version Manager
6. PM2 Manager or Node Project plugin
7. Supervisor Manager / Process Manager, if available
8. Cron module

Enable PHP 8.4 extensions required by Laravel and this backend: `bcmath`, `ctype`, `curl`, `dom`/`xml`, `fileinfo`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `zip`, and optionally `redis` if using Redis queues/cache.

Recommended PHP settings:

```text
memory_limit=256M or higher
upload_max_filesize=match expected CSV/import size
post_max_size=greater than upload_max_filesize
max_execution_time=120 or higher
```

Optional Ubuntu packages:

```bash
sudo apt update
sudo apt install -y git unzip curl ca-certificates supervisor
```

---

## 3. Create the website and database

### 3.1 Website

In **aaPanel -> Website -> Add site**:

- Domain: `DOMAIN`
- Root path: `/www/wwwroot/votersalert`
- PHP version: PHP 8.4+
- SSL: enable after DNS points to the server

The Nginx config will be customized later so `/` goes to TanStack Start and `/api/*` goes to Laravel.

### 3.2 Database

In **aaPanel -> Databases -> MySQL -> Add database**:

- Database: `votersalert`
- User: `votersalert_app`
- Password: generate a long random password
- Access: local server only unless your database is remote

Laravel migrations need DDL privileges during deployment:

```sql
GRANT ALL PRIVILEGES ON votersalert.* TO 'votersalert_app'@'localhost';
FLUSH PRIVILEGES;
```

---

## 4. Upload or clone the project

Git deployment is preferred:

```bash
cd /www/wwwroot
sudo git clone <REPOSITORY_URL> votersalert
cd /www/wwwroot/votersalert
sudo chown -R www:www /www/wwwroot/votersalert
```

If PHP-FPM runs as a different user, replace `www:www`.

### 4.1 Optional: use the web installer

This repository includes a web-based deployment helper at `deploy/installer/index.php` that digitizes the remaining manual steps.

Recommended use on a freshly cloned aaPanel server:

```bash
cd /www/wwwroot/votersalert
/www/server/php/84/bin/php -S 127.0.0.1:8088 -t deploy/installer
```

Open the installer through a temporary SSH tunnel or restricted Nginx location. Step 1 writes and synchronizes the root `.env` and `backend/.env`, validates PHP 8.4+, required PHP extensions, Node 22+, Bun, Composer, PM2, Supervisor, and MySQL connectivity, then generates these templates:

```text
deploy/generated/nginx-votersalert.conf
deploy/generated/votersalert-campaign-worker.conf
```

Step 2 runs the backend install/migration/cache commands, builds the Nitro frontend with `NITRO_PRESET=node-server`, restarts/starts PM2 for `.output/server/index.mjs`, and restarts the Supervisor queue worker. Remove or lock down the installer after deployment because it can write secrets and execute server commands.

---

## 5. Configure the root frontend `.env`

The root `.env.example` is consumed by `src/lib/config.server.ts`.

```bash
cd /www/wwwroot/votersalert
cp .env.example .env
nano .env
```

Set production values:

```dotenv
NODE_ENV=production
APP_URL=https://DOMAIN

DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=votersalert_app
DB_PASSWORD=<DB_PASSWORD>
DB_NAME=votersalert
DB_POOL_LIMIT=10

AUTH_ACCESS_SECRET=<LONG_RANDOM_SECRET>
AUTH_REFRESH_SECRET=<DIFFERENT_LONG_RANDOM_SECRET>
AUTH_ACCESS_TTL_SECONDS=900
AUTH_REFRESH_TTL_SECONDS=2592000
AUTH_MFA_TICKET_TTL_SECONDS=300
AUTH_ISSUER=votersalert
AUTH_AUDIENCE=votersalert.app
AUTH_COOKIE_DOMAIN=
AUTH_SECURE_COOKIES=true
```

Notes:

- Frontend server functions currently read MySQL directly via the root `DB_*` variables.
- Do not place backend-only provider secrets in the root `.env` unless code explicitly requires them.
- Never put secrets in `VITE_*` variables because those can be exposed to browser code.

Restrict the file:

```bash
chmod 600 .env
chown www:www .env
```

---

## 6. Configure `backend/.env` for Laravel 12

Create the Laravel env file from `backend/.env.example`:

```bash
cd /www/wwwroot/votersalert/backend
cp .env.example .env
nano .env
```

Use production values:

```dotenv
APP_NAME=VotersAlert
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://DOMAIN

LOG_CHANNEL=stack
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=votersalert
DB_USERNAME=votersalert_app
DB_PASSWORD=<DB_PASSWORD>

CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=database

BILLING_CURRENCY=NGN
BILLING_VAT_RATE_BASIS_POINTS=750
BILLING_MIN_WALLET_FUNDING_KOBO=100000
PAYMENT_CALLBACK_URL=https://DOMAIN/billing/payments/callback

PAYSTACK_PUBLIC_KEY=<PAYSTACK_PUBLIC_KEY>
PAYSTACK_SECRET_KEY=<PAYSTACK_SECRET_KEY>
FLUTTERWAVE_PUBLIC_KEY=<FLUTTERWAVE_PUBLIC_KEY>
FLUTTERWAVE_SECRET_KEY=<FLUTTERWAVE_SECRET_KEY>
FLUTTERWAVE_SECRET_HASH=<FLUTTERWAVE_SECRET_HASH>

CAMPAIGN_DELIVERY_DRY_RUN=false
CAMPAIGN_DELIVERY_REQUIRE_PAID_INVOICE=true
CAMPAIGN_DELIVERY_QUEUE=campaign-delivery
CAMPAIGN_DELIVERY_BATCH_SIZE=500
CAMPAIGN_DELIVERY_CHUNK_SIZE=500
CAMPAIGN_DELIVERY_MAX_ATTEMPTS=3

WHATSAPP_DELIVERY_MODE=cloud
WHATSAPP_GRAPH_VERSION=v20.0
WHATSAPP_PHONE_NUMBER_ID=<WHATSAPP_PHONE_NUMBER_ID>
WHATSAPP_ACCESS_TOKEN=<WHATSAPP_ACCESS_TOKEN>
WHATSAPP_WEBHOOK_VERIFY_TOKEN=<WHATSAPP_WEBHOOK_VERIFY_TOKEN>
```

Optional provider keys supported by `backend/config/campaign_delivery.php`:

```dotenv
WHATSAPP_BSP_BASE_URL=
WHATSAPP_BSP_TOKEN=
SMS_PROVIDER_BASE_URL=
SMS_PROVIDER_TOKEN=
SMS_SENDER_ID=VotersAlert
VOICE_PROVIDER_BASE_URL=
VOICE_PROVIDER_TOKEN=
VOICE_CALLER_ID=
META_GRAPH_VERSION=v20.0
META_AD_ACCOUNT_ID=
META_ACCESS_TOKEN=
GOOGLE_ADS_API_BASE_URL=https://googleads.googleapis.com
GOOGLE_ADS_CUSTOMER_ID=
GOOGLE_ADS_DEVELOPER_TOKEN=
```

Restrict the file:

```bash
chmod 600 .env
chown www:www .env
```

### 6.1 Keep root `.env` and `backend/.env` synchronized

Both stacks use the same database, so keep these pairs identical:

| Root `.env` | Backend `.env` | Purpose |
| --- | --- | --- |
| `DB_HOST` | `DB_HOST` | MySQL host |
| `DB_PORT` | `DB_PORT` | MySQL port |
| `DB_NAME` | `DB_DATABASE` | Same schema |
| `DB_USER` | `DB_USERNAME` | Same app DB user |
| `DB_PASSWORD` | `DB_PASSWORD` | Same app DB password |
| `APP_URL` | `APP_URL` | Public URL for same-domain deployment |

Keep backend-only secrets out of the root `.env`: `APP_KEY`, `PAYSTACK_SECRET_KEY`, `FLUTTERWAVE_SECRET_KEY`, `FLUTTERWAVE_SECRET_HASH`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_BSP_TOKEN`, `SMS_PROVIDER_TOKEN`, `VOICE_PROVIDER_TOKEN`, `META_ACCESS_TOKEN`, and `GOOGLE_ADS_DEVELOPER_TOKEN`.

---

## 7. Install and prepare Laravel

Run from `backend/`:

```bash
cd /www/wwwroot/votersalert/backend
php -v
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan key:generate --force
```

If aaPanel's PHP 8.4 binary is not the default `php`, use the full path, commonly similar to `/www/server/php/84/bin/php`.

Set writable directories:

```bash
chown -R www:www storage bootstrap/cache
chmod -R ug+rwX storage bootstrap/cache
```

Run migrations:

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

If using `QUEUE_CONNECTION=database` and the `jobs` table migration is not present, add it before relying on queues:

```bash
php artisan queue:table
php artisan migrate --force
```

Then optimize Laravel:

```bash
php artisan storage:link || true
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize
```

Validate:

```bash
php artisan about
php artisan route:list --path=api
```

---

## 8. Build the TanStack Start frontend with Bun/Vite

The root `package.json` defines `build` as `vite build`. The root `bunfig.toml` enables Bun's 24-hour supply-chain guard with `minimumReleaseAge = 86400`; use the committed `bun.lock` and frozen installs.

### 8.1 Install Node and PM2

In **aaPanel -> Node.js Version Manager**, install Node 22 LTS or newer and set it active. Install PM2 through aaPanel PM2 Manager or with:

```bash
npm install -g pm2
```

### 8.2 Install Bun

```bash
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun --version
```

Make sure the deployment user that runs builds has Bun in `PATH`.

### 8.3 Install dependencies and build

```bash
cd /www/wwwroot/votersalert
bun install --frozen-lockfile
NODE_ENV=production NITRO_PRESET=node-server bun run build
```

For aaPanel/PM2, the Nitro build must produce a standalone Node server, normally:

```text
.output/server/index.mjs
.output/public/
```

If the build only creates `dist/server/server.js` exporting a `fetch` handler and no `.output/server/index.mjs`, it is still using a non-Node Nitro preset. Rebuild with `NITRO_PRESET=node-server` or configure TanStack/Nitro to use the Node preset for production deployments.

Smoke-test locally:

```bash
HOST=127.0.0.1 PORT=3000 NODE_ENV=production node .output/server/index.mjs
```

From another SSH session:

```bash
curl -I http://127.0.0.1:3000/
```

Stop the foreground process after the test.

---

## 9. Keep the frontend running with PM2 / aaPanel

### Option A: aaPanel Node Project UI

In **aaPanel -> Node Project** or **PM2 Manager -> Add project**:

- Project path: `/www/wwwroot/votersalert`
- Startup file: `.output/server/index.mjs`
- Project name: `votersalert-frontend`
- Port: `3000`
- Run user: `www`, if available
- Environment: `NODE_ENV=production`, `HOST=127.0.0.1`, `PORT=3000`

Use aaPanel's environment editor or a restricted PM2 ecosystem file for env values. Do not pass secrets as command-line arguments.

### Option B: PM2 CLI

Create a server-only `ecosystem.config.cjs`:

```js
module.exports = {
  apps: [
    {
      name: 'votersalert-frontend',
      cwd: '/www/wwwroot/votersalert',
      script: '.output/server/index.mjs',
      interpreter: 'node',
      env: {
        NODE_ENV: 'production',
        HOST: '127.0.0.1',
        PORT: '3000'
      }
    }
  ]
}
```

Start and persist:

```bash
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup systemd
pm2 status
pm2 logs votersalert-frontend --lines 100
```

---

## 10. Configure Nginx in aaPanel

Open **aaPanel -> Website -> DOMAIN -> Config** and adapt the server block. The PHP-FPM socket/include varies by aaPanel install; common PHP 8.4 examples are `unix:/tmp/php-cgi-84.sock` or an `include enable-php-84.conf;` line.

Example same-domain config:

```nginx
upstream votersalert_frontend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 80;
    server_name DOMAIN;

    root /www/wwwroot/votersalert/backend/public;
    index index.php index.html;

    access_log /www/wwwlogs/DOMAIN.log;
    error_log  /www/wwwlogs/DOMAIN.error.log;
    client_max_body_size 50m;

    location ^~ /.well-known/acme-challenge/ {
        root /www/wwwroot/votersalert/backend/public;
    }

    location ~ /\. {
        deny all;
    }

    location ^~ /api/ {
        try_files $uri /index.php?$query_string;
    }

    location ^~ /sanctum/ {
        try_files $uri /index.php?$query_string;
    }

    location ^~ /storage/ {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $document_root;
        fastcgi_pass unix:/tmp/php-cgi-84.sock;
    }

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 300;
        proxy_send_timeout 300;
        proxy_pass http://votersalert_frontend;
    }
}
```

Replace `DOMAIN` and verify the PHP-FPM socket. If aaPanel generated an `include enable-php-84.conf;` line, you may keep it instead of the manual PHP block, but `/api/*` must route internally to Laravel `index.php`.

Test and reload:

```bash
nginx -t
sudo systemctl reload nginx
```

Then enable SSL in aaPanel and force HTTPS.

---

## 11. Configure Laravel queues for campaign delivery

`backend/app/Jobs/ProcessCampaignDeliveryJob.php` implements `ShouldQueue`. Campaign delivery dispatches jobs onto `config('campaign_delivery.queue')`, which defaults to `campaign-delivery`.

Production should use a real queue connection, not `sync`:

```dotenv
QUEUE_CONNECTION=database
CAMPAIGN_DELIVERY_QUEUE=campaign-delivery
```

### 11.1 Supervisor, recommended

Create `/etc/supervisor/conf.d/votersalert-campaign-worker.conf`:

```ini
[program:votersalert-campaign-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /www/wwwroot/votersalert/backend/artisan queue:work database --queue=campaign-delivery,default --sleep=3 --tries=3 --timeout=120 --max-time=3600
directory=/www/wwwroot/votersalert/backend
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www
numprocs=2
redirect_stderr=true
stdout_logfile=/www/wwwroot/votersalert/backend/storage/logs/queue-worker.log
stopwaitsecs=180
```

Replace `/usr/bin/php` with aaPanel's PHP 8.4 binary if needed.

Load it:

```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
```

After each deployment:

```bash
cd /www/wwwroot/votersalert/backend
php artisan queue:restart
sudo supervisorctl restart votersalert-campaign-worker:*
```

### 11.2 aaPanel Process Manager alternative

If using aaPanel Process Manager:

- Name: `votersalert-campaign-worker`
- Working directory: `/www/wwwroot/votersalert/backend`
- Command: `/www/server/php/84/bin/php artisan queue:work database --queue=campaign-delivery,default --sleep=3 --tries=3 --timeout=120 --max-time=3600`
- User: `www`
- Auto start: enabled
- Auto restart: enabled
- Instances: start with `2` and tune based on provider rate limits

### 11.3 Cron fallback

If persistent workers are unavailable, add an aaPanel Cron job every minute:

```bash
cd /www/wwwroot/votersalert/backend && /www/server/php/84/bin/php artisan queue:work database --queue=campaign-delivery,default --stop-when-empty --tries=3 --timeout=120 >> storage/logs/queue-cron.log 2>&1
```

This is a fallback only. Persistent workers are better for `ProcessCampaignDeliveryJob` throughput.

### 11.4 Laravel scheduler

Add a one-minute Cron entry so future Laravel scheduled tasks run:

```bash
* * * * * cd /www/wwwroot/votersalert/backend && /www/server/php/84/bin/php artisan schedule:run >> /dev/null 2>&1
```

---

## 12. Provider callback configuration

Set provider dashboards to these URLs:

```text
Paystack webhook:     https://DOMAIN/api/payments/paystack/webhook
Flutterwave webhook:  https://DOMAIN/api/payments/flutterwave/webhook
Delivery verify URL:  https://DOMAIN/api/delivery/{provider}/webhook
Delivery webhook URL: https://DOMAIN/api/delivery/{provider}/webhook
Payment callback:     https://DOMAIN/billing/payments/callback
```

Security-sensitive backend keys:

- `PAYSTACK_SECRET_KEY` signs/verifies Paystack webhook requests.
- `FLUTTERWAVE_SECRET_HASH` verifies Flutterwave webhooks.
- `WHATSAPP_ACCESS_TOKEN` authorizes WhatsApp Cloud API delivery.
- `WHATSAPP_BSP_TOKEN` authorizes BSP delivery when `WHATSAPP_DELIVERY_MODE=bsp`.

Store these only in `backend/.env` or aaPanel's protected environment configuration.

---

## 13. Security checklist

1. `APP_DEBUG=false` in `backend/.env`.
2. `NODE_ENV=production` in root `.env` and PM2.
3. Root `.env` and `backend/.env` permissions are `600`.
4. Nginx does not expose the repository root or hidden files.
5. PM2/Nitro listens on `127.0.0.1:3000`, not a public interface.
6. Only `80` and `443` are open publicly.
7. `PAYSTACK_SECRET_KEY`, `WHATSAPP_ACCESS_TOKEN`, and similar tokens are backend-only.
8. Secrets are not passed as command-line arguments.
9. SSL is enabled and HTTP redirects to HTTPS.
10. The MySQL user is scoped to the application database.

---

## 14. Future deployment/update procedure

```bash
cd /www/wwwroot/votersalert
git pull --ff-only

# Frontend
bun install --frozen-lockfile
NODE_ENV=production NITRO_PRESET=node-server bun run build
pm2 restart votersalert-frontend --update-env

# Backend
cd backend
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan optimize
php artisan queue:restart
```

If using Supervisor:

```bash
sudo supervisorctl restart votersalert-campaign-worker:*
```

---

## 15. Smoke tests

Run after first deployment and after updates:

```bash
pm2 status
pm2 logs votersalert-frontend --lines 50
sudo supervisorctl status
```

HTTP checks:

```bash
curl -I https://DOMAIN/
curl -I https://DOMAIN/api/user
```

`/api/user` may return `401 Unauthorized` when unauthenticated; that is acceptable. `404`, `500`, or `502` needs investigation.

Laravel checks:

```bash
cd /www/wwwroot/votersalert/backend
php artisan about
php artisan migrate:status
php artisan queue:failed
```

Database checks in aaPanel MySQL or MySQL CLI:

```sql
SHOW TABLES;
```

Expected groups include users, Sanctum tokens, voter tables, campaign tables, billing tables, campaign delivery tables, and queue tables if database queues are enabled.

---

## 16. Troubleshooting

### Frontend 502

```bash
pm2 status
pm2 logs votersalert-frontend --lines 100
curl -I http://127.0.0.1:3000/
```

If `.output/server/index.mjs` is missing, rebuild with:

```bash
NODE_ENV=production NITRO_PRESET=node-server bun run build
```

### API 404

Confirm Nginx routes `/api/*` to Laravel:

```bash
cd /www/wwwroot/votersalert/backend
php artisan route:list --path=api
nginx -t
```

### API 500

Check Laravel logs:

```bash
tail -n 200 /www/wwwroot/votersalert/backend/storage/logs/laravel.log
```

Common causes are missing `APP_KEY`, stale config cache, database mismatch between env files, and unwritable `storage/` or `bootstrap/cache/`.

After changing `backend/.env`:

```bash
cd /www/wwwroot/votersalert/backend
php artisan config:clear
php artisan config:cache
php artisan queue:restart
```

After changing root `.env`:

```bash
pm2 restart votersalert-frontend --update-env
```

### Campaign deliveries do not process

```bash
cd /www/wwwroot/votersalert/backend
php artisan tinker --execute="dump(config('queue.default'), config('campaign_delivery.queue'));"
php artisan queue:failed
sudo supervisorctl status
```

Confirm:

```dotenv
QUEUE_CONNECTION=database
CAMPAIGN_DELIVERY_QUEUE=campaign-delivery
CAMPAIGN_DELIVERY_DRY_RUN=false
```

Workers must include `--queue=campaign-delivery,default`.

### Provider calls stay offline or dry-run

Payment initialization falls back to offline mode when payment secrets are blank. Campaign delivery may dry-run when `CAMPAIGN_DELIVERY_DRY_RUN=true`. For live delivery, set the appropriate provider credentials in `backend/.env` and rebuild Laravel config cache.

---

## 17. Rollback notes

Before major releases, create an aaPanel MySQL backup/snapshot.

To rollback code:

```bash
cd /www/wwwroot/votersalert
git checkout <previous_commit>
bun install --frozen-lockfile
NODE_ENV=production NITRO_PRESET=node-server bun run build
pm2 restart votersalert-frontend --update-env

cd backend
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
```

Do not run destructive database rollback commands in production without a tested backup and rollback plan.
