---
name: cloudflare-workers
description: "Cloudflare Workers: TypeScript routing, Durable Objects, CORS, rate limiting, session management, and deployment patterns."
version: 1.0.0
platforms: [linux, macos]
---

# Cloudflare Workers

## Overview

TypeScript-based serverless functions deployed via `wrangler`. Covers the patterns used in the Unspooled backend: addon registry, catalog relay, and addon config session management.

## Project Structure

```
worker/
├── src/
│   ├── index.ts          # Route handler + entry point
│   ├── models.ts         # TypeScript interfaces/types
│   ├── validate.ts       # Manifest validation
│   ├── page.ts           # HTML page rendering
│   ├── code.ts           # Session code generation
│   └── do.ts             # Durable Object class
├── wrangler.toml         # Worker config (routes, DO bindings, vars)
├── tsconfig.json         # TypeScript config
└── package.json          # Dependencies + scripts
```

## Free Plan Constraints

Cloudflare Workers **Free plan** requires:

- **`new_sqlite_classes` in migrations** — you CANNOT use `new_classes` for new DO namespaces. If you already have `new_classes` for v1, you must keep it and use `new_sqlite_classes` with a new tag for any additional DOs.
- **No `stub.method()` RPC** — direct method calls on DO stubs (`stub.createSession()`, `stub.getStatus()`) do NOT work on the free plan. You MUST route via `stub.fetch(request)`.
- **Separate workers** — if you corrupt a migration tag (e.g., tried `new_classes` then switched to `new_sqlite_classes`), you must delete the worker entirely and recreate, or use a new migration tag.

## wranlger.toml Template

```toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-05-31"
workers_dev = true

[[durable_objects.bindings]]
name = "MY_DO"
class_name = "MyDurableObject"

# FREE PLAN: use new_sqlite_classes for any NEW DO namespace
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyDurableObject"]

[vars]
PUBLIC_HOST = "https://my-worker.example.com"
```

## Key Patterns

### 1. Route handler with CORS

Always handle OPTIONS (preflight) and wrap routes in try/catch:

```typescript
const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    try {
      // Route matching with regex or manual path parsing
      const match = url.pathname.match(/^\/api\/resource\/([a-z0-9]+)$/);
      if (match && request.method === "GET") {
        return handleGet(match[1]);
      }
      // ... more routes
      return json({ error: "Not found" }, 404);
    } catch (e) {
      return json({ error: "Internal error", message: (e as Error).message }, 500);
    }
  },
};
```

### 2. JSON response helper

```typescript
function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
  });
}
```

### 3. Durable Objects for session management (free-plan-safe)

DOs provide strongly-consistent storage — ideal for temporary sessions, game state, or coordination:

**IMPORTANT (Free Plan):** `stub.method()` RPC does NOT work on the free plan. You MUST route all DO interactions through `stub.fetch(request)` — the DO class implements a single `fetch()` handler that dispatches based on URL path.

```typescript
// ── Worker entry point: routes to DO via fetch() ──

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Create a new session by routing to the DO
    if (url.pathname === "/api/sessions" && request.method === "POST") {
      const code = createCode();
      const id = env.MY_DO.idFromName(code);
      const stub = env.MY_DO.get(id);
      // Route the request to the DO's fetch() handler
      return stub.fetch(new Request(
        `http://do/sessions/create?code=${code}`,
        { method: "POST", body: request.body, headers: request.headers }
      ));
    }

    // Poll session status
    const pollMatch = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)$/);
    if (pollMatch && request.method === "GET") {
      const id = env.MY_DO.idFromName(pollMatch[1]);
      const stub = env.MY_DO.get(id);
      return stub.fetch(new Request(`http://do/sessions/status`));
    }

    return json({ error: "Not found" }, 404);
  },
};
```

```typescript
// ── Durable Object: single fetch() dispatcher ──

export class SessionDO implements DurableObject {
  private storage: DurableObjectStorage;

  constructor(ctx: DurableObjectState, _env: unknown) {
    this.storage = ctx.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    try {
      if (path === "/sessions/create") {
        const code = url.searchParams.get("code") || "";
        const session = { code, status: "pending", createdAt: Date.now() };
        await this.storage.put("session", session);
        await this.storage.setAlarm(Date.now() + 30 * 60 * 1000);
        return json(session, 201);
      }

      if (path === "/sessions/status") {
        const s = await this.storage.get<any>("session");
        if (!s) return json({ error: "Not found" }, 404);
        if (Date.now() > s.expiresAt && s.status !== "completed") {
          s.status = "cancelled";
          await this.storage.put("session", s);
        }
        return json(s);
      }

      return json({ error: "Not found" }, 404);
    } catch (e) {
      return json({ error: (e as Error).message }, 500);
    }
  }

