Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
services:
  privatools:
    build:
      context: .
      args:
        VITE_AUTH_PROVIDER: ${VITE_AUTH_PROVIDER:-clerk}
        VITE_CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY:-}
        VITE_CLERK_SOCIAL_PROVIDERS: ${VITE_CLERK_SOCIAL_PROVIDERS:-google,github}
        VITE_CLERK_USERNAME_ENABLED: ${VITE_CLERK_USERNAME_ENABLED:-true}
        VITE_CLERK_PASSKEYS_ENABLED: ${VITE_CLERK_PASSKEYS_ENABLED:-true}
    # The deploy pulls the cosign-signed image release.yml builds on each tag
    # (PRIVATOOLS_IMAGE=ghcr.io/...:vX.Y.Z) and runs it with `up --no-build`,
    # avoiding a heavy rebuild on the VM. Defaults to the locally-built name for
    # `docker compose up --build` (local dev / build fallback).
    image: ${PRIVATOOLS_IMAGE:-privatools-privatools:latest}
    ports:
      # Bind to loopback only. The app is meant to sit behind the host's nginx
      # (which terminates TLS and proxies to 127.0.0.1:8000). The previous
      # "8000:8000" published on 0.0.0.0, exposing the app on the VM's public
      # IP and letting clients bypass nginx/TLS and the real-IP forwarding.
      #
      # The host port is a variable only so a zero-downtime deploy can start
      # the next release beside this one: deploy/oracle-vm/rollout.sh runs a
      # temporary "interim" project on 127.0.0.1:8001 while this container is
      # replaced. Leave it unset; the steady state is always 8000.
      - "127.0.0.1:${PRIVATOOLS_HOST_PORT:-8000}:8000"
    volumes:
      - app-temp:/app/temp
      # Accounts and API keys. Deliberately NOT app-temp: the cleanup janitor
      # sweeps that volume by age and would delete the user table.
      - app-data:/app/data
    environment:
      - PRIVATOOLS_BUILD_SHA=${GIT_SHA:-unknown}
      - PORT=8000
      - PRIVATOOLS_DATA_DIR=/app/data
      # Optional durable jobs run beside the web workers in this container,
      # sharing its existing 4 GB / 1.8 CPU envelope. Enable after validating
      # worker readiness and the retained-result lifecycle on the target host.
      - API_V1_JOBS_ENABLED=${API_V1_JOBS_ENABLED:-false}
      - API_V1_DAILY_UNITS=${API_V1_DAILY_UNITS:-500}
      - API_V1_DAILY_BYTES=${API_V1_DAILY_BYTES:-262144000}
      - API_V1_MAX_JOBS_PER_KEY=${API_V1_MAX_JOBS_PER_KEY:-3}
      - API_V1_MAX_HTTP_REQUESTS=${API_V1_MAX_HTTP_REQUESTS:-6}
      - MAX_CONCURRENT_HEAVY=${MAX_CONCURRENT_HEAVY:-2}
      - ENVIRONMENT=production
      - REQUEST_TIMEOUT_SECONDS=300
      - TRUSTED_HOSTS=privatools.me,www.privatools.me,127.0.0.1,localhost
      - ALLOWED_ORIGINS=https://privatools.me,https://www.privatools.me
      # api-subdomain split (off by default). Set to https://api.privatools.me
      # — e.g. PUBLIC_API_BASE_URL=https://api.privatools.me in the VM env or a
      # .env file — to route the SPA's /api calls to a grey-clouded origin that
      # bypasses Cloudflare's 100 MB upload cap. The backend then injects the
      # runtime <meta> tag, widens the CSP connect-src, and auto-trusts the api
      # Host. Empty = same-origin (unchanged). See deploy/api-subdomain-split.md.
      - PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL:-}
      # Clerk, all optional and all empty by default. Unset means the backend
      # verifies the local session cookie exactly as it always has; there is no
      # half-configured state, because current_user consults Clerk only when a
      # publishable key is present.
      #
      # CLERK_PUBLISHABLE_KEY  is public — the backend derives the instance's
      #                        Frontend API host from it, for the CSP and for
      #                        the JWKS URL, exactly as the browser SDK does.
      # CLERK_WEBHOOK_SECRET   signs user.deleted deliveries. Without it the
      #                        webhook refuses every call rather than accepting
      #                        unverified ones, so an account deleted in Clerk
      #                        would leave its API keys live here.
      #
      # CLERK_SECRET_KEY is deliberately absent. Session verification is done
      # against Clerk's public JWKS and needs no credential, and nothing here
      # calls Clerk's Backend API — so shipping the secret into the container
      # would put a real credential somewhere nothing reads it.
      - CLERK_PUBLISHABLE_KEY=${CLERK_PUBLISHABLE_KEY:-}
      - CLERK_WEBHOOK_SECRET=${CLERK_WEBHOOK_SECRET:-}
      # GA4, both optional. The server-side beacon in routes/analytics.py is a
      # no-op unless both are present, which is why setting only the secret in
      # the VM's .env changed nothing: compose reads .env for substitution
      # here, it does not hand it to the container. Without this line the app
      # never saw the value at all.
      - GA4_MEASUREMENT_ID=${GA4_MEASUREMENT_ID:-}
      - GA4_API_SECRET=${GA4_API_SECRET:-}
      # Browser tag: enable only after the public Google configuration is verified.
      # Once on, collection is default-on for every visitor with the Privacy
      # page opt-out as the only switch. See deploy/analytics.md.
      - GA_BROWSER_TAG_ENABLED=${GA_BROWSER_TAG_ENABLED:-false}
    restart: unless-stopped
    stop_grace_period: 45s
    # Bound container stdout/stderr on disk. The default json-file driver is
    # unbounded — a 5xx storm or a left-on DEBUG session would fill the VM disk,
    # which fails temp writes -> /readyz 503 -> docker wedge. Cap at 5×50 MB.
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"
    # --- Container hardening (mirrors the systemd unit's posture) ---
    security_opt:
      - no-new-privileges:true   # a child process can't gain privileges via setuid
    cap_drop:
      - ALL                      # the app + ffmpeg/libreoffice/ghostscript need no Linux caps
    # Read-only root filesystem: nothing outside the two named volumes and the
    # tmpfs below can be written, so a write-primitive in any of the ~14 native
    # converters has nowhere to land a payload.
    #
    # The one thing that genuinely needs a writable /tmp is LibreOffice, which
    # puts its IPC pipe there and fails with the unhelpful "no valid pipe path
    # found" otherwise. It ignores TMPDIR for that (TMPDIR is already
    # /app/temp), so the mount is the only fix. 64M is ample for a named pipe;
    # user data goes to /app/temp, which the janitor sweeps.
    #
    # noexec is deliberate and verified — LibreOffice needs to create a pipe
    # there, not execute anything.
    #
    # This works only because the baked caches were moved off /tmp to
    # /app/cache (see Dockerfile): a tmpfs here masks whatever is underneath,
    # and when it masked /tmp/u2net rembg quietly re-downloaded the model from
    # GitHub on the first request.
    #
    # Verified on the VM against the signed v1.8.1 image: html-to-pdf
    # (weasyprint), office-to-pdf (libreoffice), remove-background (rembg,
    # zero runtime downloads) and image-to-pdf all return 200, /readyz ready.
    read_only: true
    tmpfs:
      - /tmp:mode=1777,size=64m
    deploy:
      resources:
        limits:
          memory: 4G             # was 20G — effectively no cap on a small VM; matches the systemd MemoryMax
          cpus: '1.8'            # VM has 2 cores; cap at 1.8 (Docker rejects >2) and leave ~0.2 for the host/nginx
          pids: 512              # bound fork-bombs (libreoffice/ffmpeg fan-out); under deploy.* to avoid the pids_limit conflict
        reservations:
          memory: 512M
    healthcheck:
      # /readyz exercises the real deps (imports + temp-writable + free disk), so
      # "unhealthy" means the container genuinely can't serve — not merely
      # "process is up" (research O4). urlopen raises on the 503 a degraded
      # /readyz returns, failing the check. start_period gives the app time to
      # import its deps on first boot before failures are counted.
      test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen(\"http://localhost:8000/readyz\").read()"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

volumes:
  app-temp:
  app-data: