services: backend: build: context: . dockerfile: JobTrackerApi/Dockerfile volumes: - jobtracker_data:/data environment: - ASPNETCORE_URLS=http://+:8080 - Data__Root=/data - Exports__DailyFolder=/data/exports - 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 # 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} # 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} - Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET} - Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI} # Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph - Microsoft__ClientId=${MICROSOFT_CLIENT_ID} - Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET} - Microsoft__TenantId=${MICROSOFT_TENANT_ID} - Microsoft__RedirectUri=${MICROSOFT_REDIRECT_URI} - Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001} - Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001} # 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} expose: - "8080" networks: - default - shared_services # The only other member of ai_internal — the backend is the sole permitted caller of # ai-service. - ai_internal restart: unless-stopped # 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} # Optional override; default in production is `/api` - NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL} ports: - "3000:80" depends_on: backend: condition: service_healthy networks: - default - shared_services restart: unless-stopped # 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/"] 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://: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} # AI provider for heavy /cv/* calls: ollama (default) | gemini | groq. # Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU. - AI_PROVIDER=${AI_PROVIDER:-ollama} - 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.override.yml rather than # re-adding a `ports:` here. expose: - "8001" # 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 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 with `docker compose --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 ports: - "11434: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://:11435) — ai-service can no longer resolve container # names on `shared_services`, by design. networks: - default - shared_services - ai_internal restart: unless-stopped gpus: all healthcheck: test: ["CMD", "ollama", "list"] interval: 20s timeout: 15s retries: 10 start_period: 20s volumes: jobtracker_data: ollama_data: 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