# SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Aaron K. Clark # # Container mode: `docker compose up` brings up Postgres + the .NET API + the Python # poller — the whole two-runtime stack. # Local mode: `docker compose up -d db` then `dotnet run` (or `applytrack poll`) # against localhost — the same build, just a different connection string. services: db: image: postgres:17-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-applytrack} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-JanewayDidNothingWrong} POSTGRES_DB: ${POSTGRES_DB:-applytrack} ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-applytrack}"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped api: build: context: ./api dockerfile: ApplyTrack.Api/Dockerfile environment: ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=${POSTGRES_USER:-applytrack};Password=${POSTGRES_PASSWORD:-JanewayDidNothingWrong}" ASPNETCORE_URLS: "http://+:8080" # Cover-letter AI (all optional; see .env.example). Empty defaults leave drafting # off until a tenant configures their own endpoint in the SPA's AI panel. Llm__BaseUrl: ${Llm__BaseUrl:-} Llm__Model: ${Llm__Model:-} Llm__ApiKey: ${Llm__ApiKey:-} # Daily per-tenant cap on model requests made with the key above (0 = unlimited). Llm__DailyCapPerTenant: ${Llm__DailyCapPerTenant:-200} # Daily retention sweep (#350); see README · Configuration. 0 days = keep forever. Retention__ScreenshotDays: ${Retention__ScreenshotDays:-90} Retention__AgentEventDays: ${Retention__AgentEventDays:-180} Retention__SeenDays: ${Retention__SeenDays:-0} Retention__Enabled: ${Retention__Enabled:-true} Retention__IntervalHours: ${Retention__IntervalHours:-24} Retention__InitialDelaySeconds: ${Retention__InitialDelaySeconds:-300} Retention__ScreenshotsKeptPerApplication: ${Retention__ScreenshotsKeptPerApplication:-3} Retention__ExpiredGraceDays: ${Retention__ExpiredGraceDays:-1} Retention__PollRunDays: ${Retention__PollRunDays:-30} # Encryption at rest is always on. Leave this unset and the api generates a key on # first run into the `secrets` volume below (shared with the agent and the poller, # which must decrypt the same rows); set it to manage the key yourself. Back the key # up with the database — losing it loses the résumé, letters, packets and screenshots. APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:-} # Opt-in observability (#359), empty = off: an OTLP collector, and/or a port for a # Prometheus /metrics served on that port only (publish it to the scraper, not the # internet). See README · Observability. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} Metrics__Port: ${METRICS_PORT:-} # Email / magic-link delivery (all optional; see .env.example). With Email__Host # unset, login links are logged to the console instead of sent — no email config # needed to try auth locally. Set Email__Host to relay through any SMTP provider. Email__Host: ${Email__Host:-} Email__Port: ${Email__Port:-587} Email__Username: ${Email__Username:-} Email__Password: ${Email__Password:-} Email__From: ${Email__From:-} Email__FromName: ${Email__FromName:-OSApplyTrack} # Public origin (#337). Sign-in links are built from this, never the request Host # header. Unset, links fall back to the request origin only for localhost/127.0.0.1 # — reaching the api by LAN IP or a hostname then sends no link. Set it (scheme, # no trailing slash) for any non-localhost use; it is REQUIRED once Email__Host is # set (the api refuses to boot without it). ALLOWED_HOSTS pins the Host headers # Kestrel answers at all, e.g. "apply.example.com;localhost". App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:-} AllowedHosts: ${ALLOWED_HOSTS:-*} # Same-host proxies are trusted by default. Set either value only when the # proxy reaches this container from a different address or bridge network. ForwardedHeaders__KnownProxies__0: ${FORWARDED_HEADERS_KNOWN_PROXY:-} ForwardedHeaders__KnownNetworks__0: ${FORWARDED_HEADERS_KNOWN_NETWORK:-} # 8080 is contested (vLLM, llama.cpp, and half of all dev tools default to it). # If it's already taken, Docker fails the bind loudly ("address already in use") # rather than silently winning — relocate by setting API_PORT in .env. ports: - "${API_PORT:-8080}:8080" volumes: - secrets:/var/lib/applytrack depends_on: db: condition: service_healthy restart: unless-stopped # The agent worker: the same API image with Agent__Enabled=true and no published # port. It judges each tenant's promising leads unattended (Settings · Agent is # off by default per tenant), holding a connection across multi-minute model # calls — which is exactly why it is not run inside the request-serving container. agent: build: context: ./api dockerfile: ApplyTrack.Api/Dockerfile environment: ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=${POSTGRES_USER:-applytrack};Password=${POSTGRES_PASSWORD:-JanewayDidNothingWrong}" ASPNETCORE_URLS: "http://+:8080" Agent__Enabled: "true" Agent__IntervalSeconds: ${AGENT_INTERVAL:-300} Agent__LlmTimeoutSeconds: ${AGENT_LLM_TIMEOUT:-300} Llm__BaseUrl: ${Llm__BaseUrl:-} Llm__Model: ${Llm__Model:-} Llm__ApiKey: ${Llm__ApiKey:-} # Email notifications (#355) go out from here: the same SMTP relay as the api's. Email__Host: ${Email__Host:-} Email__Port: ${Email__Port:-587} Email__Username: ${Email__Username:-} Email__Password: ${Email__Password:-} Email__From: ${Email__From:-} Email__FromName: ${Email__FromName:-OSApplyTrack} APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:-} # Opt-in observability (#359), empty = off: an OTLP collector, and/or a port for a # Prometheus /metrics served on that port only (publish it to the scraper, not the # internet). See README · Observability. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} Metrics__Port: ${METRICS_PORT:-} App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:-} # The browser container (below). Unset both to prepare packets without one. Browser__Endpoint: ${BROWSER_ENDPOINT:-ws://browser:3000/} Browser__Proxy: ${BROWSER_PROXY:-http://proxy:3128} volumes: - secrets:/var/lib/applytrack networks: - default - browser depends_on: db: condition: service_healthy restart: unless-stopped # The browser the agent drives: Playwright's own image running `run-server`, on # an internal network whose only way out is the proxy. Never in the api image — # Chromium cannot run under the hardened profile the other containers keep. browser: build: context: ./docker/browser init: true security_opt: - seccomp:./docker/browser/seccomp_profile.json environment: HTTPS_PROXY: http://proxy:3128 HTTP_PROXY: http://proxy:3128 networks: - browser restart: unless-stopped # The forward proxy that does the browser's DNS and refuses private destinations. proxy: build: context: ./docker/proxy networks: - browser - default restart: unless-stopped # The Python discovery poller (second runtime). Shares the same Postgres; the .NET # API owns the schema (DbUp migrates it on startup), so on a cold boot the poller's # first tick may run before migrations land — its loop retries, no action needed. poller: build: context: . dockerfile: Dockerfile.poller environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-applytrack}:${POSTGRES_PASSWORD:-JanewayDidNothingWrong}@db:5432/${POSTGRES_DB:-applytrack}" # Optional (#300): lets tenants who opt in score listings with Jev. Blank = keywords. TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} # The board sessions (LinkedIn, Handshake, MyGreenhouse) are sealed by the api; the # poller opens them with the same key — this, or the generated key file (#340). APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:-} # Read-only: the api generates the key, the poller only reads it. The key file is # owner-only, so the poller runs as the api image's uid to read it. user: "1654:1654" volumes: - secrets:/var/lib/applytrack:ro depends_on: db: condition: service_healthy restart: unless-stopped networks: default: # No gateway: a container on only this network cannot reach the host, the LAN, # or the internet — the browser's containment boundary. browser: internal: true volumes: pgdata: # The generated encryption key, when APPLYTRACK_SECRETS_KEY is unset. Back it up. secrets: