# SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Aaron K. Clark # # Hardened self-hosting stack. Copy .env.production.example to .env.production, # replace every required value, then run: # docker compose --env-file .env.production -f docker-compose.production.yml up -d services: db: image: postgres:17-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-applytrack} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production} POSTGRES_DB: ${POSTGRES_DB:-applytrack} volumes: - pgdata:/var/lib/postgresql/data networks: - database healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-applytrack}"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped api: image: ghcr.io/cryptojones/osapplytrack-api:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} environment: ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=${POSTGRES_USER:-applytrack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}" ASPNETCORE_URLS: "http://+:8080" # The agent's and the poller's own least-privilege roles: the api (the owner, and # the only migrator) creates them from these on boot and keeps their passwords and # grants in step (#345). The agent and poller services connect as them. AGENT_DB_PASSWORD: ${AGENT_DB_PASSWORD:?Set AGENT_DB_PASSWORD in .env.production} POLLER_DB_PASSWORD: ${POLLER_DB_PASSWORD:?Set POLLER_DB_PASSWORD in .env.production} App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:?Set APP_PUBLIC_BASE_URL to the public HTTPS origin} AllowedHosts: ${ALLOWED_HOSTS:?Set ALLOWED_HOSTS to the public hostname} 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 for résumés, letters, packets, screenshots and tenant secrets. # Required here: the hardened containers are read-only, so the app cannot generate # and keep a key file itself — the operator manages the key (and backs it up). APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up} # 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__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} ForwardedHeaders__KnownProxies__0: ${FORWARDED_HEADERS_KNOWN_PROXY:-} ForwardedHeaders__KnownNetworks__0: ${FORWARDED_HEADERS_KNOWN_NETWORK:-} # Keep Kestrel off the public interface. Terminate TLS with a same-host reverse # proxy and forward it to this loopback port. ports: - "127.0.0.1:${API_PORT:-8080}:8080" networks: - database - egress depends_on: db: condition: service_healthy user: "1654:1654" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m cap_drop: - ALL security_opt: - no-new-privileges:true init: true restart: unless-stopped agent: image: ghcr.io/cryptojones/osapplytrack-api:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} environment: # The restricted role: what decides how bad a renderer escape in the browser # is. It cannot migrate, so it waits for the api container to. ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=applytrack_agent;Password=${AGENT_DB_PASSWORD:?Set AGENT_DB_PASSWORD in .env.production}" Migrations__Mode: wait 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:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up} # 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:?Set APP_PUBLIC_BASE_URL to the public HTTPS origin} Browser__Endpoint: ws://browser:3000/ Browser__Proxy: http://proxy:3128 # No published port: this container only talks to Postgres, the model, and the browser. networks: - database - egress - browser depends_on: db: condition: service_healthy user: "1654:1654" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m cap_drop: - ALL security_opt: - no-new-privileges:true init: true restart: unless-stopped # The browser: Playwright's server image, deliberately NOT under the hardened # profile above (Chromium needs an executable temp dir and its sandbox needs # user namespaces). Its containment is the network: `browser` is internal, so the # only thing it can reach is the proxy; plus Playwright's own seccomp profile, # a non-root user, and a fresh context per run holding no credentials. browser: image: ghcr.io/cryptojones/osapplytrack-browser:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} environment: HTTPS_PROXY: http://proxy:3128 HTTP_PROXY: http://proxy:3128 networks: - browser init: true security_opt: - seccomp:./docker/browser/seccomp_profile.json - no-new-privileges:true restart: unless-stopped # The forward proxy: does the browser's DNS, refuses private destinations. proxy: image: ghcr.io/cryptojones/osapplytrack-proxy:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} networks: - browser - egress cap_drop: - ALL # squid starts as root only to drop to its own user; that needs exactly these. cap_add: - SETUID - SETGID - CHOWN security_opt: - no-new-privileges:true init: true restart: unless-stopped poller: image: ghcr.io/cryptojones/osapplytrack-poller:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} environment: # The restricted role (#345): read profiles, stage leads, keep board sessions — # no sessions, tokens, résumés or DDL. Until the api has created it on its first # boot the poll loops just retry. DATABASE_URL: "postgresql://applytrack_poller:${POLLER_DB_PASSWORD:?Set POLLER_DB_PASSWORD in .env.production}@db:5432/${POSTGRES_DB:-applytrack}" DRAIN_INTERVAL: ${DRAIN_INTERVAL:-60} POLL_INTERVAL: ${POLL_INTERVAL:-3600} # Optional (#300): lets tenants who opt in score listings with Jev. Blank = keywords. TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-} # Opens the board sessions the api sealed (LinkedIn, Handshake, MyGreenhouse) (#340). APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up} networks: - database - egress depends_on: db: condition: service_healthy user: "10001:10001" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m cap_drop: - ALL security_opt: - no-new-privileges:true init: true restart: unless-stopped networks: database: internal: true egress: # The browser's world: no gateway, nothing reachable but the proxy. browser: internal: true volumes: pgdata: