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
  2. Prerequisites
  3. Step 1: Install Node.js via aaPanel
  4. Step 2: Install Chrome System Dependencies
  5. Step 3: Install Puppeteer & Chrome Binary
  6. Step 4: Set Permissions for PHP (www user)
  7. Step 5: Configure Laravel Code
  8. Step 6: Verify Everything Works
  9. Troubleshooting
  10. 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:
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

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

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:

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):

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))
"
# Method 2: Using npx (if puppeteer CLI works)
npx puppeteer browsers install chrome-headless-shell
# Method 3: If puppeteer CLI has permission issues
chmod +x node_modules/.bin/puppeteer
npx puppeteer browsers install chrome-headless-shell
# Method 4: Global install as last resort
npm install -g puppeteer
puppeteer browsers install chrome-headless-shell

3c. Find the installed Chrome path

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.

# 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

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:

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:

grep -r "v22.22.2" app/

Step 6: Verify Everything Works

6a. Test Chrome directly

# 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

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)

cd /www/wwwroot/your-domain.com
php artisan tinker
$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

# 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:

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:

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:

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:

#!/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

# 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           │
└─────────────────────────────────────────────────────┘
Posted in Multi School ERP and tagged , , .