Obscura Production Deployment — Docker, Systemd, Workers, and Tuning

Complete production deployment guide for Obscura. Docker image, Systemd service, multi-process workers, V8 heap tuning, reverse proxy, and security.

16Yun Engineering TeamJul 6, 20264 min read

From Dev to Production

Running obscura fetch locally is straightforward. Production requires process management, resource isolation, monitoring, and scaling. This article walks through Obscura's production deployment options and the operational decisions that matter most.

Docker

Based on distroless/cc (no shell, no package manager), ~57 MB compressed.

docker run -d \
  --name obscura \
  --restart unless-stopped \
  -p 127.0.0.1:9222:9222 \
  -v /srv/obscura/data:/data \
  h4ckf0r0day/obscura \
  serve --host 0.0.0.0 --storage-dir /data --stealth

The default command is serve; override it with arguments after the image name. Note the -p 127.0.0.1:9222:9222 binding — this keeps the CDP port reachable only from the host, never from the public network. Inside the container you bind 0.0.0.0 so the port-mapped listener can accept connections, but Docker only forwards the host's loopback to it.

Resource limits:

docker run -d \
  --name obscura \
  --memory=4g \
  --cpus=2 \
  -p 127.0.0.1:9222:9222 \
  h4ckf0r0day/obscura

Pair --memory with the V8 heap cap (--v8-flags "--max-old-space-size=...") — the container limit should sit comfortably above the V8 old-space ceiling so the engine has room for the DOM tree, network buffers, and Rust overhead. A common mistake is setting --memory=2g while leaving V8's default 4 GB old-space ceiling untouched, which gets the process OOM-killed under load.

Systemd

For direct host deployment, Systemd is the recommended supervisor.

# /etc/systemd/system/obscura.service
[Unit]
Description=Obscura headless browser
After=network.target
 
[Service]
ExecStart=/usr/local/bin/obscura serve \
  --port 9222 --stealth --storage-dir /var/lib/obscura
Restart=always
RestartSec=5
User=obscura
Group=obscura
LimitNOFILE=65536
MemoryMax=4G
MemoryHigh=3G
 
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now obscura
sudo journalctl -fu obscura

MemoryMax is the hard cgroup ceiling (the kernel OOM-kills above it); MemoryHigh is the soft threshold where the kernel starts reclaiming. Setting MemoryHigh slightly below MemoryMax gives the engine breathing room before a hard kill, which matters for long-running scrape sessions.

Multi-Worker Scaling

obscura serve --workers N spawns N processes (not threads):

obscura serve --workers 4

Each worker is a separate process with sequential ports starting from port+1:

main process (port 9222) → load balancer

  ├── Worker 1 (port 9223)
  ├── Worker 2 (port 9224)
  ├── Worker 3 (port 9225)
  └── Worker 4 (port 9226)

The main process runs a load balancer that forwards inbound connections to workers in round-robin. Sessions are sticky to one worker — once a browser context lands on a worker, all its subsequent CDP messages go to the same process.

This is the right way to scale Obscura, because all pages within a single process share one V8 Isolate (serialized through a mutex). Multiple workers means multiple isolates, which is the only way to get true JS parallelism. Rule of thumb: workers = CPU cores.

# 8-core host
obscura serve --workers 8

V8 Heap Tuning

Default heap settings by architecture:

Architecturemax-old-space-sizeOther defaults
64-bit4096 MB--max-semi-space-size=4 --optimize-for-size
Non-64-bit1024 MB--max-semi-space-size=4 --optimize-for-size

--optimize-for-size biases V8 toward memory efficiency over peak throughput, which helps keep RSS down — the right trade-off for a scraping workload where you care about concurrency, not single-page latency.

Override:

# Lower the ceiling for memory-constrained hosts
obscura serve --v8-flags "--max-old-space-size=2048"
 
# Raise it for heavy SPAs that build large DOMs
obscura serve --v8-flags "--max-old-space-size=8192 --max-semi-space-size=8"

Flags you pass are appended after the defaults, and V8 uses the last value for a repeated flag, so your override wins for that flag while the other defaults stay in effect.

Reverse Proxy and WebSocket

CDP runs over WebSocket, so the reverse proxy must support the WebSocket upgrade.

Nginx

location /obscura/ {
  proxy_pass http://127.0.0.1:9222/;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_read_timeout 86400;
}
 
# MCP HTTP transport
location /mcp/ {
  proxy_pass http://127.0.0.1:3000/;
  proxy_read_timeout 86400;
}

CDP sessions can stay open for a long time (an active Puppeteer/Playwright session), so proxy_read_timeout needs a large value (in seconds; 86400 = 24 hours). A default 60s proxy timeout will silently drop idle scraping sessions.

Caddy handles TLS and WebSocket upgrade automatically:

obscura.example.16yun.cn {
  reverse_proxy /obscura/* 127.0.0.1:9222
}

Security

Obscura has no built-in auth. Anyone who can reach the port can drive the browser, so the deployment boundary is the security boundary.

  • Default bind 127.0.0.1 — local only
  • Docker port mapping -p 127.0.0.1:9222:9222 — never expose to the public interface
  • Reverse proxy auth — add Basic Auth or OAuth at the proxy layer
  • Network isolation — use a Docker network to restrict who can reach the container
# NO — never bind the CDP port directly to the public network
obscura serve --host 0.0.0.0 --port 9222   # dangerous!
 
# YES — bind loopback and put an authenticating proxy in front
obscura serve --host 127.0.0.1 --port 9222

MCP HTTP transport has its own Origin allowlist for cross-origin browser protection:

OBSCURA_MCP_ALLOWED_ORIGINS="https://app.16yun.cn" \
  obscura mcp --http --host 0.0.0.0

Timeout Tuning

Three environment variables bound the timeouts that matter in production:

export OBSCURA_NAV_TIMEOUT_MS=60000          # navigation (default 30s)
export OBSCURA_CDP_COMMAND_TIMEOUT_MS=30000  # per CDP command (default 60s)
export OBSCURA_FETCH_TIMEOUT_MS=20000        # JS fetch/XHR (default 30s)
obscura serve

If you scrape slow sites, raise OBSCURA_NAV_TIMEOUT_MS. If a runaway page tends to hold the V8 lock, lower OBSCURA_CDP_COMMAND_TIMEOUT_MS so other sessions are not blocked as long.

Observability

obscura serve --verbose
RUST_LOG=obscura=debug obscura serve
RUST_LOG=obscura_cdp=trace,obscura_browser=debug obscura serve

--verbose on any subcommand is equivalent to RUST_LOG=obscura=info. Logs go to stderr, which makes them easy to redirect or pipe into journald / Docker's logging driver.

Production Checklist

Before going live, confirm each item:

  1. Auto-restart configured (Docker --restart or Systemd Restart=always)
  2. Resource limits set (MemoryMax, LimitNOFILE)
  3. V8 heap tuned for the page workload you actually scrape
  4. Reverse proxy handles WebSocket upgrade and long read timeouts
  5. Ports only exposed to trusted networks (loopback bind or private Docker network)
  6. Stealth mode enabled (unless you explicitly do not need it)
  7. Storage directory configured for cookie/localStorage persistence
  8. Log rotation in place
  9. Worker count load-tested for expected concurrency

Summary

Production deployment of Obscura is similar to a standard web service, with three key differences: WebSocket long-connection proxy configuration, multi-process worker scaling for real V8 parallelism, and V8 heap tuning matched to your page workload. Get those three right and the rest is routine operations.

Need an enterprise proxy plan?

We can tailor architecture to your target domains, concurrency, and reliability goals.