  async alarm(): Promise<void> {
    const s = await this.storage.get<any>("session");
    if (s && (s.status === "pending" || s.status === "active")) {
      s.status = "cancelled";
      await this.storage.put("session", s);
    }
  }
}
```

**Key DO patterns:**
- `DurableObjectNamespace.idFromName(name)` — deterministic ID from a string (e.g., session code)
- `DurableObjectNamespace.get(id)` — gets the DO stub
- `stub.fetch(request)` — routes an HTTP request to the DO's `fetch()` handler (use this, NOT `stub.method()`)
- `storage.put/get` — strongly consistent key-value storage
- `storage.setAlarm(Date.now() + ttlMs)` — schedule an alarm callback. **CRITICAL**: `setAlarm()` expects an ABSOLUTE timestamp (ms since epoch), NOT a duration. `setAlarm(900000)` is epoch 1970 and fires instantly, cancelling all sessions. Always use `setAlarm(Date.now() + SESSION_TTL_MS)`.
- **DO NOT use `stub.method()` RPC** — fails silently on free plan (error 1101)

### 4. Rate limiting (in-memory)

Simple per-IP rate limiting for Workers (no external storage needed):

```typescript
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
const MAX_REQUESTS = 30;
const requestCounts = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(clientIp: string): boolean {
  const now = Date.now();
  const entry = requestCounts.get(clientIp);
  if (!entry || now > entry.resetAt) {
    requestCounts.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
    return true;
  }
  if (entry.count >= MAX_REQUESTS) return false;
  entry.count++;
  return true;
}
```

### 5. HTML page rendering from Worker

Workers can serve HTML directly — useful for QR code landing pages, config forms, etc.:

```typescript
function renderPage(params: { code: string; appName: string; status: string }): string {
  return `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  body { font-family: -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
  .card { background: #1a1a2e; border-radius: 16px; padding: 32px; }
  .btn { display: inline-flex; padding: 14px 24px; border-radius: 12px; background: #00a3ff; color: #fff; }
</style>
</head>
<body>
  <h1>Configure ${escapeHtml(params.appName)}</h1>
  <!-- page content -->
</body>
</html>`;
}
```

### 6. Manifest validation for Stremio addons

When validating configured addon URLs, fetch the manifest and confirm required fields:

```typescript
async function validateManifest(url: string): Promise<{ valid: boolean; error: string | null }> {
  const response = await fetch(url, {
    headers: { "User-Agent": "Worker/1.0", Accept: "application/json" },
  });
  if (!response.ok) return { valid: false, error: `HTTP ${response.status}` };
  const body = await response.json() as Record<string, unknown>;
  if (!body.id || !body.name) return { valid: false, error: "Missing id or name" };
  const resources = body.resources as Array<unknown> | undefined;
  if (!resources?.length) return { valid: false, error: "Missing resources" };
  return { valid: true, error: null };
}
```

## Deployment

```bash
cd services/<worker-directory>
npx wrangler deploy          # Production deploy
npx wrangler dev             # Local dev (with DO support)
npx wrangler secret put KEY  # Set a secret
```

## Pitfalls

- **`crypto` is not typed** in `@cloudflare/workers-types` — use a try/catch wrapper or `// @ts-expect-error` when using `crypto.getRandomValues()`.
- **DO alarm TTLs are minimum 30 seconds** — don't use alarms for sub-30s scheduling.
- **Durable Objects are regional** — DOs live in a single location; high-latency cross-region access is possible.
- **Rate limit state is per-isolate** — in-memory rate limiting resets on cold starts. For production, use DO or KV for persistent rate limits.
- **CORS preflight** — always handle `OPTIONS` before your route matching, with a 204 response.
- **Durable Object IDs** — `idFromName()` is deterministic (same name → same DO). For sessions, use random codes as names to isolate sessions.
- **DO storage has 128KB per-value limit** — keep session data compact.
- **Free plan: `new_sqlite_classes` for DOs** — you CANNOT create a new DO namespace with `new_classes` on the free plan. If you already have a DO created with `new_classes`, you CANNOT change it to `new_sqlite_classes` in a later deploy — the migration tag is already tracked. Use a new tag (`v2`, `v3`) with `new_sqlite_classes` for any ADDITIONAL DOs. Never change an already-applied migration's class type.
- **DO RPC `stub.method()` — NOT supported on free plan.** Always use `stub.fetch(request)` to route HTTP requests through the DO's `fetch()` handler. Calling `stub.createSession()` or any `stub.method()` will fail with error 1101 on free plans.
- **Migration tags are immutable** — once a tag like `v1` is applied with `new_classes = ["FooDO"]`, you CANNOT redeploy with `new_sqlite_classes = ["FooDO"]` under the same tag. If you need to fix a migration, either:
  - Use a fresh migration tag (`v2`, `v3`, etc.)
  - Delete the worker entirely (`wrangler delete`) and redeploy from scratch
  - Split into a separate worker (see "Separate workers" ref pattern)
