# Base stack — works standalone for local smoke testing (`docker compose up`). # The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik) # terminates TLS for bookmark-api. and forwards to it. # # If your proxy runs in Docker on its own network, use the prod override which # attaches to that network instead of publishing a port: # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d services: bookmark-api: build: ./backend image: bookmarkmanager-backend:latest container_name: bookmark-api restart: unless-stopped environment: # API_TOKEN is required — compose refuses to start without it. API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to} DB_PATH: /data/bookmarks.db PORT: "8080" # Gates the browser UI. Unset means the web routes are not served at all. WEB_PASSWORD: ${WEB_PASSWORD:-} # Path inside the container; matches the bindmount above. USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js} # Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill # switch; it only takes effect because these are listed here. LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1} LATEST_CHAPTER_POLL_COOLDOWN: ${LATEST_CHAPTER_POLL_COOLDOWN:-1h} LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m} LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14} LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s} # CDP endpoint for sites behind a JavaScript challenge (kagane). Unset # disables browser polling for those sites; the userscript still covers them. # Must be an IP, not the "headless-shell" DNS name: Chrome's DevTools HTTP # handler rejects the discovery request (GET /json/version) with a 500 # unless the Host header is an IP address or "localhost" — confirmed # 2026-08-03 against chromedp/headless-shell:stable, independent of # chromedp's own dial logic. The sidecar's static address below exists so # this URL survives container recreation. BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222} depends_on: - headless-shell volumes: - bookmarks-data:/data # The userscript is served from here, read fresh on every request. Editing # the file in this checkout takes effect on the next Violentmonkey poll — # no rebuild, no restart. `git pull` restores the committed version, which # is why a redeploy always ships the repo's script. - ./userscript:/userscript:ro # Bound to loopback only: the proxy (or curl during smoke test) reaches it, # the public internet does not. ports: - "127.0.0.1:8080:8080" networks: - browser headless-shell: image: chromedp/headless-shell:stable restart: unless-stopped # Chrome allocates shared memory per tab and dies on Docker's 64MB default. shm_size: '1gb' # Reaps zombie renderer processes, which otherwise accumulate for the # container's lifetime. init: true # Deliberately no `ports:` — an exposed CDP endpoint is remote code # execution. Only bookmark-api, via the `browser` network below, may reach it. # Don't pass --remote-debugging-address/--remote-debugging-port here: the # image's own entrypoint (/headless-shell/run.sh) already starts Chrome on # 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222. # Redeclaring the port flag here overrides Chrome's, so it binds 9222 # directly (IPv6 loopback only) instead of 9223 — collides with socat's own # bind on 9222 and leaves nothing listening on 9223, so every external # connection to headless-shell:9222 fails with EOF. Only pass flags the # entrypoint doesn't already set. command: - --disable-gpu - --no-sandbox networks: browser: # Pinned so BROWSER_WS_URL can name an IP (required, see above) that # survives `docker compose up` recreating this container. ipv4_address: 172.28.0.10 volumes: bookmarks-data: networks: # Not `internal: true`: headless Chrome still needs outbound access to reach # kagane.to. Isolation here comes from membership (only bookmark-api and # headless-shell join it), not from cutting egress. browser: ipam: config: - subnet: 172.28.0.0/24