Files
jobtrackingapp/docker-compose.yml
T
cesnimda 93462b799c
CI and Deploy / test (push) Failing after 1m8s
CI and Deploy / deploy (push) Has been skipped
chore(ops): add deployment backups restore docs and health checks
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>
2026-07-19 17:49:31 +02:00

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