# 03 · Production Deployment

Going live. This document assumes a Linux server you control, and it assumes the system is about
to hold real customer names, addresses, phone numbers, CNIC numbers and money.

**Read §6 before you put real data in.** The demo dataset ships with published passwords.

---

## 1. Server requirements

| | Minimum | Comfortable |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU |
| RAM | 4 GB | 8 GB |
| Disk | 40 GB SSD | 80 GB SSD, growing with uploaded photographs |

Software: **PHP 8.2** (fpm), **PostgreSQL 16+**, **Redis 6+**, **Nginx**, **Node 18+** (build only,
can be built elsewhere), **Supervisor**, **Certbot**.

PHP extensions: `pdo_pgsql pgsql mbstring openssl fileinfo gd intl zip bcmath opcache`.

Unlike the Windows development environment, a Linux server **does** have `ext-pcntl`, so Laravel
Horizon becomes available if you want it. The built-in queue monitor at
`/admin/core/queue-monitor` works either way.

**The database:** create an empty one and a role that owns it, and stop there.

```sql
CREATE DATABASE rusukh;
```

The schema needs three PostgreSQL extensions — `citext`, `pg_trgm` and `btree_gist` — and the first
migration installs all three. They are *trusted* extensions on PostgreSQL 13+, so the owning role
installs them without superuser rights. The one exception is a managed database service that blocks
even trusted extensions; there, have an administrator run those three
`CREATE EXTENSION IF NOT EXISTS` statements before you migrate, or `php artisan migrate` will stop
at the first trigram index with `operator class "gin_trgm_ops" does not exist`.

---

## 2. Get the code and build

```bash
git clone <your-repository> /var/www/rusukh
cd /var/www/rusukh

composer install --no-dev --optimize-autoloader
npm ci
npm run build            # required — this application ships no CDN fallback

cp .env.example .env
php artisan key:generate
```

Set ownership so PHP-FPM can write where it must, and only where it must:

```bash
chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
```

---

## 3. Environment

The differences from development are not cosmetic. Every line below matters.

```ini
APP_ENV=production
APP_DEBUG=false                     # See the warning below
APP_URL=https://rusukh.pk
APP_TIMEZONE=Asia/Karachi

LOG_LEVEL=warning                   # debug will fill the disk

DB_CONNECTION=pgsql
DB_DATABASE=rusukh
DB_USERNAME=rusukh_app              # NOT the postgres superuser
DB_PASSWORD=<long random secret>

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=<set one>            # do not leave Redis unauthenticated

SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true          # HTTPS only
QUEUE_CONNECTION=redis
CACHE_DRIVER=redis

# Real mail. Emails are the ONLY channel this system uses — no SMS, ever.
MAIL_MAILER=smtp
MAIL_HOST=<your smtp host>
MAIL_PORT=587
MAIL_USERNAME=<user>
MAIL_PASSWORD=<secret>
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="no-reply@rusukh.pk"

# Live payments — only when PayFast has issued real credentials.
PAYFAST_MODE=live
PAYFAST_MERCHANT_ID=<real>
PAYFAST_SECURED_KEY=<real>

# Security
TWO_FACTOR_REQUIRED_ROLES=super_admin,management,finance
IMPERSONATION_MAX_MINUTES=30
TELESCOPE_ENABLED=false             # never expose Telescope in production
```

> ### `APP_DEBUG=false` is not optional
> With debug on, an unhandled error renders a full stack trace including absolute file paths,
> framework versions and source code. This was observed in testing: a `GET /logout` (a 405) dumped
> the complete debug screen. On a public server that is an information-disclosure vulnerability.
> Set it to `false` and confirm by visiting a URL that does not exist.

Then cache the configuration — and remember that **every future `.env` change needs these re-run**:

```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```

---

## 4. The queue worker — the step people skip

Rusukh dispatches every customer email, every report, every commission maturation and every
nightly job through the queue. If the worker is not running:

- customers stop receiving order updates, and nobody notices for days
- commissions never mature
- reports never generate

The application will look completely healthy the whole time.

`/etc/supervisor/conf.d/rusukh-worker.conf`:

```ini
[program:rusukh-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/rusukh/artisan queue:work redis --tries=3 --timeout=120 --sleep=3 --max-time=3600
directory=/var/www/rusukh
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/rusukh/storage/logs/worker.log
stopwaitsecs=130
```

```bash
supervisorctl reread && supervisorctl update && supervisorctl start rusukh-worker:*
```

**After every deployment, restart the workers** — they hold the old code in memory:

```bash
php artisan queue:restart
```

### The scheduler

One crontab line, which internally runs everything nightly:

```cron
* * * * * cd /var/www/rusukh && php artisan schedule:run >> /dev/null 2>&1
```

