Desktop-bound GoLogin/AdsPower workflows do not scale on Linux CI. Multilogin cloud API lets workers on Ubuntu launch profiles remotely and attach via CDP — no GUI on the runner. This recipe covers headless flags, Xvfb fallback, and a GitHub Actions skeleton.

Architecture

┌─────────────────────┐         ┌──────────────────────────┐
│  GitHub Actions     │  HTTPS  │  Multilogin Cloud API    │
│  ubuntu-latest      │ ──────► │  profile/start → CDP URL │
│  Playwright client  │ ◄────── │  Mimic on MLX infra      │
└─────────────────────┘   WS    └──────────────────────────┘

Alternative: self-hosted Linux VM with Multilogin agent + local API — same CDP attach code, different base URL.

Headless launch payload

POST /profile/start
{
  "profile_id": "uuid",
  "headless": true,
  "automation": true
}

If headless crashes on target site (canvas/WebGL checks), retry with headless: false + Xvfb on a dedicated worker — not on shared GitHub-hosted runners for prod accounts.

Xvfb fallback (self-hosted VM)

sudo apt-get install -y xvfb
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
python run_job.py

GitHub Actions workflow

name: mlx-profile-job
on:
  workflow_dispatch:
    inputs:
      profile_id:
        required: true

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install httpx playwright
      - run: playwright install chromium
      - name: Run profile job
        env:
          MLX_TOKEN: ${{ secrets.MLX_TOKEN }}
          MLX_PROFILE_ID: ${{ inputs.profile_id }}
        run: python ci_mlx_job.py

ci_mlx_job.py (minimal)

import os
import sys
import httpx
from playwright.sync_api import sync_playwright

MLX = "https://api.multilogin.com"
TOKEN = os.environ["MLX_TOKEN"]
PROFILE = os.environ["MLX_PROFILE_ID"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}

def main():
    r = httpx.post(f"{MLX}/profile/start",
        json={"profile_id": PROFILE, "headless": True},
        headers=HEADERS, timeout=90)
    r.raise_for_status()
    cdp = r.json().get("cdp_url") or r.json().get("wsUrl")
    if not cdp:
        sys.exit("No CDP URL")

    try:
        with sync_playwright() as p:
            browser = p.chromium.connect_over_cdp(cdp, timeout=60_000)
            page = browser.contexts[0].pages[0]
            page.goto("https://example.com", wait_until="domcontentloaded")
            print("title:", page.title())
            browser.close()
    finally:
        httpx.post(f"{MLX}/profile/stop",
            json={"profile_id": PROFILE}, headers=HEADERS)

if __name__ == "__main__":
    main()

CI hardening checklist

When NOT to use GitHub-hosted runners

Shared runner IPs are datacenter ASNs — fine for smoke tests, risky for logged-in prod accounts. Use sticky residential proxy inside profile + dedicated self-hosted worker for revenue accounts. See proxy types and scaling guide.

Related

Disclosure: MLX-MMO affiliated with Multilogin. Headless and cloud API availability depend on your plan — verify with Multilogin.