# Deploying FacelessShorts on cPanel + CloudLinux

Production setup for a **cPanel/WHM server running CloudLinux** (tested on CloudLinux 9,
cPanel 136). This is different from the plain-Ubuntu guide in [`DEPLOYMENT.md`](DEPLOYMENT.md):
on cPanel you work **with** the managed stack (Apache/LiteSpeed + cPanel-managed PHP),
not by installing your own nginx + php-fpm.

> The app is not a plain web app: video generation runs as a **background queue job**
> (up to a 30-minute timeout each) and shells out to **ffmpeg/ffprobe**. Without a
> running queue worker **and** ffmpeg installed, videos stay stuck at "queued" or fail
> at the final render step.

The concrete example below uses:
- Domain: `beta.event6.com`
- cPanel account/user: `event6admin`
- App path: `/home/event6admin/public_html/beta.event6.com`
- Database: **managed MySQL over TLS** (DigitalOcean) — `DB_SSL=true`

Adapt those to your own account/domain.

---

## ⚡ Every deploy (run after every `git pull`)

Run these **as `event6admin`** (the cPanel account Terminal, `$` prompt) — **never** as root,
or you'll leave root-owned files that break the app until re-chowned.

```bash
cd ~/public_html/beta.event6.com
alias php=/opt/cpanel/ea-php83/root/usr/bin/php
git pull
php artisan migrate --force        # REQUIRED — new features add DB columns
php artisan optimize:clear
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan queue:restart          # reload the worker with new code
```

> **Never skip `migrate --force`.** The code references new columns as soon as it's pulled,
> so skipping the migration causes errors like `Unknown column 'countdown' in 'field list'`.
> Code and database must be migrated together.

If you hit a stale-view `ParseError` or a `403 / can't read htaccess` after a deploy, it's
almost always **root-owned files** from running artisan as root earlier — fix from the root
WHM Terminal:

```bash
chown -R event6admin:event6admin /home/event6admin/public_html/beta.event6.com
find /home/event6admin/public_html/beta.event6.com -type d -exec chmod 755 {} \;
find /home/event6admin/public_html/beta.event6.com -type f -exec chmod 644 {} \;
chmod -R 775 /home/event6admin/public_html/beta.event6.com/storage \
             /home/event6admin/public_html/beta.event6.com/bootstrap/cache
```

---

## 0. Two terminals — know which one you're in

This is the #1 source of confusion on cPanel. There are **two different shells**, and
commands fail in the wrong one:

| Terminal | Prompt | Runs as | Use for |
|----------|--------|---------|---------|
| **cPanel → Terminal** | `[event6admin@... ]$` | jailed account (CageFS) | git, composer, `php artisan`, app work |
| **WHM → Server Configuration → Terminal** | `[root@... ]#` | root (not jailed) | installing software, `dnf`, `cagefsctl`, systemd |

Rule of thumb:
- **Installing anything / system config** → **root WHM Terminal** (`#`)
- **Running the app** → **account Terminal** (`$`)

`dnf: command not found`, `xz: Cannot exec: Permission denied`, or
`cagefsctl: command not found` almost always means you ran a root command in the jailed
account shell by mistake.

---

## 1. PHP 8.3 (the app requires `^8.3`)

CloudLinux boxes have **two** PHP systems. Only one of them reliably has 8.3 here:

- **cPanel MultiPHP / `ea-php`** (EasyApache) — installs from cPanel's EA4 repo. ✅ Use this.
- **CloudLinux PHP Selector / `alt-php`** — needs a registered CloudLinux license; on an
  unregistered box the CL repos are disabled and it tops out at whatever's installed
  (often 8.2). ❌ Avoid for this app.

If you try the `alt-php` route unregistered you'll see:
`This system is not registered with CloudLinux Network server ... Module or Group 'alt-php83' is not available.`
That's a licensing issue — don't fight it, use `ea-php83` instead.

### Install ea-php83 + extensions (root WHM Terminal)

```bash
dnf clean all && dnf makecache        # avoids stale-cache "Cannot open file" errors
dnf -y install ea-php83-php-cli ea-php83-php-fpm ea-php83-php-mysqlnd \
  ea-php83-php-mbstring ea-php83-php-xml ea-php83-php-curl ea-php83-php-zip \
  ea-php83-php-gd ea-php83-php-bcmath ea-php83-php-intl ea-php83-php-iconv
```

> `ea-php83-php-iconv` is easy to miss — `symfony/string` (a Laravel dependency) requires
> `ext-iconv`, and `composer install` will refuse without it.

Verify the CLI binary exists:

```bash
/opt/cpanel/ea-php83/root/usr/bin/php -v | head -1     # PHP 8.3.x
```

(Equivalent GUI path: **WHM → EasyApache 4 → Customize → PHP Versions → 8.3 →
Extensions → Provision**.)

### Point the website at 8.3

**WHM → MultiPHP Manager** → select `beta.event6.com` → **PHP Version = ea-php83** → Apply.

- Do **not** click "Use PHP Selector" — that moves the domain into the CloudLinux
  Selector (no 8.3). If it's already there, set an explicit `ea-php83` in MultiPHP
  Manager to pull it back.
- Symptom of the web side still on 8.2: the browser shows
  `Composer detected issues in your platform: Your Composer dependencies require a PHP version ">= 8.3.0"`.

The **account CLI** still defaults to the system PHP (often 8.2), so alias it for app work:

```bash
alias php=/opt/cpanel/ea-php83/root/usr/bin/php
php -v | head -1
```

---

## 2. Get the code

Generate a passwordless deploy key (account Terminal) and add it to GitHub:

```bash
ssh-keygen -t ed25519 -C "event6admin@beta.event6.com" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub    # add as a Deploy Key at github.com/<org>/<repo>/settings/keys
ssh -T git@github.com        # expect: "Hi <repo>! You've successfully authenticated"
```

Clone into the account (the domain's docroot points *inside* this — see §5):

```bash
cd ~/public_html
git clone git@github.com:<org>/<repo>.git beta.event6.com
cd beta.event6.com
```

---

## 3. Composer + dependencies

The jailed PHP usually has `allow_url_fopen` **disabled**, so the PHP `copy()` installer
fails silently. Download the phar with `curl` instead (account Terminal):

```bash
curl -sSL https://getcomposer.org/composer-stable.phar -o composer.phar
php composer.phar --version
php composer.phar install --no-dev --optimize-autoloader
```

If `curl` is blocked in the jail too, fetch it from the **root** terminal and `chown` it:

```bash
# root:
curl -sSL https://getcomposer.org/composer-stable.phar -o /home/event6admin/public_html/beta.event6.com/composer.phar
chown event6admin:event6admin /home/event6admin/public_html/beta.event6.com/composer.phar
```

If `composer install` complains about a missing `ext-*`, install
`ea-php83-php-<name>` as root (see §1) and re-run. Do **not** use
`--ignore-platform-req` — those extensions are needed at runtime.

---

## 4. Configure `.env`

```bash
cp .env.example .env        # or upload your production .env
php artisan key:generate    # only if APP_KEY is blank
```

Minimum production values:

```dotenv
APP_NAME=FacelessShorts
APP_ENV=production
APP_DEBUG=false
APP_URL=https://beta.event6.com

# Managed database over TLS
DB_CONNECTION=mysql
DB_HOST=your-db-host.ondigitalocean.com
DB_PORT=25060
DB_DATABASE=your_db
DB_USERNAME=your_user
DB_PASSWORD=your_password
DB_SSL=true

QUEUE_CONNECTION=database
SESSION_DRIVER=database
CACHE_STORE=database

OPENAI_API_KEY=sk-...
ELEVENLABS_API_KEY=...

# MUST be empty on Linux — see the ffmpeg note in §6
FFMPEG_DIR=
```

> **Do not copy a Windows dev `.env`.** A Windows `FFMPEG_DIR` (e.g.
> `C:\Users\...\ffmpeg\bin`) gets mangled on Linux into a bogus project-relative path and
> the render fails with `ffmpeg/ffprobe failed`. Leave `FFMPEG_DIR` empty (see §6).

Also make sure the **managed DB firewall allows this server's IP** (the DigitalOcean DB
"Trusted Sources" list), or `migrate` will hang / refuse the connection.

Then:

```bash
php artisan migrate --force
php artisan storage:link
php artisan config:cache
```

---

## 5. Document root → `public/`

Laravel must only expose `public/`. If the domain's docroot points at the project root,
the whole app (including `.env`) is downloadable — a serious leak, and you'll see a bare
`Index of /` directory listing instead of the app.

**cPanel → Domains** → `beta.event6.com` → **Manage** → set **Document Root** to:

```
/home/event6admin/public_html/beta.event6.com/public
```

Verify secrets aren't reachable:

```bash
curl -sI https://beta.event6.com/.env | head -1        # must be 403/404, NOT 200
```