- **`wrangler tail` requires interactive auth** — on headless servers, use `wrangler tail --format json` with `CLOUDFLARE_API_TOKEN` env var. If auth keeps failing, use Python subprocess with the token read from a file.
- **CLOUDFLARE_API_TOKEN in non-interactive shells** — when the token appears in commands, the system may redact/mangle it with `***`. Workaround: write the token to a file (`echo -n '...' > /tmp/cf_token`) and read it via `TOKEN=*** /tmp/cf_token)` in shell, or use Python `os.environ['CLOUDFLARE_API_TOKEN'] = open('/tmp/cf_token').read().strip()` and spawn wrangler via subprocess.
- **Shell `$()` / backtick escaping** — when passing CF tokens via inline `$(cat /tmp/cf_token)`, the heredoc/escape layers in the agent's terminal tool often cause `syntax error near unexpected token ')'`. Workaround: use Python's `execute_code` with `from hermes_tools import terminal` and f-string interpolation to avoid shell escaping entirely. Example: `terminal(f"cd ... && CLOUDFLARE_API_TOKEN=*** npx wrangler deploy", timeout=120)`.
- **`configuredApiKey` not in poll response** — after a user submits an API key via the phone form, the DO stores it but the outer worker's `handleAuthStatus` may omit it from the poll response. The Android client expects `configuredApiKey` field in the JSON. Always include `configuredApiKey: s.apiKey || s.token || null` in the poll response when status is "completed".
- **Provider ID normalization** — Android enums (e.g., `REAL_DEBRID`) produce `"real_debrid"` after `.lowercase()`, but workers expect `"realdebrid"`. Normalize with `.replace(/_/g, "").toLowerCase()` on the worker side to handle both formats.

# Cloudflare Workers Deployment

Deploy TypeScript Workers with Durable Objects for auth/config sessions. Covers free-plan constraints and QR-code-based phone-to-TV auth flows.

## Free Plan Constraints

- **DO migrations MUST use `new_sqlite_classes`** — `new_classes` is rejected on free plans
- **`stub.method()` RPC is NOT supported** — calling `await stub.myMethod()` throws error 1101
- **Use `stub.fetch(request)` HTTP routing instead** — route all DO operations through a single `fetch()` handler on the DO class, dispatching by URL pathname

### Pattern: DO with `fetch()` dispatch

```typescript
export class MyDO implements DurableObject {
  private state: DurableObjectState;
  private storage: DurableObjectStorage;

  constructor(ctx: DurableObjectState, _env: unknown) {
    this.state = ctx;
    this.storage = ctx.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/action1") return this.handleAction1(await request.json());
    if (url.pathname === "/status") return this.handleStatus();
    return json({ error: "Not found" }, 404);
  }
}
```

### Pattern: Worker calling DO via `stub.fetch()`

```typescript
// Instead of: const result = await stub.myMethod(data)
const r = await stub.fetch(new Request(env.PUBLIC_HOST + "/action1", {
  method: "POST",
  body: JSON.stringify(data),
  headers: { "Content-Type": "application/json" },
}));
const result = await r.json() as any;
```

## Deployment

### First deploy with fresh DO namespace

```toml
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-05-31"
workers_dev = true

[[durable_objects.bindings]]
name = "MY_DO"
class_name = "MyDO"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyDO"]
```

```bash
npx wrangler deploy
```

### Re-deploy after DO namespace change

If you change the DO class name or need a clean namespace:
1. Delete the worker via API: `curl -X DELETE "https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/workers/scripts/{WORKER_NAME}"`
2. Use a **fresh worker name** in `wrangler.toml` — the old DO namespace persists in Cloudflare and blocks re-creation with a different migration type
3. Deploy fresh

### Token authentication

Use `CLOUDFLARE_API_TOKEN` env var. For non-interactive deployments:

```bash
export CLOUDFLARE_API_TOKEN="cfut_..."
npx wrangler deploy
```

Note: the system may redact the token value from tool output and source code. Keep a copy in a file (`/tmp/cf_token`) and read it at runtime.

## Current Unspooled Worker URLs

Three deployed workers, all from the same source code:

| Worker | URL | DO Namespaces | Usage |
|--------|-----|---------------|-------|
| `unspooled-config-v3` | `https://unspooled-config-v3.tw24fr.workers.dev` | `ADDON_CONFIG_SESSIONS` + `AUTH_SESSIONS` | **Primary** — all API routes merged |
| `addon-config-worker` | `https://addon-config-worker.tw24fr.workers.dev` | `ADDON_CONFIG_SESSIONS` + `AUTH_SESSIONS` | Legacy redirect — same code as config-v3 |
| `unspooled-auth-v2` | `https://unspooled-auth-v2.tw24fr.workers.dev` | `AUTH_SESSIONS` only | Auth-only from `auth_index.ts` (older code) |

**The `unspooled-config-v3` worker is the canonical URL.** All Android clients should point here. It has both DO namespaces and all routes (config sessions + debrid auth + addon URL + smoke test) in a single `index.ts`.

### Endpoint layout (merged worker at `unspooled-config-v3`)

All routes now live on a single worker:

**Config sessions:**
- `POST /api/addons/config-sessions` — create session
- `GET /api/addons/config-sessions/{code}` — poll status
- `POST .../complete` — complete with configured URL
- `POST .../cancel` — cancel
- `GET /addons/config/{code}` — phone HTML form page

**Debrid auth:**
- `POST /api/debrid/auth-sessions` — create session (normalizes providerId: `.replace(/_/g,"").toLowerCase()`)
- `GET /api/debrid/auth-sessions/{code}` — poll status (returns `configuredApiKey` when completed)
- `POST /api/debrid/auth-sessions/{code}/cancel` — cancel
- `GET /debrid/auth/{code}` — phone HTML form (QR landing page)
- `GET /debrid/auth/form/{code}` — HTML form for API key entry
- `POST /debrid/auth/submit/{code}` — submit API key

**Addon URL:**
- `POST /api/addons/url-sessions` — create session
- `GET /api/addons/url-sessions/{code}` — poll status
- `POST /api/addons/url-sessions/{code}/cancel` — cancel
- `GET /addons/url/{code}` — phone HTML form (QR landing page)
- `GET /addon/url/form/{code}` — URL entry form
- `POST /addon/url/submit/{code}` — submit manifest URL

**Smoke test:**
- `POST /api/v1/smoke-test/run` — test all addons
- `GET /api/v1/smoke-test/status` — latest results

## Pitfalls

- **Stale migration tags**: Once a migration tag (v1, v2) is applied, removing it from `wrangler.toml` breaks redeployment. Keep tags in sync or use a fresh worker name.
- **DO namespace persistence**: Deleting a worker via API does NOT immediately delete its DO namespace. Use a new worker name when changing migration type.
- **TypeScript errors about DurableObjectStub methods**: The LSP complains that `fetch()`, `createSession()`, etc. don't exist on `DurableObjectStub<undefined>` — these are type-level only, safe to ignore at runtime.
- **Error 1101 (Worker threw exception)**: Usually means an uncaught exception in the DO handler. Check if you're using `stub.method()` RPC (not supported) vs `stub.fetch()` routing.

## Android Client Integration

Must update these files when worker URLs change:
- `ConfigSessionApiClient.kt` — uses `BuildConfig.ADDON_CONFIG_WORKER_URL`
- `AddonUrlApiClient.kt` — uses `BuildConfig.ADDON_AUTH_WORKER_URL` (was `ADDON_CONFIG_WORKER_URL` before 2026-05-31)
- `DebridAuthApiClient.kt` — uses `BuildConfig.ADDON_AUTH_WORKER_URL` (was `ADDON_CONFIG_WORKER_URL` before 2026-05-31)
- `build.gradle.kts` — defines both `ADDON_CONFIG_WORKER_URL` and `ADDON_AUTH_WORKER_URL` build secrets with fallbacks:
  - `addonConfigWorkerUrl` fallback: `https://unspooled-config-v3.tw24fr.workers.dev`
  - `addonAuthWorkerUrl` fallback: `https://unspooled-config-v3.tw24fr.workers.dev`
