# Deploying to cPanel

Written for a host with **no SSH access**, where every step happens in the
cPanel web interface. Everything here is reversible and nothing deletes.

The general cPanel notes in the root [README](../../README.md#cpanel-or-any-apache-host)
assume two document roots — the SPA on the apex, the API on `api.<domain>`.
That is the better arrangement when you own a domain. It is impossible on a
cPanel *temporary* hostname (`*.cpanel.site`), which is a single name with no
way to hang a subdomain off it, so this deployment shares one document root
instead.

That is not a downgrade. One origin means the Sanctum session cookie is
first-party, so the cross-site cookie problem the root README devotes a
section to never arises, and CORS is never exercised.

## Layout

```
/home/USER/
├── <DOCROOT>/              ← document root, served over HTTP
│   ├── index.html          the SPA
│   ├── assets/             content-hashed JS and CSS
│   ├── .htaccess           filesystem → /api,/sanctum,/up → SPA fallback
│   └── laravel.php         front controller for the API
└── domainsansar/
    └── backend/            ← the application, NOT web-reachable
        ├── .env            secrets live here, which is why it is up here
        ├── vendor/
        └── ...
```

`<DOCROOT>` is **not always `public_html`.** For the account's main domain it
usually is; for a cPanel temporary hostname it is frequently a directory named
after the hostname itself, e.g.
`/home/onlinebl/courteous-mauve-horse.138-199-140-147.cpanel.site`. Check
cPanel → **Domains** and read the Document Root column before uploading
anything. Uploading the SPA to the wrong directory is the failure that looks
like a successful deploy serving the host's placeholder page.

`domainsansar/` must be a **sibling** of the document root, not inside it. Inside,
`.env` becomes fetchable over HTTP and every secret in it is public.

`laravel.php` resolves the application as `__DIR__/../domainsansar/backend`, which
holds for any document root sitting directly under the home directory. If yours
is nested deeper (`public_html/sub/`), adjust the `$base` line in it to match.

## Build the bundles

```bash
deploy/cpanel/build-bundle.sh
```

Produces in `deploy/cpanel/dist/`:

| File | Goes to | What it is |
|---|---|---|
| `public_html.zip` | `~/<DOCROOT>` | SPA + `.htaccess` + `laravel.php` |
| `domainsansar.zip` | `~/` (creates `domainsansar/`) | The application with `vendor/` |
| `schema-mysql.sql` | — | Fallback only; see step 6 |

The `.htaccess` inside `public_html.zip` is generated from
`web/public/.htaccess` with the API rule rewritten for the shared document
root, prefixed with the host's own directives from `htaccess-host-prelude`.

Rebuild whenever the frontend changes **or the hostname changes** — the API
origin is compiled into the bundle, not read at runtime.

## Steps

### 1. Check PHP

**MultiPHP Manager** → the domain must be on **PHP 8.3 or newer**.
`composer.json` requires `^8.3` and the bundle's `vendor/` was resolved against
8.3; on 8.2 or below it will not run.

On this account it is already correct: the document root's `.htaccess` sets
`AddHandler application/x-httpd-ea-php83`, so the hostname runs ea-php83.

**Select PHP Version → Extensions** → confirm `pdo_mysql`, `mbstring`, `intl`,
`openssl`, `tokenizer`, `ctype`, `fileinfo`, `curl`, `dom`, `xml`, `zip`.

Nothing needs `redis`, `gd` or `imagick`. The 2FA QR code renders as SVG.

If the account shows `lscache`/`lscmData` directories, the web server is
LiteSpeed rather than Apache. It reads `.htaccess` with Apache-compatible
syntax, so the rewrite, caching and compression blocks work unchanged; the
`<IfModule>` guards resolve against the modules it emulates.

### 2. Create the database

**MySQL Databases** → create a database, create a user, then **add the user to
the database with ALL PRIVILEGES**. That last part is a separate form and is
the step most often missed; without it every request fails on connect.

cPanel prefixes both names with the account, e.g. `onlinebl_domainsansar`. Note
the prefixed names — they go in `.env` verbatim.

### 3. Upload

**File Manager**. Upload the zips and use **Extract** — do not upload the files
individually. `vendor/` alone is ~11,000 files and extracting server-side takes
seconds where a file-by-file upload takes hours.

- `public_html.zip` → upload into `<DOCROOT>/`, extract there, then delete the
  zip.

  **The bundled `.htaccess` already carries the host's directives.**
  A provisioned hostname has its own `.htaccess` holding cPanel-generated PHP
  handler and INI blocks, and Extract replaces the file without asking. Losing
  the `AddHandler` line is worse than a broken deploy: `.php` stops being
  executed, so `laravel.php` is served to the browser as source text.

  Those blocks are captured in
  [`htaccess-host-prelude`](htaccess-host-prelude) and prepended at build time,
  so the bundled file is safe to extract over the existing one as-is.

  That capture goes stale if cPanel rewrites its own blocks — a MultiPHP
  version switch or an INI edit does exactly that. **Before each deploy**, open
  the live `.htaccess` and compare it against the prelude. If they differ, the
  live copy wins: update the prelude and rebuild.

  `.well-known/` and `cgi-bin/` do not collide with anything in the bundle.
  Leave `.well-known/` alone in particular — AutoSSL renews the certificate
  through it, and the session cookie is `Secure`, so a lapsed certificate means
  nobody can log in.
- `domainsansar.zip` → upload into the **home directory** (one level above
  `<DOCROOT>`), extract there. It creates `domainsansar/backend/`.

Do not put `domainsansar.zip` in the document root, not even temporarily: while it
sits there its `.env.example.cpanel` and, after extraction, `.env` are both
fetchable over HTTP.

### 4. Create `.env`

In `domainsansar/backend/`, rename `.env.example.cpanel` to `.env` (File Manager →
Rename), then edit it and fill the four blanks:

- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` from step 2
- `MAIL_USERNAME` / `MAIL_PASSWORD` / `MAIL_FROM_ADDRESS` from a mailbox you
  create in **Email Accounts**

Leave `APP_KEY` empty; step 5 generates it.

Every other value is already set for this host and explained inline in the
file. Read the comments before changing any of them — particularly `APP_ENV`,
which is `production` for a specific reason to do with HTTPS link generation.

### 5. Run the one-shot setup

No SSH means no `artisan` prompt, so use cron as a one-shot runner.
**Cron Jobs** → add, set every field to `*` so it fires within the minute:

```
* * * * * cd /home/USER/domainsansar/backend && php artisan key:generate --force && php artisan migrate --force && php artisan db:seed --force && php artisan config:cache && php artisan route:cache >> /home/USER/setup.log 2>&1
```

Replace `USER` with your cPanel account name. Wait two minutes, then open
`setup.log` in File Manager.

**Then delete this cron job.** Left in place it re-runs every minute forever.
It is individually harmless — `key:generate --force` would rotate `APP_KEY` and
invalidate every session, which is not harmless.

Expect to see the 18 migrations run, then the four reference seeders (TLDs,
categories, plans, fees). If `php` is not found, use the absolute path. The handler for this hostname is
ea-php83, so that is `/opt/cpanel/ea-php83/root/usr/bin/php` (some hosts also
symlink it as `/usr/local/bin/ea-php83`).

### 6. If cron is unavailable

`schema-mysql.sql` is the fallback: **phpMyAdmin** → select the database →
**Import**. It contains the schema only, rendered by `php artisan
schema:compile`, with no reference data — so the site will run but categories,
TLDs, plans and the fee schedule will all be empty, and the fee schedule being
empty means nothing can be priced. Prefer step 5. `APP_KEY` still has to come
from somewhere: generate one locally with `php artisan key:generate --show` and
paste it in.

### 7. The permanent cron jobs

Queued work is not optional here. Login risk assessment, the suspicious-login
email and the retention sweeps all run on the queue; with no worker they
accumulate in the `jobs` table and never happen.

```
* * * * * cd /home/USER/domainsansar/backend && php artisan queue:work --stop-when-empty --max-time=55 >> /dev/null 2>&1
* * * * * cd /home/USER/domainsansar/backend && php artisan schedule:run >> /dev/null 2>&1
```

`--stop-when-empty` with `--max-time=55` keeps each run inside its own minute.
A plain `queue:work` daemon is what shared hosting kills, silently, leaving the
queue stalled with no error anywhere.

On a shared account hosting other sites, these flags carry more weight than
usual: the cron runs as the account user and draws on the same process and
memory allowance as every other site on it. A daemon left running does not just
risk being killed — it competes with the neighbours.

### 8. Verify

In a browser, in this order — each failure has a different cause:

| URL | Expected | If not |
|---|---|---|
| `/up` | `200`, health page | `.htaccess` or `laravel.php` path is wrong |
| `/api/v1/tlds` | JSON list of TLDs | empty list ⇒ seeders did not run |
| `/api/v1/plans` | JSON list of plans | as above |
| `/` | the landing page | blank ⇒ SPA assets did not extract |
| `/pricing` then reload | still the pricing page | 404 on reload ⇒ SPA fallback rule not active |

Then register an account and confirm the verification email arrives. That
exercises the database, the queue worker and SMTP in one action, which is why
it is the single most useful test here.

While you are looking at `/api/v1/domains?q=coffee`, check the scheme in the
`links` it returns. Those are built from the incoming request, not from
`APP_URL`, so they read `http://` if the TLS-terminating proxy in front of
Apache forwards plain HTTP. The application does not trust `X-Forwarded-Proto`
— no trusted proxies are configured — so it cannot correct this by itself.
Mixed-content `http://` links on an https page are the visible symptom. The fix
is `trustProxies()` in `bootstrap/app.php`, and it is worth confirming before
you need it rather than after.

If any API URL returns **HTML instead of JSON**, do not debug the application
first — see the note on the protection layer below.

## Two things that will surprise you

### The marketplace will be empty

There are no listing-creation endpoints yet, so the browse surface is read-only
over whatever is in the database, and nobody can add inventory through the UI.
With reference data only, search returns nothing and every category shows zero.
The landing page, FAQ, pricing, registration, login, 2FA, profile and seller
settings all work fully.

`DemoListingSeeder` exists and fabricates a populated marketplace, but it
**refuses to run when `APP_ENV=production`** — deliberately, and its own
docblock gives the reason: a live site showing invented prices against domain
names nobody owns is worse than an empty one.

To populate it anyway on a demo host, override the environment for that one
command only, so the serving application stays in production mode:

```
* * * * * cd /home/USER/domainsansar/backend && APP_ENV=staging php artisan db:seed --class=DemoListingSeeder --force >> /home/USER/seed.log 2>&1
```

Delete the cron once it has run. Understand what you are choosing: the site
becomes a convincing marketplace of listings that do not exist. Fine for an
internal demo on a temporary URL, not fine for anything a real buyer reaches.

### A protection layer sits in front of this server

Requests from a non-browser client are answered with an HTTP **200** and a
"One moment, please…" holding page that reloads itself. It applies to the
website, to `:2083`, and while an address is held, SSH and FTP are dropped
too — which is why this guide assumes no shell.

For the SPA this is invisible: real browsers run the script and are let
through. It matters in two places.

**Diagnosis.** An API path returning `200` with HTML is exactly the failure the
`.htaccess` comments warn about, and here it has two possible causes — a
misconfigured rewrite, or the protection layer. Check a path that cannot exist,
like `/definitely-not-a-route-xyz`. Holding page ⇒ it is the protection layer,
not your deployment.

**Later phases.** Payment webhooks are inbound non-browser requests and will be
held. Before wiring one up, ask the host to exempt `/api/` or whitelist the
provider's addresses.
