Skip to main content
Back to Blog
Guide
2026-08-08

Playwright Browser Launch Args Hardening: Safer Chromium Flags for CI and Local Runs

Harden Playwright browser launch args with intentional flags, safer container defaults, and audits that stop cargo-cult --no-sandbox from spreading.

Playwright Browser Launch Args Hardening: Safer Chromium Flags for CI and Local Runs

Playwright browser launch args hardening means treating every Chromium (or Firefox/WebKit) flag you pass through launchOptions.args as a security and reliability decision, not as a paste from an old Docker blog post. Playwright already ships a curated default argument list designed to keep automation stable. Custom args can fix real constraints (small /dev/shm in a container, corporate proxy quirks, GPU-less hosts), and they can also disable sandboxing, weaken isolation, or break Playwright features in ways that only show up as "random" CI flakes.

Hardening is the opposite of collecting flags. It starts from Playwright defaults, adds the minimum flag set required for a documented environment constraint, records why each flag exists, and refuses to copy --no-sandbox into contexts that still have a working user namespace. Official guidance warns that custom args are at your own risk and that ignoreDefaultArgs is dangerous. See https://playwright.dev/docs/api/class-browsertype and https://playwright.dev/docs/test-use-options.

This guide covers default-first configuration, container shared-memory strategy, channel and headless choices, safe and unsafe flag classes, a realistic sandbox failure story, project-level matrices, and an audit process. If you are comparing runner ecosystems, use the JavaScript testing frameworks guide. When a hardened browser still fails on selectors, continue with Playwright locator best practices.

Prefer Playwright defaults before adding any custom flag

Create a new config with zero custom args. Run smoke tests locally and in CI. Only when you have a concrete failure (crash, launch error, policy requirement) do you add a flag.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  timeout: 30_000,
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  use: {
    baseURL: 'http://127.0.0.1:3000',
    trace: 'on-first-retry',
    // No launchOptions.args yet. Defaults only.
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Document the moment you introduce args. A short comment is not enough for a security-sensitive flag. Keep a table in the repo:

FlagEnvironmentReasonOwnerReview date
(none)local macOSDefaults workqa-platform2026-08-08

When the table is empty of flags, that is a success state, not a TODO.

Playwright config surfaces common options without raw flags. Prefer first-class options when they exist: headless, viewport, locale, timezoneId, userAgent, proxy, ignoreHTTPSErrors, and device descriptors. Raw Chromium switches should not duplicate what use already expresses.

Harden shared-memory and container launches without disabling the sandbox casually

Containerized Chromium historically collided with the default 64MB /dev/shm. Two known strategies exist in the wider ecosystem:

  1. Increase shared memory for the container (docker run --shm-size=1g or Compose shm_size).
  2. Pass Chromium's --disable-dev-shm-usage so it uses /tmp instead of /dev/shm.

Prefer larger /dev/shm when you control the runtime. It keeps Chromium's normal memory paths. Use --disable-dev-shm-usage when you cannot change the container runtime and you have confirmed crashes tied to shared memory.

# docker-compose.ci.yml
services:
  e2e:
    image: mcr.microsoft.com/playwright:v1.54.0-jammy
    shm_size: '1gb'
    working_dir: /work
    volumes:
      - ./:/work
    command: npx playwright test

If you must add the flag through Playwright:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

const inContainer = process.env.E2E_CONTAINER === '1';

export default defineConfig({
  use: {
    ...devices['Desktop Chrome'],
    launchOptions: {
      args: inContainer ? ['--disable-dev-shm-usage'] : [],
    },
  },
});

Notice the gate. Local developers on macOS or Windows typically should not inherit container-only flags. Unconditional global args create "works on my machine" inversions where CI is the only environment that matches production-like constraints.

One thing this config does not do is turn the Chromium sandbox on. Playwright's chromiumSandbox launch option defaults to false, and the official Docker images run as root by default, which also rules the sandbox out. Removing --no-sandbox from your args list is therefore not the same as being sandboxed. If your threat model needs the sandbox (untrusted pages, third-party content, shared runners), set chromiumSandbox: true explicitly and run the container as a non-root user with a seccomp profile that permits user namespaces. If you are only driving your own trusted application, it is legitimate to leave the sandbox off, but say so in the config rather than leaving readers to assume otherwise. See https://playwright.dev/docs/api/class-browsertype and https://playwright.dev/docs/docker.

ConstraintPreferFlag fallbackAvoid
Small /dev/shmRaise shm_size--disable-dev-shm-usageDisabling sandbox
Root in legacy imageNewer Playwright image with non-root docsTemporary --no-sandbox only if required and documentedCopying it to all projects forever
Corporate TLS inspectionProper root CA installLimited ignoreHTTPSErrors for test env only--ignore-certificate-errors everywhere
Need branded Chrome behaviorchannel: 'chrome' with installed browserNoneRandom compatibility flags from forums

Control channel, headless mode, and branded browsers deliberately

Playwright can launch bundled browsers or a channel such as chrome, msedge, or chromium depending on your installed Playwright version and docs. Channel choice changes more than the binary path: available features, headless implementation, and corporate policy surfaces differ. Hardening means pinning the decision per project.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'bundled-chromium',
      use: {
        ...devices['Desktop Chrome'],
      },
    },
    {
      name: 'chrome-stable-channel',
      use: {
        ...devices['Desktop Chrome'],
        channel: 'chrome',
      },
    },
  ],
});

