Headless Console Sorcery: Running Off Browser Scripts with Puppeteer inside Docker

Amir YuristaAmir Yurista
4 min read

Automating a bit of JavaScript in the browser console can save hours of repetitive clicking, but doing it reliably in a containerised CI or server environment introduces a fresh set of head-scratchers.
This post walks through the approach I landed on after “Dockerizing” a Puppeteer helper that injects arbitrary snippets, waits for their results, and fails gracefully when something goes wrong. You’ll find:

  1. A minimal Dockerfile that keeps images light yet Chromium-ready

  2. Launch flags that make Chrome behave inside a container (and why they matter)

  3. A reusable evaluateInBrowser() utility, complete with fall-back HTML dumps for post-mortem debugging

  4. A checklist of gotchas (shared memory, sandboxing, time-outs, and more)


1. Why Docker complicates Puppeteer

Running Puppeteer locally is pretty painless: npm i puppeteer downloads its own Chromium build, you call launch(), and off it goes.
Inside Docker you must also contend with:

IssueSymptomFix
Sandbox securityChrome exits immediately with “No usable sandbox!”Run with --no-sandbox and --disable-setuid-sandbox
Shared memoryRandom page crashes; blank screenshotsAdd --disable-dev-shm-usage or mount a larger /dev/shm
Zombie processesStale Chrome instances after container stopUse Docker’s --init flag or an entry-point that reaps processes
Huge image sizes1-GB-plus fat imagesInstall only the libraries Chromium needs & clean the apt cache
Headless timing quirksWorks locally, hangs in CIPick the right waitUntil strategy (networkidle2 rarely lies)

The official Puppeteer documentation lists the up-to-date matrix of required Linux packages and flags, worth bookmarking.


2. A lean Dockerfile

Below is a Node 18 Alpine image that weighs in under 300 MB yet happily launches Chrome:

# docker/Dockerfile
FROM mcr.microsoft.com/devcontainers/javascript-node:18-alpine

# Puppeteer needs these system libs
RUN apk add --no-cache \
      udev ttf-freefont \
      chromium \
      nss

# Don’t let npm re-download its own Chromium
ENV PUPPETEER_SKIP_DOWNLOAD=1 \
    PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

ENTRYPOINT ["dumb-init", "--"]  # keeps child processes in line
CMD ["node", "index.js"]

Notes

  • Alpine’s chromium package matches Puppeteer’s API most of the time. Pin versions if you need pixel-perfect rendering.

  • PUPPETEER_SKIP_DOWNLOAD avoids doubling your image size with an extra Chromium bundle.

  • dumb-init (or Docker’s --init) cleans up Chrome zombies on SIGTERM.


3. The evaluateInBrowser helper

const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');

async function evaluateInBrowser(url, scripts, headless = true) {
  const browser = await puppeteer.launch({
    headless,
    executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, // works locally & in Docker
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage'
    ],
    timeout: 120000
  });

  const page = await browser.newPage();
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36'
  );

  await page.goto(url, { waitUntil: 'networkidle2', timeout: 120000 });

  // Run each snippet in the page context and collect its result
  const results = await page.evaluate(async (snippets) => {
    const run = (code) =>
      eval(`(async () => { ${code} })()`); // caller supplies async/sync code

    const pairs = await Promise.all(
      Object.entries(snippets).map(async ([k, v]) => ({ [k]: await run(v) }))
    );

    return Object.assign({}, ...pairs);
  }, scripts);

  // Optional: if nothing came back, dump the HTML to debug later
  if (Object.values(results).every((v) => v == null || v === '')) {
    const title = (await page.title()).replace(/[\\/:*?"<>|]/g, '_');
    await fs.writeFile(path.join('debug', `${title}.html`), await page.content());
  }

  await browser.close();
  return results;
}

module.exports = { evaluateInBrowser };

Why these particular changes?

ChangeWhy it matters
Replaced new Function with evalKeeps the code shorter and preserves this if needed (still runs in page context, use with care)
networkidle2 wait-strategyWorks better for SPAs that keep a long-polling websocket open; stops after 500 ms of silence
Recent desktop UA stringSome sites block obvious headless browsers; spoofing buys time until they tighten detection
HTML dump on empty resultsSaves the rendered DOM so you can replay bugs offline, especially useful when the CI job is gone

4. Running it

docker build -t console-runner .
docker run --rm \
  -e PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
  console-runner \
  "https://example.com" \
  '{"title":"return document.title;","h1":"return document.querySelector(\"h1\").textContent;"}'

Behind the scenes the container launches Chromium headlessly with the right flags, each snippet is evaluated in the page context, and the combined JSON results are streamed back to Node for further processing.


5. Troubleshooting checklist

  1. Hangs at goto()
    • Try switching waitUntil to domcontentloaded for heavily client-rendered apps.

  2. ERR_INSUFFICIENT_RESOURCES
    • Increase shared memory: docker run --shm-size=1gb …

  3. Font-related “tofu” characters
    • Install ttf-freefont or the specific fonts your page requires.

  4. Chromium not found
    • Pin PUPPETEER_EXECUTABLE_PATH to your system Chrome, or let Puppeteer download its own build by removing the env variable.


6. Final thoughts

With the right launch flags and a disciplined Dockerfile, Puppeteer becomes a rock-solid way to replay those “one-off” console hacks programmatically, whether you’re scraping data, generating reports, or just pushing a button hidden behind ten clicks.

0
Subscribe to my newsletter

Read articles from Amir Yurista directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Amir Yurista
Amir Yurista