docker-compose.yml
Rama main · réplica de lectura del repositorio original.
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: