Deployment — Docker & CDP endpoint
Run Clearcote as a standing CDP endpoint and point any existing framework at it — Playwright, Puppeteer, browser-use, Crawl4AI, Stagehand — with no code change. It launches the binary directly (no --enable-automation), so navigator.webdriver stays false: stealthy by construction.
Official Docker image
Pull the image and go. Any CDP client attaches over the exposed port.
docker run -d --rm -p 127.0.0.1:9222:9222 -e CC_FINGERPRINT=user-7423 teamflatearth/clearcote # CDP on http://localhost:9222That runs Chrome without its sandbox, as every image before sdk-0.41.0 did. Add the image's seccomp profile and Chrome runs with its sandbox; see Chrome's sandbox below.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp("http://localhost:9222") # your code, unchanged
page = browser.contexts[0].new_page() # the container's own profile
page.goto("https://example.com")
print(page.title())The image bakes in the SHA-256-verified open build for Linux, the Windows metric-clone fonts (so Latin text measures like the persona's fonts) and, from sdk-0.41.0, fonts for the other writing systems a Windows machine draws out of the box (see Fonts), and defaults to a coherent native Linux persona. The browser runs headed on a virtual display (CC_HEADLESS=1 for pure headless). With no GPU in the container, WebGL renders in software through the backend whose limits match the GPU the persona names: Mesa for a Linux persona, headed or headless, and SwiftShader for a Windows persona. Configure it entirely with env vars:
docker run -d -p 127.0.0.1:9222:9222 -e CC_PLATFORM=linux -e CC_FINGERPRINT=user-7423 -e CC_ACCEPT_LANGUAGE=en-US -e CC_TIMEZONE=America/New_York teamflatearth/clearcote| Variable | Meaning |
|---|---|
CC_FINGERPRINT | Seed → stable identity. Without it each container gets its own random seed, printed at start and kept in the container's profile across restarts (images before sdk-0.39.0 gave every container the same seed). Set it to choose or reuse an identity. |
CC_PLATFORM | linux (default) | windows | macos | android. A Windows persona also turns on Widevine and, on the licensed build (151 r15+), the HLSL shader dialect (CC_WIDEVINE / CC_SHADER_DIALECT to override). |
CC_BRAND, CC_BRAND_VERSION | Chrome (default) | Edge | Opera | Vivaldi, and the version it claims. |
CC_ACCEPT_LANGUAGE, CC_TIMEZONE | Language list and IANA timezone. |
CC_TLS_PROFILE | Leave unset: TLS follows the Chrome version the persona claims. Pin a chrome-<major> only together with a matching CC_BRAND_VERSION. |
CC_HARDWARE_CONCURRENCY, CC_GPU_VENDOR, CC_GPU_RENDERER, CC_STORAGE_QUOTA | Individual persona values. |
CC_HEADLESS, CC_SCREEN | 1 for pure headless; the virtual screen size (default 1920x1080x24). |
CC_EXTRA_ARGS | Extra browser switches, space-separated. --disable-runtime-suppression here is the SDK's stock_runtime (kept only on an engine that has it); --no-sandbox turns Chrome's sandbox off. |
CLEARCOTE_LICENSE_KEY, CC_VERSION | Run the licensed build — see below. |
CC_IDLE_EXIT_SECONDS | Stop once no CDP client has been connected for this many seconds, counted from when the browser first answers (default: never). Pair it with --rm so an abandoned container disappears too. Images from sdk-0.40.0. |
CC_SECRETS_FILE | A JSON file inside the container holding CLEARCOTE_LICENSE_KEY and/or CC_PROXY, read once at start and deleted — see keeping secrets out of docker inspect. Images from sdk-0.40.0. |
CLEARCOTE_FONT_DIRS | Your own fonts, mounted into the container (for example a copy of a Windows machine's Fonts folder) — see Fonts. Images from sdk-0.41.0. |
CLEARCOTE_FALLBACK_FONT_DIRS | Fonts used only for characters nothing else covers. The image sets it to its own script fonts (/usr/local/share/clearcote/fonts); leave it unless you replace them. |
CLEARCOTE_PERSONA_ENV | 0 keeps the persona on the browser's command line. By default, on an engine that can take it (licensed build from r32), the persona reaches the container's browser through its environment, so it is not in the host's process list; the serve-state line reports persona_env. Images from sdk-0.41.0. |
Claim the OS the container runs on. Some of what a page can read comes from the host operating system below the browser, and no persona setting reaches it. In this Linux image, text is sized by Linux's FreeType scaler and fonts come from fontconfig, whateverCC_PLATFORMsays. Measured on this image withCC_PLATFORM=windows: stepping a font size by 0.01 px changes the text width on 66% of steps (real Windows Chrome: 99%), and Segoe UI and Georgia measure as installed but cannot be loaded by name. The fingerprint test flags both. The default Linux persona passes. Genuine Windows fonts mounted withCLEARCOTE_FONT_DIRSfix the names on the licensed build from r32 (Fonts), but not the scaler, which no setting reaches. For a Windows identity, run the Windows build or hosted browsers, which run on Windows machines. The same applies toserve()andlaunch()on a Linux server. Details: the font stack under the user agent.
Licensed build in Docker — pass a key, mount the cache
The image ships the open build baked in. Set CLEARCOTE_LICENSE_KEY and the container resolves the licensed build instead — the newest one, or on the Pro plan a specific one with CC_VERSION. Free keys always run the latest build, and a pin is refused.
docker run -d -p 127.0.0.1:9222:9222 -v clearcote-cache:/opt/xdg-cache -e CLEARCOTE_LICENSE_KEY=cc_lic_... teamflatearth/clearcote
# Pro only: pin a major, an exact build, or a revision
# -e CC_VERSION=154 -e CC_VERSION=154.0.8037.57 -e CC_VERSION=r35Always mount the cache volume. A licensed container downloads the engine on first start; without a persisted volume, every container does it again. With the volume, later containers start from the cached build.
The startup log tells you which engine you got, so a misconfigured key is visible immediately rather than at the first blocked request:
[clearcote] engine: /opt/xdg-cache/clearcote/pro-154.0.8037.57-r35/browser/chrome (licensed)
[clearcote] licence lease acquiredRequires an image built from SDK 0.26.1 or newer, and 0.30.0 or newer for a free key from GitHub — the licensed browser expects a free container to keep its licence current while it runs, and refuses an older image. Older published images ignore the key and quietly serve the open build — docker pull teamflatearth/clearcote to refresh. A licensed run also checks out a concurrency lease at startup, so the container needs outbound access to the licence API; if the lease fails the container exits with the reason instead of starting an engine that cannot run. A free key runs one browser at a time across all your containers — see how licensed browsers are counted.From image sdk-0.40.0, several licensed containers starting at once on the same cache volume download the engine once: the first one fetches it and the others wait for it.
Keep secrets out of docker inspect
Every -e variable is part of the container's configuration, so anyone who can run docker inspect on it (or read docker compose config, or most container dashboards) sees CLEARCOTE_LICENSE_KEY and the password in a CC_PROXY URL in plain text. From image sdk-0.40.0 you can pass them as a file instead: a JSON file inside the container, named by CC_SECRETS_FILE, which the container reads once at start and deletes. The file must belong to the image's user (uid 10001).
printf '{"CLEARCOTE_LICENSE_KEY": "%s"}' "$CLEARCOTE_LICENSE_KEY" > clearcote-secrets.json
tar --owner=10001 --group=10001 --mode=600 -cf clearcote-secrets.tar clearcote-secrets.json
id=$(docker create -p 127.0.0.1:9222:9222 -e CC_SECRETS_FILE=/tmp/clearcote-secrets.json -v clearcote-cache:/opt/xdg-cache teamflatearth/clearcote)
docker cp - "$id:/tmp" < clearcote-secrets.tar # copy the file in before the container starts
docker start "$id"
rm clearcote-secrets.json clearcote-secrets.tarThis is what the SDKs' macOS launch does. Anyone with access to the Docker daemon can still read a running container's files and memory, so treat daemon access as access to the key.
Since sdk-0.40.0 the image runs tini as its init process, so browser helper processes that exit inside the container (for example from scripts you docker exec that start their own browsers) are cleaned up instead of piling up. docker stop still reaches the browser and releases the licence seat.
Fonts
The browser sees only the fonts the SDK points it at: the release's bundled Windows lookalikes, then the directories below. Nothing else under /usr/share/fonts reaches it, so a font installed in the image can never change the widths a Windows persona reports. From image sdk-0.41.0:
- Script fonts are built in. Colour emoji, Chinese, Japanese and Korean (WenQuanYi Zen Hei, IPA Gothic), and Noto faces for Arabic, Armenian, Bengali, Cherokee, Devanagari, Ethiopic, Georgian, Gujarati, Gurmukhi, Kannada, Khmer, Lao, Malayalam, Myanmar, Sinhala, Tamil, Telugu, Thai, Tibetan and more, in
/usr/local/share/clearcote/fonts, whichCLEARCOTE_FALLBACK_FONT_DIRSlists after the bundle. They are used only for characters the bundle cannot draw, so those characters render instead of showing as empty boxes, on the open build and the licensed build alike. No Latin family is added. - Your own fonts. Mount a directory of fonts, typically a copy of a Windows machine's
C:\Windows\Fonts, and name it inCLEARCOTE_FONT_DIRS. It is listed ahead of the bundle. On the licensed build from r32 every family it provides renders as itself, under its own name, and Helvetica, Times, Courier and the CSS generics follow; older engines keep their lookalikes and use the directory only for characters nothing else covers. Only use fonts you are licensed to use. The same setting outside Docker is the SDK's fontDirs.
docker run -d -p 127.0.0.1:9222:9222 -v /srv/windows-fonts:/fonts:ro -e CLEARCOTE_FONT_DIRS=/fonts \
-e CC_PLATFORM=windows -v clearcote-cache:/opt/xdg-cache -e CLEARCOTE_LICENSE_KEY=cc_lic_... teamflatearth/clearcote
docker exec <container> clearcote info --quick # which writing systems render, which families are genuineSecurity: a CDP endpoint is full browser control. Publish it only to trusted networks — -p 127.0.0.1:9222:9222 keeps it host-local. The docker/ Dockerfile is auditable — rebuild and verify it yourself.Chrome's sandbox
Chrome's Linux sandbox runs every renderer (the process that parses and runs a page) in namespaces of its own under a seccomp filter, so a page that compromises its renderer is still confined to it. With --no-sandbox that renderer has everything the container's user has: its files, its network and its other processes. Docker's default seccomp profile refuses the calls that create those namespaces, so images before sdk-0.41.0 always ran Chrome with --no-sandbox.
From image sdk-0.41.0 the container checks first: it makes the same calls Chrome's sandbox makes, and runs Chrome with its sandbox when they work. Start it with the seccomp profile the image ships, which is Docker's default profile with the two namespace calls Chrome needs added:
# copy the profile out of the image (--tmpfs keeps docker from first copying the engine into a volume)
docker run --rm --tmpfs /opt/xdg-cache --entrypoint cat teamflatearth/clearcote /etc/clearcote/seccomp.json > clearcote-seccomp.json
docker run -d -p 127.0.0.1:9222:9222 --security-opt seccomp=clearcote-seccomp.json teamflatearth/clearcote# docker compose
services:
clearcote:
image: teamflatearth/clearcote
ports: ["127.0.0.1:9222:9222"]
security_opt: ["seccomp=./clearcote-seccomp.json"]- The log says which way it went:
[clearcote] sandbox: on (…), or[clearcote] sandbox: OFF, Chrome runs with --no-sandbox: …with the reason and how to turn it on. Theserve-stateline carries"sandbox": trueorfalse. Without the profile the container runs exactly as before. - It also needs the image's own user (not
--user 0),CAP_SYS_CHROOT(granted by default; add--cap-add SYS_CHROOTback after--cap-drop ALL), a host that allows unprivileged user namespaces, and an amd64 machine. When one is missing the container falls back to--no-sandboxand says why. - The trade: with the profile, any process in the container, not only Chrome, can create a user namespace with its own network namespace, which reaches kernel networking code Docker's default profile keeps out of reach. Chrome's renderers, where untrusted pages run, stay under Chrome's own filter. Without the sandbox, a compromised renderer has everything the container's user has, so the profile is still the better choice. Do not use
--privileged,--cap-add SYS_ADMINorseccomp=unconfinedinstead: they turn off far more of the container's protection. CC_EXTRA_ARGS=--no-sandboxturns it off on purpose.--no-zygote,--disable-namespace-sandbox,--renderer-cmd-prefix=and the canvas bridge (--canvas-bridge-url=) turn it off too, because Chrome cannot run its sandbox with them here.- The SDKs' macOS launch passes the profile itself, on a Docker that runs on an x86_64 CPU. On Apple silicon the image runs emulated, and the sandbox stays off there for now.
Standing CDP endpoint from the SDK — serve()
Launch the binary yourself and get a cdp_url any client attaches to. Same stealthy direct launch as the image, driven from your own process.
from clearcote import serve
srv = serve(fingerprint="seed-123", platform="windows") # -> srv.cdp_url
# attach ANY CDP client:
# playwright: p.chromium.connect_over_cdp(srv.cdp_url)
# puppeteer: puppeteer.connect({ browserURL: srv.cdp_url, defaultViewport: null })
# browser-use / Crawl4AI / Stagehand: point them at srv.cdp_url
srv.close()import { serve } from "clearcote";
const srv = await serve({ fingerprint: "seed-123", platform: "windows" });
// srv.cdpUrl -> connectOverCDP / puppeteer.connect({ browserURL, defaultViewport: null })
await srv.close();Headless, the served browser gets a real-size display — the persona's, or one drawn from real desktops — and its window is fitted to the work area before any client attaches, so every page, tab and popup reports a window that fits its screen (SDK 0.31+). Pass windowSize: { width, height } (window_size in Python, WindowSize in .NET) for a smaller window, or your own --window-size in args to take over.
One endpoint, many identities — clearcote serve
From the shell (Python and Node packages, 0.29+), clearcote serve runs a standing endpoint that starts a separate browser for each identity a connection asks for — its seed, proxy, timezone and language taken from the connection URL. The same seed reuses its running browser.
clearcote serve --port 9222 --idle-timeout 300 --max-browsers 16
# then, from any Playwright client:
# chromium.connect_over_cdp("http://127.0.0.1:9222?fingerprint=acct-1&platform=windows")
# chromium.connect_over_cdp("http://127.0.0.1:9222?fingerprint=acct-2&proxy=socks5://user:pass@host:1080&geoip=true")
# (a password-protected proxy needs the licensed build)It binds to localhost by default and refuses requests a web page could make. Idle identities close after --idle-timeout seconds, --max-browsers caps how many run at once (further identities get HTTP 429), --data-dir keeps each identity's profile across restarts, and --allow-host / --allow-origin let it run behind a reverse proxy. Open http://127.0.0.1:9222/ to see what is running. On a Free with GitHub key only one browser runs at a time. The older single-identity clearcote-serve --port 9222 --fingerprint seed-123 script is still in the Python package.
Direct — the open build, no SDK
The open build on the Releases page is a plain Chromium binary you can launch yourself with executable_path and switches. The licensed build needs the SDK, which holds its licence token. The extra arguments below are the main defaults the SDK adds for this seed on Windows (it also merges feature flags and, behind a proxy, turns off QUIC):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=r"C:\clearcote\chrome.exe",
ignore_default_args=["--enable-automation", "--enable-unsafe-swiftshader"],
args=[
"--fingerprint=seed-123",
"--fingerprint-platform=windows",
"--fingerprint-brand=chrome",
"--accept-lang=en-US,en",
"--lang=en-US",
"--timezone=America/New_York",
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
"--ignore-gpu-blocklist",
],
)
browser.new_page().goto("https://example.com")Build your own image (SDK-driven)
Clearcote ships a Linux x64 binary, so it runs headless in a container. The image needs the browser runtime libraries, fontconfig and the SDK. The browser uses its release's own font bundle rather than the system fonts, so fonts you install with apt stay invisible to it until you list them: link the script fonts into one directory and name it in CLEARCOTE_FALLBACK_FONT_DIRS (SDK 0.41.0+), as the official image does, or your own fonts in CLEARCOTE_FONT_DIRS (see Fonts). Never list all of /usr/share/fonts: its Latin families would change a Windows persona's widths. On Linux the persona defaults to a coherent native Linux identity. WebRTC leak-proofing is on by default; the Privacy Sandbox APIs stay on, as in Google Chrome. With libegl1 and libgl1-mesa-dri installed, SDK 0.39.0 and newer render a headless Linux persona's WebGL through Mesa, with the limits real Linux machines report; without them it falls back to SwiftShader.
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
xz-utils libnss3 libnspr4 libgbm1 libasound2 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libxfixes3 libxext6 libxrender1 \
libpango-1.0-0 libcairo2 libx11-6 libxcb1 libexpat1 libdbus-1-3 ca-certificates libegl1 libgl1-mesa-dri \
fontconfig fonts-noto-color-emoji fonts-ipafont-gothic fonts-wqy-zenhei \
&& F=/usr/local/share/clearcote/fonts && mkdir -p $F && cd /usr/share/fonts \
&& cp -l truetype/noto/NotoColorEmoji.ttf truetype/wqy/wqy-zenhei.ttc opentype/ipafont-gothic/ipag.ttf $F/ \
&& rm -rf /var/lib/apt/lists/*
# emoji + CJK, used only where the bundle has no glyph
ENV CLEARCOTE_FALLBACK_FONT_DIRS=/usr/local/share/clearcote/fonts
WORKDIR /app
RUN npm i clearcote
RUN node --input-type=module -e "import { download } from 'clearcote'; await download();" # bake the binary in
COPY run.mjs .
CMD ["node", "run.mjs"]import { launchPersistentContext } from "clearcote";
const ctx = await launchPersistentContext("/tmp/prof", {
headless: true,
fingerprint: "user-1",
proxy: { server: "http://gateway:8080", username: "u", password: "p" },
geoip: true, // timezone + languages + WebRTC IP matched to the proxy exit
humanize: true, // trusted bezier input; navigator.webdriver stays false
args: ["--no-sandbox"],
});
const page = ctx.pages()[0] ?? (await ctx.newPage());
await page.goto("https://example.com");
await ctx.close();Run the container with--shm-size=1gto avoid/dev/shmcrashes on heavy pages. Python is identical (from clearcote import launch_persistent_context,snake_caseoptions).
What attaches over CDP
| Client | How |
|---|---|
| Playwright | chromium.connect_over_cdp(url) / connectOverCDP(url) |
| Puppeteer | puppeteer.connect({ browserURL: url, defaultViewport: null }) |
| browser-use · Crawl4AI · Stagehand | point their CDP/endpoint setting at the URL |
| Anything speaking CDP | the raw DevTools Protocol on the port |
Want an AI agent to drive it instead? See the MCP server. Curious why a direct launch is stealthier? See How detection works.