Do not pass contradictory settings (for example, mixing approaches that force old and new headless modes without reading current Playwright browser docs for your version). When you upgrade Playwright, re-read https://playwright.dev/docs/browsers because headless packaging has evolved across releases.

Headless is a use.headless concern more than a raw --headless=... flag for most Playwright Test users. Prefer the documented option so the runner and browser stay aligned.

ignoreDefaultArgs is a last resort, not a cleanup tool

Playwright documents ignoreDefaultArgs as dangerous. It can be a boolean (drop all Playwright defaults) or an array (drop specific defaults). Teams sometimes set ignoreDefaultArgs: true to "simplify" the command line, then spend weeks rediscovering why automation is flaky.

// BAD: strips Playwright's curated defaults entirely
// launchOptions: { ignoreDefaultArgs: true, args: ['--some-flag'] }

// BETTER when you truly must remove one default, after measuring:
// launchOptions: { ignoreDefaultArgs: ['--mute-audio'] }

The official docs illustrate filtering --mute-audio as a targeted example. Copy that pattern of minimal removal, not blanket true, when you have a demonstrated need (for example, testing audio behavior). Re-validate traces, downloads, and screenshots after any default removal.

ApproachRiskWhen acceptable
No custom argsLowestDefault goal
Additive args onlyMediumDocumented environment constraint
ignoreDefaultArgs: [specific]HighMeasured conflict with one default
ignoreDefaultArgs: trueHighestAlmost never in CI test runners

Failure mode: --no-sandbox copied from a blog post into production CI

Story

A team moves e2e jobs to a minimal container. Chromium refuses to launch under their user setup. An engineer searches for the error, finds multiple posts recommending:

launchOptions: {
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
}

CI turns green. The flags land in the shared playwright.config.ts used by every project, including jobs that run against staging URLs with real session cookies on self-hosted runners. Months later, a security review flags sandbox disablement. Nobody can explain whether the original launch failure still reproduces on the current Playwright base image.

Diagnosis

  1. Remove the flags on a feature branch and run the pipeline using the current official Playwright image.
  2. If launch succeeds, the flags are obsolete cargo cult.
  3. If launch still fails, capture the exact Chromium error and compare user namespaces, seccomp, and AppArmor profiles on the runner.
  4. Check whether shm_size or a non-root image change fixes the issue without sandbox disablement.
  5. If policy truly forces sandbox off (rare and undesirable), isolate that config to a single locked-down project and environment, never the global default.

Hardened fix pattern

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

function chromiumLaunchArgs(): string[] {
  const args: string[] = [];
  if (process.env.E2E_CONTAINER === '1') {
    args.push('--disable-dev-shm-usage');
  }
  // Only if a tracked incident requires it. Default is off.
  if (process.env.E2E_ALLOW_NO_SANDBOX === '1') {
    args.push('--no-sandbox');
  }
  return args;
}

export default defineConfig({
  use: {
    launchOptions: {
      args: chromiumLaunchArgs(),
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Pair with pipeline env that does not set E2E_ALLOW_NO_SANDBOX on shared runners. If a special job must set it, name the job clearly and attach a tracking ticket id in the workflow comment.

What people get wrong about stuffing stealth flags into launchOptions

"Stealth" lists from bot-detection blogs often disable blink features, automation-controlled switches, or fingerprint mitigations. In a QA context, those flags optimize for pretending not to be automated software. That is usually the wrong objective for first-party product testing, and it can break Playwright's assumptions. Your app under test is your own system; you need reliable automation, not an arms race with your own bot score.

Other common mistakes:

  1. Duplicating defaults. Adding flags Playwright already passes clutters diffs and confuses audits.
  2. Globalizing machine-specific paths. --user-data-dir=/Users/someone/... destroys CI portability.
  3. Using args for what context options already solve. Locale, geolocation, permissions, and color scheme belong in context options or devices.
  4. Silencing certificate errors broadly. Prefer installing test CAs; keep ignoreHTTPSErrors scoped to ephemeral test environments.
  5. Mixing Firefox/WebKit assumptions with Chromium flags. Flags are browser-family specific. Gate by project name.
// Gate Chromium-only args by project
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'chromium-ci',
      use: {
        ...devices['Desktop Chrome'],
        launchOptions: {
          args:
            process.env.E2E_CONTAINER === '1'
              ? ['--disable-dev-shm-usage']
              : [],
        },
      },
    },
    {
      name: 'webkit',
      use: {
        ...devices['Desktop Safari'],
        // No Chromium flags here
      },
    },
  ],
});

Project-level launchOptions matrices for isolation

Large suites benefit from separating "hardened default," "legacy exception," and "branded channel" into projects rather than one overloaded config.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

const containerArgs =
  process.env.E2E_CONTAINER === '1' ? ['--disable-dev-shm-usage'] : [];

export default defineConfig({
  projects: [
    {
      name: 'chromium-default',
      testMatch: /.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        launchOptions: { args: containerArgs },
      },
    },
    {
      name: 'chromium-no-sandbox-exception',
      testMatch: /legacy-container\/.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        launchOptions: {
          args: [...containerArgs, '--no-sandbox'],
        },
      },
    },
  ],
});

Keep the exception project tiny. The goal is extinction: migrate tests off it as infrastructure improves. Track the count of tests in the exception project as a platform metric (illustrative target: drive it to zero by a date your team sets).

Audit and document every non-default arg in the repo

Add a lightweight check that fails when unknown flags appear. This does not replace security review, but it stops silent growth.

// scripts/audit-launch-args.mjs
const allowed = new Set([
  '--disable-dev-shm-usage',
  // Explicit allowlist only. Empty set is ideal.
]);

const configArgs = process.env.AUDITED_ARGS?.split(',').filter(Boolean) ?? [];

const unexpected = configArgs.filter((arg) => !allowed.has(arg));
if (unexpected.length > 0) {
  console.error('Unexpected browser launch args:', unexpected.join(', '));
  process.exit(1);
}
console.log('Launch args audit passed for', configArgs.length, 'args');

Wire the script to CI with the args your config would emit. Even better, export args from a shared module both the config and the audit import.

// e2e/launch-args.ts
export function resolveChromiumArgs(env: NodeJS.ProcessEnv): string[] {
  const args: string[] = [];
  if (env.E2E_CONTAINER === '1') {
    args.push('--disable-dev-shm-usage');
  }
  return args;
}
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { resolveChromiumArgs } from './e2e/launch-args';

