19c5251612
Run untrusted document decoders in a secret-free, resource-bounded child process and terminate its process tree on deadline. Harden the production container and enforce parser and lint gates in CI.
282 lines
12 KiB
YAML
282 lines
12 KiB
YAML
services:
|
|
|
|
backend:
|
|
build:
|
|
context: .
|
|
dockerfile: JobTrackerApi/Dockerfile
|
|
volumes:
|
|
- jobtracker_data:/data
|
|
# Kept outside restored application data so an older database/data backup cannot erase the
|
|
# deletion ledger used to detect and re-delete resurrected accounts.
|
|
- jobtracker_deletion_tombstones:/account-lifecycle/tombstones
|
|
environment:
|
|
- ASPNETCORE_URLS=http://+:8080
|
|
- Data__Root=/data
|
|
- Exports__DailyFolder=/data/exports
|
|
- AccountLifecycle__DeletionEnabled=${ACCOUNT_DELETION_ENABLED:-false}
|
|
- AccountLifecycle__TombstonesRoot=/account-lifecycle/tombstones
|
|
- Database__Provider=${DATABASE_PROVIDER:-sqlite}
|
|
- ConnectionStrings__JobTracker=${JOBTRACKER_CONNECTION_STRING}
|
|
# If you enable HTTPS at a reverse proxy (recommended), handle redirects there.
|
|
- HttpsRedirection__Enabled=false
|
|
# Backend is internal-only here; nginx is the sole trusted ingress.
|
|
- Proxy__TrustForwardedHeaders=true
|
|
- Proxy__KnownNetworks__0=${WEB_PROXY_SUBNET:-172.31.250.0/29}
|
|
# Authentication (recommended for any non-local deployment)
|
|
- Auth__Require=true
|
|
- Auth__JwtKey=${AUTH_JWT_KEY}
|
|
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
|
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
|
- Auth__AllowRegistration=${AUTH_ALLOW_REGISTRATION:-false}
|
|
- Auth__RequireEmailVerification=${AUTH_REQUIRE_EMAIL_VERIFICATION:-false}
|
|
- Turnstile__SiteKey=${TURNSTILE_SITE_KEY}
|
|
- Turnstile__SecretKey=${TURNSTILE_SECRET_KEY}
|
|
- Stripe__SecretKey=${STRIPE_SECRET_KEY}
|
|
- Stripe__PricePremium=${STRIPE_PRICE_PREMIUM}
|
|
- Stripe__WebhookSecret=${STRIPE_WEBHOOK_SECRET}
|
|
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
|
|
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
|
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
|
- Auth__MicrosoftTenant=${AUTH_MICROSOFT_TENANT}
|
|
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
|
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
|
- Microsoft__ClientId=${MICROSOFT_CLIENT_ID}
|
|
- Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET}
|
|
- Microsoft__TenantId=${MICROSOFT_TENANT_ID}
|
|
- Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001}
|
|
- Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001}
|
|
# External processing requires this admin gate AND a per-user opt-in. Default is local-only.
|
|
- Ai__ExternalProcessingEnabled=${EXTERNAL_AI_ENABLED:-false}
|
|
- Ai__ExternalProvider=${AI_PROVIDER:-ollama}
|
|
- Ai__RoutingMode=${AI_ROUTING_MODE:-local_first}
|
|
# Shared secret for calls to ai-service. Must match AI_SERVICE_TOKEN below.
|
|
# Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
|
|
- "Ai__ServiceToken=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
|
|
# Email (SMTP)
|
|
# Build metadata should be resolved before deployment. Examples:
|
|
# APP_VERSION=1.0.0
|
|
# APP_COMMIT_SHA=abc1234
|
|
# APP_BUILD_STAMP=2026-03-22 14:00 UTC
|
|
# Do not set literal placeholders like $(git rev-parse --short HEAD) in .env.
|
|
- App__PublicBaseUrl=${APP_PUBLIC_BASE_URL}
|
|
- App__Version=${APP_VERSION}
|
|
- App__CommitSha=${APP_COMMIT_SHA}
|
|
- App__BuildStamp=${APP_BUILD_STAMP}
|
|
- Email__Enabled=${EMAIL_ENABLED}
|
|
- Email__SmtpHost=${EMAIL_SMTP_HOST}
|
|
- Email__SmtpPort=${EMAIL_SMTP_PORT}
|
|
- Email__SmtpEnableSsl=${EMAIL_SMTP_ENABLE_SSL}
|
|
- Email__SmtpTimeoutMs=${EMAIL_SMTP_TIMEOUT_MS}
|
|
- Email__SmtpUser=${EMAIL_SMTP_USER}
|
|
- Email__SmtpPassword=${EMAIL_SMTP_PASSWORD}
|
|
- Email__From=${EMAIL_FROM}
|
|
- Email__FromName=${EMAIL_FROM_NAME}
|
|
- Email__FollowUpReminders__Enabled=${EMAIL_FOLLOWUPREMINDERS_ENABLED:-false}
|
|
- Email__FollowUpReminders__UpcomingDays=${EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS:-2}
|
|
# These formerly inert workers stay off until their owner-safe behavior and downstream
|
|
# notification/privacy/entitlement prerequisites have been explicitly rolled out.
|
|
- Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
|
|
- Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_ENABLED:-false}
|
|
- Workers__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false}
|
|
- Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
|
|
- Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false}
|
|
- AiQueue__WorkerConcurrency=${AI_QUEUE_WORKER_CONCURRENCY:-1}
|
|
- AiQueue__GlobalCapacity=${AI_QUEUE_GLOBAL_CAPACITY:-100}
|
|
- AiQueue__PerUserCapacity=${AI_QUEUE_PER_USER_CAPACITY:-10}
|
|
- AiQueue__DeadlineMinutes=${AI_QUEUE_DEADLINE_MINUTES:-15}
|
|
- AiQueue__OperationTimeoutSeconds=${AI_QUEUE_OPERATION_TIMEOUT_SECONDS:-300}
|
|
expose:
|
|
- "8080"
|
|
networks:
|
|
default:
|
|
shared_services:
|
|
web_proxy:
|
|
aliases:
|
|
- backend-web
|
|
# The only other member of ai_internal — the backend is the sole permitted caller of
|
|
# ai-service.
|
|
ai_internal:
|
|
restart: unless-stopped
|
|
logging:
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
# Liveness only. /health does not touch the database on purpose: a health check that queried
|
|
# MariaDB would restart a healthy backend whenever the database blipped.
|
|
# start_period covers first-boot schema reconciliation, which can take a while on a new database.
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 90s
|
|
|
|
frontend:
|
|
build:
|
|
context: ./job-tracker-ui
|
|
# Next's build type-checker needs more than Docker's default 64MB /dev/shm; too little
|
|
# causes a SIGSEGV during `npm run build`.
|
|
shm_size: '1gb'
|
|
args:
|
|
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
|
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
|
|
- NEXT_PUBLIC_MICROSOFT_TENANT=${AUTH_MICROSOFT_TENANT}
|
|
# Optional override; default in production is `/api`
|
|
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
|
|
expose:
|
|
- "80"
|
|
# Compatibility ingress for the current production reverse proxy, which
|
|
# targets the host's port 3000 rather than Docker service discovery. Keep
|
|
# this until the operator-owned proxy is migrated onto jobtracker_shared.
|
|
ports:
|
|
- "3000:80"
|
|
environment:
|
|
- APP_PUBLIC_BASE_URL=${APP_PUBLIC_BASE_URL}
|
|
depends_on:
|
|
backend:
|
|
condition: service_healthy
|
|
networks:
|
|
- shared_services
|
|
- web_proxy
|
|
restart: unless-stopped
|
|
logging:
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
# Cheap liveness: nginx answering on its own port. wget ships with the alpine base.
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:80/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
ai-service:
|
|
build:
|
|
context: ./tools/summarizer
|
|
dockerfile: Dockerfile
|
|
environment:
|
|
# Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
|
|
# (e.g. http://<host-ip>:11435). The in-compose ollama service below is
|
|
# opt-in via the "bundled-ollama" profile, so it is NOT started by default
|
|
# and no duplicate Ollama container is created.
|
|
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
|
# External fallback provider for heavy /cv/* calls. Ollama remains primary by default.
|
|
- AI_PROVIDER=${AI_PROVIDER:-ollama}
|
|
- EXTERNAL_AI_ENABLED=${EXTERNAL_AI_ENABLED:-false}
|
|
- AI_ROUTING_MODE=${AI_ROUTING_MODE:-local_first}
|
|
- EXTERNAL_AI_ALLOWED_TASKS=${EXTERNAL_AI_ALLOWED_TASKS:-cv-normalize,cv-classify,cv-rewrite}
|
|
- EXTERNAL_AI_MAX_PROMPT_CHARS=${EXTERNAL_AI_MAX_PROMPT_CHARS:-24000}
|
|
- LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD=${LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD:-3}
|
|
- LOCAL_AI_CIRCUIT_OPEN_SECONDS=${LOCAL_AI_CIRCUIT_OPEN_SECONDS:-30}
|
|
- PARSER_TIMEOUT_SECONDS=${PARSER_TIMEOUT_SECONDS:-25}
|
|
- PARSER_CPU_SECONDS=${PARSER_CPU_SECONDS:-20}
|
|
- PARSER_MEMORY_MIB=${PARSER_MEMORY_MIB:-512}
|
|
- PARSER_STALE_WORK_SECONDS=${PARSER_STALE_WORK_SECONDS:-3600}
|
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
|
|
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
|
- GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
|
|
# Shared secret required on every endpoint except /health. Must match Ai__ServiceToken
|
|
# on the backend. `:?` so a deploy that forgets it fails loudly instead of booting open.
|
|
# Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
|
|
- "AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
|
|
# Deliberately NOT published to the host: this service has no user auth and can spend a
|
|
# paid provider's API key (AI_PROVIDER=gemini/groq). The backend reaches it in-network at
|
|
# http://ai-service:8001. To debug locally, use docker-compose.dev.yml rather than
|
|
# re-adding a `ports:` here.
|
|
expose:
|
|
- "8001"
|
|
read_only: true
|
|
cap_drop:
|
|
- ALL
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
pids_limit: 96
|
|
mem_limit: 2g
|
|
cpus: 2.0
|
|
tmpfs:
|
|
- /tmp:rw,noexec,nosuid,nodev,size=768m,mode=1777
|
|
volumes:
|
|
- ai_model_cache:/home/app/.cache/huggingface
|
|
# ai_internal ONLY. Not on `default` (which the frontend shares) and not on
|
|
# `shared_services` (which is `external: true`, so any other compose stack on this host can
|
|
# join it and would then be able to reach this service). ai_internal carries exactly two
|
|
# members: this service and the backend. Nothing else can route to port 8001.
|
|
#
|
|
# The network is NOT marked `internal: true` — ai-service still needs egress to
|
|
# generativelanguage.googleapis.com / api.groq.com when AI_PROVIDER is gemini or groq.
|
|
networks:
|
|
- ai_internal
|
|
restart: unless-stopped
|
|
logging:
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5).read()"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
# Opt-in only: start locally with
|
|
# `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile bundled-ollama up`.
|
|
# Left out of the default set so deploys reuse an existing/shared Ollama
|
|
# (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
|
|
ollama:
|
|
profiles: ["bundled-ollama"]
|
|
image: ollama/ollama:latest
|
|
expose:
|
|
- "11434"
|
|
environment:
|
|
- OLLAMA_HOST=0.0.0.0:11434
|
|
volumes:
|
|
- ollama_data:/root/.ollama
|
|
# On ai_internal so the bundled Ollama stays reachable at http://ollama:11434 now that
|
|
# ai-service has left the `default`/`shared_services` networks.
|
|
#
|
|
# NOTE: if you point OLLAMA_BASE_URL at an Ollama running in ANOTHER compose stack, address
|
|
# it by host IP (e.g. http://<host-ip>:11435) — ai-service can no longer resolve container
|
|
# names on `shared_services`, by design.
|
|
networks:
|
|
- ai_internal
|
|
restart: unless-stopped
|
|
logging:
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
gpus: all
|
|
healthcheck:
|
|
test: ["CMD", "ollama", "list"]
|
|
interval: 20s
|
|
timeout: 15s
|
|
retries: 10
|
|
start_period: 20s
|
|
|
|
volumes:
|
|
jobtracker_data:
|
|
jobtracker_deletion_tombstones:
|
|
ollama_data:
|
|
ai_model_cache:
|
|
|
|
networks:
|
|
shared_services:
|
|
external: true
|
|
name: jobtracker_shared
|
|
|
|
# Private backend <-> ai-service link. Deliberately NOT external: nothing outside this compose
|
|
# project can join it, so ai-service is unreachable from the host, from the frontend, and from
|
|
# any other stack sharing jobtracker_shared. Egress to cloud AI providers still works because
|
|
# this is a normal bridge (not `internal: true`).
|
|
ai_internal:
|
|
driver: bridge
|
|
|
|
# Only nginx and the backend join this network. The backend trusts forwarded headers solely
|
|
# from this CIDR; set WEB_PROXY_SUBNET explicitly in production after checking for overlap.
|
|
web_proxy:
|
|
internal: true
|
|
ipam:
|
|
config:
|
|
- subnet: ${WEB_PROXY_SUBNET:-172.31.250.0/29}
|