- `DebridAuthViewModel.kt` — provider ID mapping: `provider.name.lowercase().replace("_", "")` converts enum `REAL_DEBRID` → `"realdebrid"`

**Critical: Separate auth and config URLs.** Do NOT point auth clients at the config worker URL unless the routes are merged. Before 2026-05-31, all three clients used `ADDON_CONFIG_WORKER_URL` which hit the config worker — but auth routes only existed on the auth worker, causing 404s. The fix: add `ADDON_AUTH_WORKER_URL` as a separate build config field.

## When to Load

Load this skill when you need to:
- Build a lightweight HTTP microservice (scraper proxy, API gateway, config server) for an Android app or web frontend
- Containerize a Go (or static binary) service with Docker multi-stage build
- Deploy behind nginx reverse proxy on a self-managed Debian/Ubuntu server
- Set up a reliable docker-compose lifecycle with resource limits and restart policies
- Move a service from one server to another (Docker makes this trivial)

## Architecture Pattern

```
Android App / Client
     │
     ▼
NGINX (port 80/443)     ← reverse proxy, rate limiting, auth
     │
     ▼
Docker Container         ← Go service, port 8099
     │                    ← memory: 64MB, cpu: 0.5
     ▼
External APIs / Scrapers ← upstream data sources
```

## Workflow

### 1. Build the Go Service

```go
// main.go — minimal Fiber server with health endpoint
package main

import (
    "log"
    "os"
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/recover"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" { port = "8099" }

    app := fiber.New(fiber.Config{
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
        AppName:      "service-name",
    })

    app.Use(logger.New())
    app.Use(recover.New())
    app.Use(cors.New(cors.Config{AllowOrigins: "*", AllowMethods: "GET, OPTIONS"}))

    app.Get("/health", func(c *fiber.Ctx) error {
        return c.JSON(fiber.Map{"status": "ok", "service": "service-name"})
    })

    // Add your routes here...

    log.Printf("Starting on :%s", port)
    log.Fatal(app.Listen(":" + port))
}
```

### 2. Multi-Stage Dockerfile

```dockerfile
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /build
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o service .

# Runtime stage — distroless for minimal image
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/service /app/service
EXPOSE 8099
ENTRYPOINT ["/app/service"]
```

**When to use `gcr.io/distroless` vs `alpine`:**
- **distroless** — 12MB image, no shell, no package manager. Use for production. Secure but hard to debug.
- **alpine** — 25MB with shell. Use for development/debugging where you might need `docker exec -it`.
- Switch by commenting out the runtime stage.

**Common dependency pitfalls:**
- `go mod tidy` requires `git` in the builder even if no direct git dep is used (Colly and its deps pull from GitHub).
- `go.sum` must exist before `COPY go.mod go.sum ./` — generate with `docker run --rm -v $(pwd):/build -w /build golang:1.23-alpine sh -c "apk add --no-cache git && go mod tidy"`.
- If the `golang:*` builder image needs a newer Go version than what the go.mod specifies, update `go.mod` line: `go 1.23`.

### 3. docker-compose.yml

```yaml
services:
  service-name:
    image: org/service-name:latest
    container_name: service-name
    restart: unless-stopped
    ports:
      - "127.0.0.1:8099:8099"    # bind to localhost only (nginx proxied)
    environment:
      - PORT=8099
    mem_limit: 64m
    cpus: 0.5
```

**Resource limits rationale:** Go services with Fiber use ~15MB idle. 64MB gives headroom for concurrent request handling. 0.5 CPU prevents a single misbehaving scraper from starving the host.

**Port binding to 127.0.0.1:** Critical security measure — the service is only reachable through nginx, not directly from the network. Attackers can't bypass nginx's rate limiting or caching.

### 4. Deploy

```bash
docker compose up -d
# or without compose:
docker run -d \
    --name service-name \
    --restart unless-stopped \
    -p 127.0.0.1:8099:8099 \
    --memory 64m --cpus 0.5 \
    org/service-name:latest
```

### 5. nginx Reverse Proxy

Add a location block to the default server config:

```nginx
# /etc/nginx/sites-enabled/default or a separate file

# Upstream
upstream service_name {
    server 127.0.0.1:8099;
    keepalive 64;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    # ... existing config ...

    # Proxy endpoint — app calls /api/service/health
    location ~ ^/api/service/(.*)$ {
        proxy_pass http://service_name/$1$is_args$args;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 10s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;

        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;

        access_log /var/log/nginx/service_access.log;
        error_log /var/log/nginx/service_error.log;
    }
}
```

**Multiple location blocks for the same upstream:** When a single Go service serves multiple route families (e.g., `/api/scraper/*` for scraper endpoints AND `/api/remote/*` for web remote), use separate location blocks instead of a single regex:

```nginx
# Web Remote — clean paths, no prefix stripping
location /remote {
    proxy_pass http://scraper_proxy/remote;
}
location /api/remote/ {
    proxy_pass http://scraper_proxy/api/remote/;
}

# Scraper API — strips /api/scraper/ prefix via regex capture
location ~ ^/api/scraper/(.*)$ {
    proxy_pass http://scraper_proxy/$1$is_args$args;
}
```

This avoids the path-mangling issue where `/api/scraper/api/remote/send` would result from trying to cram everything through the regex location.

**Key nginx config details:**
- The regex location `~ ^/api/service/(.*)$` captures everything after `/api/service/` and passes it to the upstream as `/$1`. So `/api/service/health` → `/health`, `/api/service/api/links/movie/tt1234567` → `/api/links/movie/tt1234567`.
- This provides a clean public URL path while allowing the Go service to use whatever internal route structure it wants.
- `$is_args$args` preserves query parameters.

### 6. Verify

```bash
# Through nginx
curl -s http://localhost/api/service/health

# Direct (only works from localhost due to binding)
curl -s http://127.0.0.1:8099/health

# Check container
docker logs service-name
docker stats service-name --no-stream
```

## Common Upgrades

### In-Memory Caching with TTL

```go
import "github.com/gofiber/storage/memory/v2"

var cache *memory.Storage

func initCache() {
    cache = memory.New(memory.Config{GCInterval: 60 * time.Second})
}

func setCache(key string, data []byte) {
    cache.Set(key, data, 5 * time.Minute)  // TTL
}

func getCached(key string) []byte {
    data, err := cache.Get(key)
    if err != nil { return nil }
    return data
}
```

### Rate Limiting

```go
import "github.com/gofiber/fiber/v2/middleware/limiter"

app.Use(limiter.New(limiter.Config{
    Max:        100,
    Expiration: 1 * time.Minute,
}))
```

## Pitfalls

- **Embed resolvers produce false positives**: When extracting video URLs from embed pages via regex, CDN/library URLs (github.com, cdnjs.cloudflare.com, etc.) get matched. Always filter with an allowlist (`isVideoURL()` checking `.m3u8`/`.mp4`/`.webm` extension + domain exclusion list). Without this, clients try to play non-video URLs and fail.
- **Go version mismatch**: The `golang:*` builder Docker image must match the `go` version in `go.mod`. If `memory` storage requires `go 1.23`, change both.
- **go.sum not found**: Generate it before building. `docker run --rm ... golang:* sh -c "go mod tidy"` is the cleanest way.
- **nginx rewrite vs proxy_pass**: Use the regex location `~ ^/path/(.*)$` pattern with `proxy_pass http://upstream/$1` — this strips the prefix without the `rewrite ... break` gotcha.
- **Container won't restart after reboot**: Use `--restart unless-stopped` (NOT `always` — `always` restarts even after manual `docker stop`).
- **Port already in use**: Check with `ss -tlnp | grep 8099`. Bind to `127.0.0.1:8099` to avoid conflicts with Docker bridge networks.
- **Large Docker images**: Multi-stage builds with `distroless` produce ~25MB images. If using `alpine`, expect ~80MB. Never use the full `golang` image (1GB+) for runtime.
- **Memory leaks from scraping**: Colly's `Async(true)` with `Limit()` prevents runaway goroutines. Always set both `Parallelism` and `Delay`.
- **fish shell heredoc limitation**: On systems where the shell is fish, `<< 'EOF'` heredocs don't work for nginx config writes. Use `sudo tee /path/to/file > /dev/null << 'NGINXCONF'` ... `NGINXCONF` which writes via sudo without heredoc in fish.

## Verification

After deployment:
- [ ] Container running: `docker ps --filter name=service-name`
- [ ] Health endpoint: `curl -s http://127.0.0.1:8099/health`
- [ ] Through nginx: `curl -s http://localhost/api/service/health`
- [ ] nginx config valid: `sudo nginx -t`
- [ ] Memory within limits: `docker stats service-name --no-stream --format "{{.MemUsage}}"`
- [ ] Restart policy: `docker inspect service-name --format '{{.HostConfig.RestartPolicy.Name}}'` should show `unless-stopped`
