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 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: