93462b799c
Closes the three operational blockers from the production readiness review. deploy.sh now takes a database backup before it builds, stops or replaces anything, and aborts the deploy if the backup fails — so no deploy proceeds without a restore point. Dumps are gzipped and timestamped into /opt/job-tracker/backups (override with BACKUP_DIR), so one deploy never overwrites an earlier backup. Credentials come from the existing connection string and travel via MYSQL_PWD, never on the command line, so they cannot reach the process list or the deploy log. A dump that is empty or missing CREATE TABLE is rejected, because a truncated file that looks like a restore point is worse than none. SQLite deployments get their data volume tarred instead. Nothing is ever deleted automatically; retention is documented as manual. deploy/README.md documents backup creation, location, retention, database restore, application rollback, and when to use which — restore and rollback kept distinct, because a bad deploy usually needs only the rollback and restoring would discard everything written since the dump. Health checks now cover backend and frontend, which previously had none. GET /health is anonymous, cheap, and deliberately does not touch the database: a health check that queried MariaDB would restart a healthy backend whenever the database blipped, and would hand out an unauthenticated way to probe database availability. The backend image gains curl on the existing chromium apt layer, since the aspnet runtime ships neither curl nor wget. frontend now waits for backend to be healthy rather than merely started, because nginx proxies /api and refuses to start when the upstream cannot be resolved. Verified against real containers, no production data: backup from a seeded MariaDB 11; restore into a clean MariaDB 11 with rows identical; bad credentials and a missing connection string both abort non-zero and leave no partial file; SQLite volume backup produces a readable archive; backend and frontend both reach healthy; and a backend pointed at an unreachable database exits and is reported unhealthy, so a broken deploy cannot present as a running stack. Incidentally confirmed the earlier authorization work: with Auth:Require unset, /health returns 200 while /api/jobapplications returns 401. 393 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
8.2 KiB
YAML
193 lines
8.2 KiB
YAML
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://<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}
|
|
# 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://<host-ip>: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
|