Permissions (account Terminal):

```bash
mkdir -p storage/framework/{cache,sessions,views} storage/logs bootstrap/cache
chmod -R 775 storage bootstrap/cache
```

---

## 6. ffmpeg / ffprobe (required for rendering)

ffmpeg is **not** in cPanel's repos, so install a self-contained **static build**.

Install to **two** locations: `/usr/local/bin` (for the systemd worker, which runs
*outside* CageFS) **and** the account's `~/bin` (guaranteed visible *inside* the CageFS
jail — CageFS virtualizes `/usr/local/bin`, so a copy there alone may not appear in the
jailed shell). Extraction must be done as root (the jail can't exec `xz`).

**Root WHM Terminal:**

```bash
cd /tmp
curl -sSL https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz
ls -la ffmpeg.tar.xz          # sanity: ~40 MB, not a tiny error page
tar xf ffmpeg.tar.xz

# 1) system location — for the systemd worker (outside CageFS)
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe

# 2) account home bin — guaranteed visible inside the CageFS jail
mkdir -p /home/event6admin/bin
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /home/event6admin/bin/
chown -R event6admin:event6admin /home/event6admin/bin
chmod +x /home/event6admin/bin/ffmpeg /home/event6admin/bin/ffprobe

rm -rf ffmpeg.tar.xz ffmpeg-*-static
ffmpeg -version | head -1
cagefsctl --force-update       # refresh the CageFS jail
```

`~/bin` is first on the jailed account's PATH. Verify (account Terminal):

```bash
which ffmpeg ffprobe           # /home/event6admin/bin/ffmpeg  /home/event6admin/bin/ffprobe
```

> **`FFMPEG_DIR` must stay empty on Linux.** `App\Services\VideoComposer::bin()` appends a
> hardcoded `.exe` when `FFMPEG_DIR` is set, so any non-empty value breaks on Linux. Empty
> = call `ffmpeg`/`ffprobe` from PATH, which is what we want.

If `cagefsctl --force-update` isn't enough, try `cagefsctl --remount-all`.

---

## 7. Queue worker (CRITICAL — videos won't render without it)

Video jobs are dispatched to the queue; a persistent worker must process them. On
CloudLinux, a **root-managed systemd service** is the most reliable option (systemd is
already present, unlike Supervisor, and it survives CageFS process reaping).

**Root WHM Terminal:**

```bash
cat > /etc/systemd/system/faceless-worker.service <<'EOF'
[Unit]
Description=FacelessShorts queue worker (beta.event6.com)
After=network.target

[Service]
Type=simple
User=event6admin
Group=event6admin
WorkingDirectory=/home/event6admin/public_html/beta.event6.com
ExecStart=/opt/cpanel/ea-php83/root/usr/bin/php /home/event6admin/public_html/beta.event6.com/artisan queue:work --queue=default --sleep=3 --tries=1 --timeout=1810
Restart=always
RestartSec=5
StartLimitIntervalSec=0
TimeoutStopSec=1830

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now faceless-worker
systemctl status faceless-worker --no-pager
journalctl -u faceless-worker -f      # live log
```

- `--timeout=1810` must exceed the job's own 1800s (`$timeout`) limit.
- `--tries=1` matches the job (rendering is expensive; do not auto-retry).

### Alternative: per-minute cron (no root)

cPanel → **Cron Jobs**, every minute, guarded by `flock` so a long job isn't doubled:

```
* * * * * /usr/local/bin/flock -n /home/event6admin/.faceless-worker.lock /opt/cpanel/ea-php83/root/usr/bin/php /home/event6admin/public_html/beta.event6.com/artisan queue:work --stop-when-empty --sleep=3 --tries=1 --timeout=1810 >> /home/event6admin/public_html/beta.event6.com/storage/logs/worker.log 2>&1
```

Downsides vs systemd: up to ~60s start latency, and CloudLinux can reap long-lived user
processes. Prefer systemd when you have root.

---

## 8. Scheduler (series automation — CRITICAL for auto-posting)

Series generate + auto-publish on a cadence via Laravel's scheduler, which only fires if
the **system cron runs `schedule:run` every minute**. Without this, series sit idle.

cPanel → **Cron Jobs** → Add New Cron Job → **every minute** (`* * * * *`):

```
/opt/cpanel/ea-php83/root/usr/bin/php /home/event6admin/public_html/beta.event6.com/artisan schedule:run >> /dev/null 2>&1
```

This is separate from the queue worker (§7): the cron *triggers due series each minute*;
the worker does the actual rendering + posting. Both are required for full automation.

Verify the schedule is registered (account Terminal):

```bash
php artisan schedule:list      # should list: * * * * * php artisan series:run
```

---

## 9. Social publishing setup (YouTube / Instagram / TikTok)

Posting needs **one registered developer app per platform** (for the client keys) plus the
per-account OAuth grant done in-app. Register each app with these **exact redirect URIs**:

| Platform | Console | Redirect URI | `.env` keys |
|----------|---------|--------------|-------------|
| YouTube | [Google Cloud](https://console.cloud.google.com) → enable *YouTube Data API v3* → OAuth client (Web) | `https://beta.event6.com/oauth/youtube/callback` | `YOUTUBE_CLIENT_ID`, `YOUTUBE_CLIENT_SECRET` |
| Instagram | [Meta for Developers](https://developers.facebook.com) → *Instagram Graph API* + *Facebook Login* | `https://beta.event6.com/oauth/instagram/callback` | `META_CLIENT_ID`, `META_CLIENT_SECRET` |
| TikTok | [TikTok for Developers](https://developers.tiktok.com) → *Content Posting API* | `https://beta.event6.com/oauth/tiktok/callback` | `TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET` |

Put the keys in `.env` (see `.env.example` for all names), then `php artisan config:cache`.

Connect flow in the app: **Channels → create a channel → Connect YouTube/Instagram/TikTok**
→ approve on the platform → the account shows "connected". Tokens are stored **encrypted**.

Platform gotchas:
- **Instagram** needs an IG **Business/Creator** account linked to a **Facebook Page**, and
  Meta **App Review** (`instagram_content_publish`) + business verification before it posts
  for anyone but app testers. IG pulls the video from the public URL
  `https://beta.event6.com/videos/{id}/file`.
- **TikTok** requires app **audit** before public posting (unaudited apps are limited to
  private/SELF_ONLY), and the domain must be **URL-ownership-verified** for pull-from-URL.
- **YouTube** works fastest — for your own channel you can publish as soon as the keys are
  in. Sensitive-scope apps benefit from Google verification to drop the "unverified" screen.

---

## 10. Deploying updates

```bash
cd ~/public_html/beta.event6.com
alias php=/opt/cpanel/ea-php83/root/usr/bin/php

git pull
php composer.phar install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan queue:restart          # reload the worker with new code
```

(`queue:restart` makes the worker exit gracefully; systemd's `Restart=always` brings it
back on the new code. Or, as root: `systemctl restart faceless-worker`.)

---

## Troubleshooting

- **`Index of /` directory listing / `.env` downloadable** → docroot points at the project
  root, not `public/`. Fix §5 immediately and rotate any exposed keys.
- **Browser: "require a PHP version >= 8.3.0"** → the *web* side is still on 8.2. Set
  `ea-php83` in **MultiPHP Manager** (§1).
- **`dnf` / `cagefsctl` / `xz` "not found" or "Permission denied"** → you're in the jailed
  account shell; run it in the **root WHM Terminal** (§0).
- **`alt-php83 is not available`** → CloudLinux isn't registered; use `ea-php83` (§1).
- **`composer install` fails on `ext-iconv`** (or another ext) → install
  `ea-php83-php-iconv` as root and re-run (§1/§3).
- **Composer installer downloads nothing** → jailed PHP has `allow_url_fopen` off; use the
  `curl` download (§3).
- **Videos stuck at "queued"** → the worker isn't running. `systemctl status faceless-worker`
  and `journalctl -u faceless-worker`.
- **`ffmpeg/ffprobe failed`** → ffmpeg not installed (§6) **or** `FFMPEG_DIR` is non-empty
  (it must be empty on Linux).
- **DB connection hangs/refused** → add this server's IP to the managed DB's trusted
  sources, and confirm `DB_SSL=true`.
- **Series never generate on schedule** → the `schedule:run` cron (§8) isn't set. Confirm
  with `php artisan schedule:list`; test a series with its **Run now** button.
- **OAuth callback fails / "state mismatch"** → the redirect URI registered on the platform
  doesn't exactly match `https://beta.event6.com/oauth/{platform}/callback` (§9), or session
  cookies aren't persisting (check `APP_URL` + HTTPS).
- **Publish fails on Instagram/TikTok** → app review/audit not passed, or (TikTok) the domain
  isn't URL-verified for pull-from-URL (§9). YouTube should work once its keys are set.