> **Known issue worth planning around:** scheduled and queued jobs compute "today" in UTC rather
> than `Asia/Karachi`, because the timezone middleware only runs for HTTP requests. Nightly jobs
> that run close to midnight Karachi time can therefore land on the previous day. Logged in
> [`../RESUME.md`](../RESUME.md) §4.

---

## 5. Nginx and TLS

```nginx
server {
    listen 443 ssl http2;
    server_name rusukh.pk;
    root /var/www/rusukh/public;

    ssl_certificate     /etc/letsencrypt/live/rusukh.pk/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rusukh.pk/privkey.pem;

    add_header X-Frame-Options            "SAMEORIGIN"        always;
    add_header X-Content-Type-Options     "nosniff"           always;
    add_header Referrer-Policy            "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security  "max-age=31536000"  always;

    index index.php;
    charset utf-8;
    client_max_body_size 12M;      # uploaded proof photographs

    location / { try_files $uri $uri/ /index.php?$query_string; }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* { deny all; }
}

server {
    listen 80;
    server_name rusukh.pk;
    return 301 https://$host$request_uri;
}
```

`client_max_body_size` must be at least as large as `rusukh.uploads.max_size_kb` (default 10 MB),
or large photographs fail with an opaque nginx error rather than a readable validation message.

---

## 6. Before real data goes in — do not skip this

The demo dataset exists so the system can be explored and demonstrated. Its passwords are
published in the source code, in this documentation and in the repository.

**1. Seed production without the demo data.** A production install wants schema, reference data
and permissions — not 120 fictional orders:

```bash
php artisan migrate --force
php artisan db:seed --class=ProductionSeeder --force
```

If you have already seeded the demo dataset, and you intend to go live on that same database,
**you must change every seeded password** — all 16 staff accounts and all 8 customer accounts.

**2. Create your real super-root account and disable the seeded one.** Never operate day to day as
`root@rusukh.pk`.

> **Enrol two-factor before you disable the seeded root, not after.** `super_admin`, `management`
> and `finance` are guarded by `EnsureTwoFactor` on **every** `/admin` request, so your new
> super-root account will be redirected to `/admin/identity/security/two-factor` and will stay there
> until an authenticator app is enrolled. Do that while you still have a working account to fall back
> on. Enrolment also issues single-use recovery codes — **store them somewhere other than the server
> they unlock**, because losing a phone with no recovery code and no second super-root account means
> nobody can reach the administration screens at all.

**3. Confirm the database user is not a superuser.** The application needs `SELECT/INSERT/UPDATE/
DELETE` and nothing more. The audit-trail immutability triggers exist specifically to stop history
being rewritten — a superuser connection can drop them.

**4. Verify the hardening actually took:**

```bash
# Should NOT show a stack trace
curl -s https://rusukh.pk/no-such-page | grep -i "stack\|vendor/laravel" && echo "DEBUG IS ON — FIX IT"

# Should be 404, not a Telescope dashboard
curl -o /dev/null -w "%{http_code}\n" https://rusukh.pk/telescope
```

---

## 7. Backups

Three things need backing up, and two of them are easy to forget.

```bash
# 1. The database.
pg_dump -Fc rusukh > /backups/rusukh-$(date +%F).dump

# 2. Uploaded files — delivery proof photographs, deposit slips, bundle
#    photographs, QC sign-offs. These are stage-gate EVIDENCE. Losing them
#    loses the proof that a garment was inspected before it shipped.
tar czf /backups/storage-$(date +%F).tgz /var/www/rusukh/storage/app
```

**3. `APP_KEY`, stored somewhere other than the server it came from.** Customer CNIC numbers and
bank account/IBAN details are encrypted by the *application*, not by the database, so a `pg_dump`
of those columns is opaque ciphertext and `APP_KEY` is the only thing that opens it. Restore a
database backup against a new `APP_KEY` and every one of those fields is permanently unreadable —
the rest of the system will come up fine, which is what makes this particular loss quiet. Keep the
key in a password manager or a secrets vault, and never rotate it without re-encrypting first.

Test a restore before you need one — including one of those encrypted fields. A backup nobody has
restored is a hypothesis.

---

## 8. Deploying an update

```bash
cd /var/www/rusukh
php artisan down --render="errors::503"

git pull
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force

php artisan optimize:clear
php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache

php artisan queue:restart          # workers hold old code in memory
php artisan up
```

Take a database dump before any deployment that includes a migration.

---

## 9. Health checks worth monitoring

| Check | Healthy | Investigate when |
|---|---|---|
| `php artisan queue:monitor redis` | Depth near zero | Depth keeps climbing — worker is dead or a job is failing repeatedly |
| `failed_jobs` table row count | 0 | Anything above 0; `php artisan queue:failed` to inspect |
| `/admin/core/queue-monitor` | Workers listed as alive | No worker heartbeat |
| Disk usage on `storage/` | Steady growth | Sudden jumps — usually log level left at `debug` |
| Certificate expiry | > 14 days | Certbot renewal has stopped |
