# Deployment

For a standard Linux VPS (Forge/Ploi-style or hand-managed) running nginx + PHP-FPM. Everything environment-specific lives in `.env`.

## 1. Server requirements

- **PHP 8.4** (the Composer platform is pinned to 8.4.21) with `bcmath`, `ctype`, `curl`, `exif`, `fileinfo`, `gd` (WebP; AVIF if available), `intl`, `mbstring`, `openssl`, `pdo_mysql`, `sodium`, `tokenizer`, `xml`, `zip`
- **MySQL 8.0+** (or MariaDB 10.11+)
- **Redis** (recommended: the content cache uses tags). The `predis` client is bundled; `phpredis` is optional
- **Node 20+** (build step only)
- **`mysqldump`** (MySQL client tools) for the nightly backup
- HTTPS (Let's Encrypt or similar)

## 2. First deploy

```bash
git clone <repo> /var/www/portfolio && cd /var/www/portfolio
composer install --no-dev --optimize-autoloader
cp .env.example .env            # fill in every value (see §3)
php artisan key:generate
php artisan migrate --force
php artisan db:seed --force      # EssentialSeeder only: safe and idempotent
php artisan storage:link
npm ci && npm run build
php artisan portfolio:create-admin   # interactive; no default credentials exist
php artisan optimize && php artisan filament:optimize && php artisan icons:cache
```

**Never run `DemoContentSeeder` in production.** It refuses to, but don't try. `php artisan portfolio:purge-demo` removes demo content from staging.

## 3. Environment (`.env`)

| Key | Production value |
|---|---|
| `APP_ENV` | `production` (enables indexing, HSTS, strict password rules) |
| `APP_DEBUG` | `false` (never show stack traces) |
| `APP_URL` | `https://your-domain` |
| `ADMIN_PATH` | A non-obvious path, e.g. `studio-k7q2`. It is never linked publicly |
| `DB_*` | A dedicated database user with rights on this schema only |
| `CACHE_STORE` | `redis` |
| `QUEUE_CONNECTION` | `redis` or `database` (not `sync`) |
| `SESSION_SECURE_COOKIE` | `true` |
| `MAIL_*` | Your SMTP/transactional mail provider |
| `MEDIA_DISK` | `public`, or `s3` with the `AWS_*` keys for S3-compatible storage |
| `PRIVATE_DISK_DRIVER` | `local` (the CV lives in `storage/app/private/documents`) |
| `TURNSTILE_*` / `RECAPTCHA_*` | Only if a CAPTCHA is enabled in Admin › Security |
| `GA4_API_SECRET` | Only if GA4 server events are used |
| `LOG_STACK` / `LOG_LEVEL` / `LOG_DAILY_DAYS` | `daily` / `warning` / `14`: one file per day, only problems, two weeks kept |
| `ALERT_EMAIL` | Where errors, failed background jobs and backup problems are emailed (ADR-048). Leave empty only if you monitor logs another way |
| `BACKUP_DISKS` | `backups,s3`: on the server **and** off it. `backups` alone is not a backup if the server is lost |
| `BACKUP_ARCHIVE_PASSWORD` | A long random password: archives are encrypted. Keep it in your password manager with `APP_KEY` |
| `DB_DUMP_EXTRA_OPTIONS` | Default `--set-gtid-purged=OFF --no-tablespaces` (no global privileges needed). On MariaDB set it to `--no-tablespaces` |

## 4. Queue worker (Supervisor)

Mail notifications, image conversions, sitemap regeneration and server-side analytics are queued.

```ini
[program:portfolio-worker]
command=php /var/www/portfolio/artisan queue:work --sleep=3 --tries=3 --max-time=3600
user=www-data
numprocs=1
autostart=true
autorestart=true
stopwaitsecs=3600
redirect_stderr=true
stdout_logfile=/var/www/portfolio/storage/logs/worker.log
```

After every deploy run `php artisan queue:restart`.

## 5. Scheduler (cron)

```cron
* * * * * www-data cd /var/www/portfolio && php artisan schedule:run >> /dev/null 2>&1
```

| Task | When |
|---|---|
| `portfolio:publish-scheduled` (flips due scheduled content, refreshes caches and the sitemap) | Every minute |
| `model:prune` (contact messages past the retention period, old analytics events) | Daily |
| Sitemap regeneration | Daily 03:15 |

## 6. nginx essentials

```nginx
server {
    listen 443 ssl http2;
    server_name your-domain;
    root /var/www/portfolio/public;
    index index.php;

    gzip on; gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;
    location /build/ { expires 1y; add_header Cache-Control "public, immutable"; }

    location / { try_files $uri $uri/ /index.php?$query_string; }
    location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.4-fpm.sock; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; }
    location ~ /\.(?!well-known) { deny all; }
}
```

- **HTTPS/HSTS:** the app sends `Strict-Transport-Security` in production over HTTPS. Redirect port 80 to 443 at nginx.
- **Security headers and CSP** are sent by the application (`SecurityHeaders` middleware). Don't add a second CSP at nginx or a CDN: it would conflict with the per-request nonce.
- **Behind a proxy or CDN:** configure trusted proxies (`$middleware->trustProxies(...)` in `bootstrap/app.php`) so HTTPS detection and rate limiting see the real client.

## 7. CSP and third parties

The public CSP is built per request. Integrations add only their own origins, and only when enabled:

| Integration | Origins added |
|---|---|
| Booking (Calendly) | `frame-src https://calendly.com` |
| Booking (Cal.com) | `frame-src https://cal.com https://app.cal.com` |
| Plausible / Umami / GA4 | The provider's script origin (`script-src`, `connect-src`), plus Google Analytics collect endpoints for GA4 |
| Turnstile / reCAPTCHA | The CAPTCHA script/frame origins |
| Video blocks | YouTube (nocookie), Vimeo, Loom frames (always) |

The admin panel uses its own, more permissive policy (Filament requirement, ADR-015).

## 8. Every subsequent deploy

```bash
php artisan down --render="errors::503"
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
npm ci && npm run build
php artisan optimize && php artisan filament:optimize && php artisan icons:cache
php artisan queue:restart
php artisan up
```

For **zero-downtime**, use release directories (Envoyer/Deployer/Forge "zero downtime"): build into a new release, symlink `storage/` and `.env`, run migrations (all are additive or have a matching `down()`), switch the `current` symlink, then reload PHP-FPM and restart queues.

## 9. Backups, alerts and monitoring

**Backups are automated** (spatie/laravel-backup, ADR-048), via the scheduler cron in §5:

| When | Command | What it does |
|---|---|---|
| 01:00 | `backup:clean` | Keeps all backups for 7 days, then daily for 16 days, weekly for 8 weeks, monthly for 4 months, yearly for 2 years |
| 01:30 | `backup:run` | Database dump (consistent snapshot, no locking) plus `storage/app/public` (images) and `storage/app/private/documents` (CV versions), as one encrypted zip |
| 07:00 | `backup:monitor` | Emails `ALERT_EMAIL` if the newest backup is over a day old or storage is too large |

Code is not backed up; it's redeployed from Git. Archives go to every disk in `BACKUP_DISKS`. Add an off-server disk (`s3`, with the `AWS_*` keys) in production. Run one by hand with `php artisan backup:run`, and list them with `php artisan backup:list`.

**Restore** (verified on 2026-10-05: every table's row count matched after restoring into a scratch database):
1. Unzip the archive with `BACKUP_ARCHIVE_PASSWORD`.
2. `mysql -u <user> -p <database> < db-dumps/mysql-<database>.sql`.
3. Copy `public/` back to `storage/app/public/` and `private/documents/` back to `storage/app/private/documents/`.
4. `php artisan optimize:clear`, then check the site and the admin.

Restores need the same `APP_KEY`, because encrypted MFA secrets and contact IP hashes depend on it. Keep it, and `BACKUP_ARCHIVE_PASSWORD`, in your password manager.

**Alerts:** with `ALERT_EMAIL` set, the operator is emailed about:
- unhandled errors (not 404s or validation errors);
- failed background jobs (retry with `php artisan queue:retry all` once fixed);
- failed or unhealthy backups.

Each distinct problem is emailed at most once per `ALERT_THROTTLE_MINUTES` (default 60).

**Uptime:** point an external monitor (UptimeRobot, Better Stack or similar; the free tiers are enough) at `https://your-domain/up` every 5 minutes. It returns 200 only when the app boots. A monitor has to run off-server: if the server is down, nothing on it can alert you.

**Housekeeping (scheduled):**
- the activity log is trimmed to `ACTIVITY_LOG_RETENTION_DAYS` (365);
- failed jobs are pruned after 30 days;
- analytics events and contact messages are pruned by their own retention settings.

## 10. After go-live

1. Sign in at `/{ADMIN_PATH}` and set up the authenticator app (required for Super Admins).
2. Replace all `[placeholder]` content (README › Owner guide).
3. Upload the CV (CV & Documents), configure Booking and Analytics, and submit the sitemap (`/sitemap.xml`) to Google Search Console.
4. Re-run Lighthouse on the live URLs (07-QUALITY §3) and check events arrive in the analytics provider.

## 11. CI/CD (GitHub Actions)

Repository: `git@github.com:fidelcom/gf-portfolio.git` · Workflows: `.github/workflows/ci.yml`, `.github/workflows/deploy.yml` · Server script: `deploy/remote-deploy.sh` (ADR-045).

### Branches and environments

| Branch | GitHub environment | What happens |
|---|---|---|
| Pull request (any branch) | — | **CI** only |
| `develop` | `development` | CI → build → deploy to the development server |
| `main` | `production` | CI → build → **approval** (environment reviewers) → deploy to production |

Manual deploys: Actions › Deploy › *Run workflow* (choose the environment).

### What CI runs (every PR and before every deploy)
1. Pint, Larastan (level 7), `composer audit`, `npm audit --audit-level=high`, and an analytics catalogue sync check.
2. Pest (343 tests) on MySQL 8.4, after building assets.
3. Playwright + axe (59 tests) on a disposable MySQL e2e database. Traces are uploaded on failure.
4. **Deploy smoke test:** builds two releases like a real deploy and runs `deploy/remote-deploy.sh` on the Linux runner against MySQL (first deploy, then `/up`, homepage and robots.txt served from `current`, then a second deploy and `--rollback`). This caught a symlink bug before any server existed.

### How a deploy works
1. The runner installs production Composer dependencies and builds assets (no Node or Composer needed on the server).
2. `rsync` uploads the release to `<DEPLOY_PATH>/releases/<run>-<sha>/` (never overwriting `.env`).
3. `deploy/remote-deploy.sh` runs, in order:
   - links `shared/.env` and `shared/storage`;
   - runs `storage:link`, `migrate --force`, `db:seed --force` (EssentialSeeder, idempotent), `optimize`, `filament:optimize` and `icons:cache`;
   - switches the `current` symlink atomically;
   - runs `queue:restart` and the optional `RELOAD_COMMAND`;
   - keeps the last 5 releases.
4. The runner calls `APP_URL/up` (up to 60 s). If it isn't healthy, the script **rolls back** to the previous release and the job fails.

### One-time server setup (per environment)
```bash
# as the deploy user on the server
mkdir -p /var/www/portfolio/{releases,shared/storage}
nano /var/www/portfolio/shared/.env          # production values (see §3); never committed
# nginx root → /var/www/portfolio/current/public ; Supervisor/cron use /var/www/portfolio/current/artisan
```
- **Deploy user:** owns `/var/www/portfolio` and can run PHP. If PHP-FPM should be reloaded after each deploy, grant a narrow sudoers rule, e.g. `deploy ALL=NOPASSWD: /bin/systemctl reload php8.4-fpm`, and set `RELOAD_COMMAND` to that command.
- **SSH key:** generate a dedicated deploy key pair (`ssh-keygen -t ed25519 -f deploy_key -N ''`), add `deploy_key.pub` to the server's `~/.ssh/authorized_keys`, and store the private key as a secret.
- **Known hosts:** `ssh-keyscan -p <port> <host>`; store the output as a secret, so host keys are verified.
- After the first deploy, create the admin: `php /var/www/portfolio/current/artisan portfolio:create-admin`.

### GitHub environment configuration
Settings › Environments › `development` / `production`:

| Kind | Name | Example |
|---|---|---|
| Secret | `DEPLOY_HOST` | `203.0.113.10` |
| Secret | `DEPLOY_USER` | `deploy` |
| Secret | `DEPLOY_SSH_KEY` | private key (whole file) |
| Secret | `DEPLOY_KNOWN_HOSTS` | `ssh-keyscan` output |
| Variable | `DEPLOY_PATH` | `/var/www/portfolio` |
| Variable | `APP_URL` | `https://dev.example.com` / `https://example.com` |
| Variable | `DEPLOY_PORT` (optional) | `22` |
| Variable | `RELOAD_COMMAND` (optional) | `sudo /bin/systemctl reload php8.4-fpm` |

**Until `DEPLOY_HOST`, `DEPLOY_USER` and `DEPLOY_PATH` are set, the deploy job skips with a notice; CI still runs.**

**Configured on GitHub (2026-10-03):** environments `development` (deployments only from `develop`) and `production` (only from `main`).

**Not available on the current plan** (private repository on GitHub Free): required reviewers on `production` and branch protection on `main`. GitHub returns "upgrade to GitHub Pro or make this repository public". Until then, a push or merge to `main` deploys to production once CI passes. To add a human approval step, either:
- upgrade to GitHub Pro/Team and add yourself as a required reviewer on `production` (Settings › Environments) and protect `main`; or
- keep the current plan and deploy production only by hand: remove `main` from the `push` trigger in `deploy.yml` and use *Run workflow* › production.

### Rollback by hand
```bash
bash /var/www/portfolio/current/deploy/remote-deploy.sh /var/www/portfolio --rollback
```

## 12. cPanel hosting behind Cloudflare (development: `dev.godsfavourokpara.com`)

This is the setup for the cPanel account `favokpa` on `208.109.232.171`. DNS for `godsfavourokpara.com` is on **Cloudflare** (registrar Namecheap), not on the cPanel nameservers.

**One-time prerequisites (owner):**
1. **SSH shell access** for the cPanel user. In WHM: *Account Functions › Manage Shell Access* › `favokpa` › **Jailed Shell**. Or ask the host. Without it, nothing can be deployed (the pipeline uploads over SSH and runs migrations on the server).
2. **DNS record in Cloudflare:** `A dev → 208.109.232.171`.
   - Start it as **DNS only** (grey cloud), so cPanel AutoSSL can issue a certificate.
   - Then switch it to **Proxied** and set SSL/TLS to **Full (strict)**.

**Server layout (created over SSH with cPanel's `uapi`):**
- Base path: `~/dev.godsfavourokpara.com/`, containing `releases/`, `shared/.env`, `shared/storage/` and the `current` symlink.
- **Document root.** On this server cPanel places subdomain document roots under `public_html`, so the subdomain's root is `public_html/dev.godsfavourokpara.com/current/public`. `public_html/dev.godsfavourokpara.com` is a **symlink** to the deploy base (`~/dev.godsfavourokpara.com`), so that path always resolves to the live release. Apache allows it because the owner matches (`SymLinksIfOwnerMatch`).
- **PHP handler.** cPanel's MultiPHP handler block (`AddHandler application/x-httpd-ea-php84 …`) is kept in `shared/public.htaccess`, and `remote-deploy.sh` prepends it to each release's `public/.htaccess`. Without it, the domain's default PHP (8.3) would run the site. If you change the PHP version in MultiPHP Manager, update that file too.
- PHP 8.4 for the subdomain (MultiPHP, `ea-php84`). The CLI binary is `/opt/cpanel/ea-php84/root/usr/bin/php` → GitHub variable `PHP_BIN`.
- A database and user prefixed `favokpa_` with rights on that database only.
- **No Supervisor:**
  - set `QUEUE_WORKER_VIA_SCHEDULER=true`;
  - add one cron job every minute: `$PHP_BIN ~/dev.godsfavourokpara.com/current/artisan schedule:run`;
  - the scheduler runs the publish job, housekeeping, backups and a short-lived queue worker.
- **No Redis on shared hosting:** `CACHE_STORE=database` (the content cache falls back to versioned keys, ADR-033), `QUEUE_CONNECTION=database`, `SESSION_DRIVER=database`.
- **Behind Cloudflare:** `TRUSTED_PROXIES=cloudflare`, so client IPs (rate limits, spam checks) and HTTPS detection are correct.

**GitHub `development` environment:**
- `DEPLOY_HOST=208.109.232.171`, `DEPLOY_USER=favokpa`;
- `DEPLOY_SSH_KEY` and `DEPLOY_KNOWN_HOSTS` (secrets);
- `DEPLOY_PATH=/home/favokpa/dev.godsfavourokpara.com`;
- `APP_URL=https://dev.godsfavourokpara.com`;
- `PHP_BIN=/opt/cpanel/ea-php84/root/usr/bin/php` (variables);
- `HEALTH_CHECK_VIA_ORIGIN=true` (variable). Cloudflare's bot protection answers GitHub runner IPs with 403, so the post-deploy check connects to the server directly. It uses the same hostname and validates the real certificate, so it still proves the release works.

