# Browsershot (Puppeteer + Chrome) Setup Guide

> **Target:** Ubuntu 22.04 / 24.04 servers with aaPanel  
> **Stack:** Laravel + Spatie Browsershot + Puppeteer + Chrome Headless Shell  
> **Last Updated:** May 2026

---

## Table of Contents

1. [Overview](#overview)
2. [Prerequisites](#prerequisites)
3. [Step 1: Install Node.js via aaPanel](#step-1-install-nodejs-via-aapanel)
4. [Step 2: Install Chrome System Dependencies](#step-2-install-chrome-system-dependencies)
5. [Step 3: Install Puppeteer & Chrome Binary](#step-3-install-puppeteer--chrome-binary)
6. [Step 4: Set Permissions for PHP (www user)](#step-4-set-permissions-for-php-www-user)
7. [Step 5: Configure Laravel Code](#step-5-configure-laravel-code)
8. [Step 6: Verify Everything Works](#step-6-verify-everything-works)
9. [Troubleshooting](#troubleshooting)
10. [Quick Setup Script](#quick-setup-script)

---

## Overview

Browsershot uses **Puppeteer** (a Node.js library) to control a **headless Chrome** browser for rendering HTML to PDF. The chain is:

```
Laravel PHP → Browsershot (PHP) → Node.js → Puppeteer → Chrome Headless Shell → PDF
```

### Key Paths on aaPanel

| Component       | Typical aaPanel Path                                                             |
|-----------------|----------------------------------------------------------------------------------|
| Node.js binary  | `/www/server/nodejs/v{VERSION}/bin/node`                                          |
| NPM binary      | `/www/server/nodejs/v{VERSION}/bin/npm`                                           |
| Chrome binary   | `/root/.cache/puppeteer/chrome-headless-shell/linux-{VERSION}/chrome-headless-shell-linux64/chrome-headless-shell` |
| Project root    | `/www/wwwroot/{your-domain}/`                                                     |
| PHP-FPM user    | `www` (runs as `/home/www/`)                                                      |

> [!CAUTION]
> **The #1 gotcha:** Chrome is installed into the **root** user's cache (`/root/.cache/puppeteer/`), but PHP-FPM runs as the **www** user and looks in `/home/www/.cache/puppeteer/` — which is empty. You MUST either:
> - Set `setChromePath()` explicitly in code, OR
> - Install Chrome as the `www` user

---

## Prerequisites

- Ubuntu 22.04 or 24.04
- aaPanel installed
- Node.js installed via aaPanel (App Store → Node.js)
- Laravel project with `spatie/browsershot` composer package installed

---

## Step 1: Install Node.js via aaPanel

1. Log into aaPanel → **App Store** → Search **Node.js** → Install
2. After installation, verify via SSH:

```bash
which node
which npm
node -v
npm -v
```

**Expected output (example):**
```
/www/server/nodejs/v22.22.2/bin/node
/www/server/nodejs/v22.22.2/bin/npm
v22.22.2
10.9.7
```

> [!IMPORTANT]
> **Write down these paths!** You'll need them for the Laravel code configuration.
> aaPanel does NOT install Node at `/usr/bin/node` — it uses `/www/server/nodejs/v{VERSION}/bin/node`.

---

## Step 2: Install Chrome System Dependencies

Chrome headless requires X11 and other shared libraries even in headless mode. Ubuntu 24.04 renamed several packages with the `t64` suffix.

### Ubuntu 24.04

```bash
sudo apt-get update && sudo apt-get install -y \
  libxfixes3 \
  libatk1.0-0t64 \
  libatk-bridge2.0-0t64 \
  libcups2t64 \
  libdrm2 \
  libxkbcommon0 \
  libxcomposite1 \
  libxdamage1 \
  libxrandr2 \
  libgbm1 \
  libpango-1.0-0 \
  libcairo2 \
  libasound2t64 \
  libnspr4 \
  libnss3 \
  libxshmfence1 \
  fonts-liberation \
  libappindicator3-1 \
  xdg-utils
```

### Ubuntu 22.04

```bash
sudo apt-get update && sudo apt-get install -y \
  libxfixes3 \
  libatk1.0-0 \
  libatk-bridge2.0-0 \
  libcups2 \
  libdrm2 \
  libxkbcommon0 \
  libxcomposite1 \
  libxdamage1 \
  libxrandr2 \
  libgbm1 \
  libpango-1.0-0 \
  libcairo2 \
  libasound2 \
  libnspr4 \
  libnss3 \
  libxshmfence1 \
  fonts-liberation \
  libappindicator3-1 \
  xdg-utils
```

> [!NOTE]
> The difference between 22.04 and 24.04 is the `t64` suffix on some packages (e.g., `libcups2` vs `libcups2t64`).
> If `apt` complains a `t64` package doesn't exist, try without the suffix, and vice versa.

---

## Step 3: Install Puppeteer & Chrome Binary

### 3a. Find the required Chrome version

Check which Chrome version your Puppeteer expects:

```bash
cd /www/wwwroot/your-domain.com
node -e "const pkg = require('./node_modules/puppeteer-core/package.json'); console.log('Puppeteer Core:', pkg.version)"
cat node_modules/puppeteer-core/lib/cjs/puppeteer/revisions.js 2>/dev/null || \
node -e "try{const r=require('puppeteer-core/lib/cjs/puppeteer/revisions.js');console.log(r)}catch(e){console.log('Check package.json for browser version')}"
```

Or check the `node_modules/puppeteer/package.json` for `"chrome-headless-shell"` version.

### 3b. Install Chrome headless shell

Replace `CHROME_VERSION` with the version from step 3a (e.g., `146.0.7680.153`):

```bash
cd /www/wwwroot/your-domain.com

# Method 1: Using @puppeteer/browsers (RECOMMENDED)
node -e "
const {install} = require('@puppeteer/browsers');
install({
  browser: 'chrome-headless-shell',
  buildId: 'CHROME_VERSION',
  cacheDir: '/root/.cache/puppeteer'
}).then(r => console.log('✅ Installed at:', r.executablePath))
  .catch(e => console.error('❌ Failed:', e.message))
"
```

```bash
# Method 2: Using npx (if puppeteer CLI works)
npx puppeteer browsers install chrome-headless-shell
```

```bash
# Method 3: If puppeteer CLI has permission issues
chmod +x node_modules/.bin/puppeteer
npx puppeteer browsers install chrome-headless-shell
```

```bash
# Method 4: Global install as last resort
npm install -g puppeteer
puppeteer browsers install chrome-headless-shell
```

### 3c. Find the installed Chrome path

```bash
find /root/.cache/puppeteer -name "chrome-headless-shell" -type f 2>/dev/null
```

**Expected output (example):**
```
/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell
```

> [!IMPORTANT]
> **Write down this path!** You'll need it for `setChromePath()` in the Laravel code.

---

## Step 4: Set Permissions for PHP (www user)

PHP-FPM on aaPanel runs as the `www` user. By default, `/root/` is not accessible to other users.

```bash
# Grant read+execute access to the Chrome binary path
chmod 755 /root
chmod 755 /root/.cache
chmod 755 /root/.cache/puppeteer
chmod -R 755 /root/.cache/puppeteer/chrome-headless-shell
```

### Verify www user can access it

```bash
sudo -u www /root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell --version
```

If it prints a version number, permissions are correct.

> [!WARNING]
> If you skip this step, you'll get one of these errors:
> - `Could not find Chrome` (Puppeteer looks in `/home/www/.cache/puppeteer/` which is empty)
> - `Permission denied` (www user can't read `/root/.cache/`)

---

## Step 5: Configure Laravel Code

In every place where `Browsershot` is used, set **all three paths explicitly**:

```php
use Spatie\Browsershot\Browsershot;

$pdf = Browsershot::html($html)
    ->setNodeBinary('/www/server/nodejs/v22.22.2/bin/node')       // ← from Step 1
    ->setNpmBinary('/www/server/nodejs/v22.22.2/bin/npm')         // ← from Step 1
    ->setChromePath('/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell')  // ← from Step 3c
    ->noSandbox()
    ->format('A4')
    ->margins(8, 8, 8, 8)
    ->showBackground()
    ->pdf();
```

### Files to update in this project

| File | Method | Line (approx) |
|------|--------|---------------|
| `app/Http/Controllers/School/Exam/MarkSheetController.php` | `download()` | ~164 |
| `app/Http/Controllers/School/Exam/MarkSheetController.php` | `isNodeInstalled()` | ~80 |
| `app/Jobs/GenerateBulkMarksheetJob.php` | `handle()` | ~129 |

> [!TIP]
> When upgrading Node.js via aaPanel, the path changes (e.g., `v22.22.2` → `v22.23.0`).
> Search your codebase for the old version string and update all occurrences:
> ```bash
> grep -r "v22.22.2" app/
> ```

---

## Step 6: Verify Everything Works

### 6a. Test Chrome directly

```bash
# As root
/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell \
  --no-sandbox --disable-gpu --dump-dom https://example.com
```

### 6b. Test as the www user

```bash
sudo -u www /root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell \
  --no-sandbox --disable-gpu --dump-dom https://example.com
```

### 6c. Test from Laravel (Tinker)

```bash
cd /www/wwwroot/your-domain.com
php artisan tinker
```

```php
$pdf = \Spatie\Browsershot\Browsershot::html('<h1>Test PDF</h1>')
    ->setNodeBinary('/www/server/nodejs/v22.22.2/bin/node')
    ->setNpmBinary('/www/server/nodejs/v22.22.2/bin/npm')
    ->setChromePath('/root/.cache/puppeteer/chrome-headless-shell/linux-146.0.7680.153/chrome-headless-shell-linux64/chrome-headless-shell')
    ->noSandbox()
    ->format('A4')
    ->pdf();

file_put_contents('/tmp/test.pdf', $pdf);
echo "PDF size: " . strlen($pdf) . " bytes\n";
```

If it prints a size > 0, everything is working.

### 6d. Restart services after any changes

```bash
# Restart PHP-FPM
systemctl restart php8.3-fpm    # or php8.2-fpm, php8.1-fpm

# Restart queue workers (for bulk marksheet jobs)
cd /www/wwwroot/your-domain.com
php artisan queue:restart

# Or if using Supervisor
supervisorctl restart all

# Clear Laravel caches
php artisan config:clear
php artisan cache:clear
```

---

## Troubleshooting

### Error: `libXfixes.so.3: cannot open shared object file`

**Cause:** Missing system libraries for Chrome.  
**Fix:** Run Step 2 (install Chrome dependencies).

To find ALL missing libraries:
```bash
ldd /root/.cache/puppeteer/chrome-headless-shell/linux-*/chrome-headless-shell-linux64/chrome-headless-shell | grep "not found"
```

### Error: `Could not find Chrome (ver. X.X.X.X) ... cache path is: /home/www/.cache/puppeteer`

**Cause:** PHP runs as `www` user, Chrome installed in `/root/.cache/puppeteer/`.  
**Fix:** Add `->setChromePath('/root/.cache/puppeteer/...')` in code (Step 5) AND set permissions (Step 4).

### Error: `Failed to launch the browser process: Code: 127`

**Cause:** Either Chrome binary missing or shared libraries missing.  
**Fix:** Run Steps 2 and 3.

### Error: `Permission denied` on puppeteer binary

**Cause:** The `node_modules/.bin/puppeteer` file lacks execute permission.  
**Fix:**
```bash
chmod +x node_modules/.bin/puppeteer
```

### Error: `Cannot find module '../puppeteer.js'`

**Cause:** Broken symlink in `node_modules/.bin/`.  
**Fix:** Use the `@puppeteer/browsers` method instead (Step 3b, Method 1).

### Error: `Download failed: server returned code 404`

**Cause:** Using `stable` as buildId instead of a specific version number.  
**Fix:** Use the exact Chrome version (e.g., `146.0.7680.153`).

### PDF generates but images are missing

**Cause:** External image URLs can't be fetched by headless Chrome.  
**Fix:** Use the `HandlesPdfAssets` trait to convert images to base64 data URIs before rendering.

### Node.js version changed after aaPanel update

**Cause:** aaPanel updates Node to a new version, changing the binary path.  
**Fix:**
```bash
which node   # get new path
which npm    # get new path
```
Then update all occurrences in the codebase.

---

## Quick Setup Script

Save this as `setup_browsershot.sh` on new servers and run as root:

```bash
#!/bin/bash
set -e

# ============================================
# Browsershot Quick Setup for Ubuntu + aaPanel
# Run as root on a fresh server
# ============================================

CHROME_VERSION="146.0.7680.153"
PROJECT_DIR="/www/wwwroot/your-domain.com"  # ← CHANGE THIS

echo "=== Step 1: Checking Node.js ==="
NODE_PATH=$(which node 2>/dev/null || echo "")
NPM_PATH=$(which npm 2>/dev/null || echo "")

if [ -z "$NODE_PATH" ]; then
    echo "❌ Node.js not found. Install it via aaPanel first."
    exit 1
fi

echo "✅ Node: $NODE_PATH ($(node -v))"
echo "✅ NPM:  $NPM_PATH ($(npm -v))"

echo ""
echo "=== Step 2: Installing Chrome dependencies ==="
apt-get update -qq
apt-get install -y -qq \
  libxfixes3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 \
  libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 \
  libgbm1 libpango-1.0-0 libcairo2 libasound2t64 libnspr4 \
  libnss3 libxshmfence1 fonts-liberation libappindicator3-1 xdg-utils \
  2>/dev/null || \
apt-get install -y -qq \
  libxfixes3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
  libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 \
  libgbm1 libpango-1.0-0 libcairo2 libasound2 libnspr4 \
  libnss3 libxshmfence1 fonts-liberation libappindicator3-1 xdg-utils

echo "✅ System dependencies installed"

echo ""
echo "=== Step 3: Installing Chrome Headless Shell ==="
cd "$PROJECT_DIR"
node -e "
const {install} = require('@puppeteer/browsers');
install({
  browser: 'chrome-headless-shell',
  buildId: '$CHROME_VERSION',
  cacheDir: '/root/.cache/puppeteer'
}).then(r => {
  console.log('✅ Chrome installed at:', r.executablePath);
}).catch(e => {
  console.error('❌ Failed:', e.message);
  process.exit(1);
});
"

echo ""
echo "=== Step 4: Setting permissions ==="
chmod 755 /root /root/.cache /root/.cache/puppeteer
chmod -R 755 /root/.cache/puppeteer/chrome-headless-shell
echo "✅ Permissions set"

echo ""
echo "=== Step 5: Verifying ==="
CHROME_PATH=$(find /root/.cache/puppeteer -name "chrome-headless-shell" -type f | head -1)
echo "Chrome path: $CHROME_PATH"
$CHROME_PATH --no-sandbox --disable-gpu --dump-dom https://example.com > /dev/null 2>&1 && echo "✅ Chrome works!" || echo "❌ Chrome failed to launch"

echo ""
echo "=== DONE ==="
echo ""
echo "Update your Laravel code with these paths:"
echo "  ->setNodeBinary('$NODE_PATH')"
echo "  ->setNpmBinary('$NPM_PATH')"
echo "  ->setChromePath('$CHROME_PATH')"
echo ""
echo "Don't forget to restart PHP-FPM and queue workers!"
```

### Usage

```bash
# 1. Upload to server
# 2. Edit PROJECT_DIR variable
# 3. Run:
chmod +x setup_browsershot.sh
./setup_browsershot.sh
```

---

## Architecture Note

```
┌─────────────────────────────────────────────────────┐
│  Laravel (PHP-FPM, runs as 'www' user)              │
│                                                     │
│  Browsershot::html($html)                           │
│    ->setNodeBinary('/www/server/nodejs/.../node')    │
│    ->setNpmBinary('/www/server/nodejs/.../npm')      │
│    ->setChromePath('/root/.cache/puppeteer/...')      │
│    ->noSandbox()                                    │
│    ->pdf()                                          │
└──────────────┬──────────────────────────────────────┘
               │ spawns process
               ▼
┌─────────────────────────────────────────────────────┐
│  Node.js (browser.cjs from spatie/browsershot)      │
│  - Reads the JSON config from PHP                   │
│  - Launches Puppeteer                               │
└──────────────┬──────────────────────────────────────┘
               │ launches
               ▼
┌─────────────────────────────────────────────────────┐
│  Chrome Headless Shell                              │
│  - Renders HTML to PDF                              │
│  - Needs system libs (libXfixes, libgbm, etc.)     │
│  - Must run with --no-sandbox as root/www           │
└─────────────────────────────────────────────────────┘
```