export default defineConfig({
  use: {
    launchOptions: {
      args: resolveChromiumArgs(process.env),
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Debugging launch failures without adding random flags

When the browser fails to start, gather evidence before editing args:

  1. Run with Playwright debug logging as documented for your version (for example, debug categories via the DEBUG environment variable patterns Playwright documents).
  2. Confirm the official browser install completed (npx playwright install / npx playwright install chromium as appropriate).
  3. Verify the container image tag matches the Playwright npm package major/minor.
  4. Check disk space, /dev/shm, and whether multiple browsers launch in parallel beyond machine capacity.
  5. Try a minimal reproduction outside the full suite: a one-file test that only opens about:blank.
// tests/smoke/launch-smoke.spec.ts
import { test, expect } from '@playwright/test';

test('browser launches and paints a data URL', async ({ page }) => {
  await page.goto('data:text/html,<h1>launch-ok</h1>');
  await expect(page.getByRole('heading', { name: 'launch-ok' })).toBeVisible();
});

If the smoke test fails, the problem is launch/runtime, not locators. If it passes, stop adding flags and debug the application test.

Security boundaries on self-hosted runners

Self-hosted runners that execute Playwright against internal staging are high value targets. Disabling the sandbox expands blast radius if a test page is compromised or a dependency supplies hostile content. Hardening checklist:

ControlWhy it matters
Keep sandbox enabledProcess isolation for renderer
Ephemeral workspacesLimit leftover profiles and cookies
Locked-down baseURL allowlistsAvoid tests navigating to arbitrary hosts
Secrets via env, not pagesPrevent artifact leakage
Short-lived staging tokensReduce replay value of traces
Avoid shared long-lived user-data-dirProfile pollution and secret residue

Traces and videos may capture PII. Launch args do not fix that; retention policies do. Still, unstable flags that force odd rendering paths can increase flake-driven re-runs and expand artifact volume.

Firefox and WebKit notes

Most launch-arg folklore is Chromium-specific. For Firefox and WebKit projects, prefer Playwright's documented options and avoid pasting Chromium switches. If you need Firefox preferences, use the mechanisms Playwright documents for Firefox, not --disable-dev-shm-usage cargo cult. Validate each browser project independently in CI rather than assuming one hardened Chromium config applies everywhere.

Upgrade playbook when Playwright changes browser packaging

On each Playwright upgrade:

  1. Read the release notes for browser and headless changes.
  2. Re-run launch smoke on all container images you maintain.
  3. Clear obsolete flags that exist only to work around fixed bugs.
  4. Re-approve any remaining --no-sandbox exception with a current security owner.
  5. Refresh the args inventory table date.

Teams that never remove flags accumulate negative knowledge: "we need these five switches" when they actually need none on modern images.

Agent-generated configs and policy

AI coding agents often emit generous args arrays because training data is full of Docker workarounds. Review agent PRs specifically for launchOptions. Provide an internal snippet or skill that encodes your hardened resolver. Ready-made QA skills installable from qaskills.sh via the qaskills CLI can standardize "defaults first, container gate second, never silent no-sandbox" so agents do not reinvent unsafe templates.

Practical rollout plan for an existing messy config

  1. Inventory all args and ignoreDefaultArgs usages across the monorepo.
  2. Classify each flag: required, unknown, or obsolete.
  3. Delete obsolete flags behind a feature branch and watch CI.
  4. Move required flags behind environment gates.
  5. Isolate any sandbox-off requirement in a named project.
  6. Add the allowlist audit script.
  7. Educate contributors with a short CONTRIBUTING note linking to this policy.

Illustrative timeline many teams can adapt: one sprint to inventory and delete safe targets, one sprint to gate container flags, ongoing quarterly review of exceptions. Your calendar should match risk, not a universal standard.

Parallelism, resource caps, and flag interactions

Launch args interact with how hard you push the machine. workers: 100% on a small CI host can produce Chromium OOM kills that look like "needs more flags" when the real fix is fewer workers or larger runners. Before adding memory-related switches, measure concurrent browser processes.

// playwright.config.ts (resource-aware workers)
import { defineConfig, devices } from '@playwright/test';
import { resolveChromiumArgs } from './e2e/launch-args';

export default defineConfig({
  workers: process.env.CI ? 2 : undefined,
  use: {
    launchOptions: {
      args: resolveChromiumArgs(process.env),
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Watch for false fixes: someone adds --disable-dev-shm-usage, flakes drop slightly because the failure mode shifted, then the suite still OOMs under peak load. Pair args changes with worker caps and container memory limits. Record the before/after flake rate for that job only (your numbers, not industry averages).

Proxy and corporate network flags deserve the same discipline. If you must route traffic through an explicit proxy, prefer Playwright's proxy option in use rather than ad hoc Chromium switches when the documented option covers your case. That keeps credentials and bypass lists in one place and avoids partial configuration where the browser and the APIRequestContext disagree.

Local developer overrides without forking the hardened config

Developers sometimes need headed debugging, slowMo, or a specific channel. Give them env-driven escapes that cannot silently land in CI.

// e2e/launch-args.ts
export function resolveChromiumArgs(env: NodeJS.ProcessEnv): string[] {
  const args: string[] = [];
  if (env.E2E_CONTAINER === '1') {
    args.push('--disable-dev-shm-usage');
  }
  if (env.E2E_EXTRA_CHROMIUM_ARGS) {
    // Space-separated allowlist only; CI should leave this unset.
    for (const part of env.E2E_EXTRA_CHROMIUM_ARGS.split(' ').filter(Boolean)) {
      if (part === '--no-sandbox' && env.CI) {
        throw new Error('Refusing --no-sandbox via E2E_EXTRA_CHROMIUM_ARGS on CI');
      }
      args.push(part);
    }
  }
  return args;
}

Document supported local env vars in the testing README: E2E_CONTAINER, headed mode via Playwright CLI, and maybe E2E_EXTRA_CHROMIUM_ARGS for rare experiments. Explicit refusal on CI for sandbox disablement turns a social rule into an enforceable one.

When a developer truly needs a one-off binary, npx playwright open or a tiny library-mode script is better than polluting the shared config. Keep the suite config boring.

Evidence pack for change requests that add a flag

Require a short template in PRs that touch launchOptions:

  1. Exact error message and log excerpt from a failing job URL.
  2. Playwright version and container image tag.
  3. Alternatives tried (shm size, workers, official image bump, channel change).
  4. Flag proposed and why narrower options failed.
  5. Rollback plan (env gate or project isolation).
  6. Security note if isolation weakens.

Without that pack, default to reject. Hardening fails when the easiest path is "add three flags and merge." Make the easiest path "use defaults and the shared resolver."

Connecting launch hardening to flake triage

Flake triage should ask, in order: is the locator stable, is the app race fixed, is the environment under-provisioned, and only then is the browser launch surface wrong. Launch args are a late lever. Teams that jump straight to flags train contributors to skip root-cause analysis.

When you do change args, open a tracking issue with an expiry. Example title: "Temporary --disable-dev-shm-usage until runners gain 1g shm." Close the issue when infrastructure lands and delete the flag in the same week. Orphan flags are how last year's incident becomes this year's unexplained baseline.

If you maintain multiple repos, publish the resolver and audit script as a small internal package so hardening does not diverge. Agents and humans both copy whatever is nearest; put the safe defaults nearest.

Frequently Asked Questions

Does Playwright require --no-sandbox in Docker?

Not by default on current official Playwright images and documented setups. Some custom containers or restricted environments still hit launch failures that people work around with sandbox flags, but that is an environment smell to investigate, not a universal requirement. Prefer official images, correct permissions, and shm_size before disabling the sandbox.

Where should launch args live in Playwright Test?

Prefer use.launchOptions in defineConfig or per-project use blocks. That keeps browser process flags separate from context options. Avoid scattering chromium.launch({ args }) calls in tests when the runner already manages the browser unless you are writing library-mode scripts outside Playwright Test.

Is --disable-dev-shm-usage still needed if shm_size is 1gb?

Often no. If the container provides enough shared memory, the flag may be unnecessary. Validate by removing it after raising shm_size and running a representative parallel suite. Keep the flag only when you cannot control the runtime and have evidence of shm-related crashes.

How do I stop agents and developers from reintroducing unsafe flags?

Provide a shared resolveChromiumArgs module, an allowlist audit in CI, and a short policy in the testing README. Reject PRs that add ignoreDefaultArgs: true or global --no-sandbox without an incident link and expiry date. Make the safe path the easiest path in templates and skills.