Remove `.env.example` and update Docker configurations for environment management
- Deleted the `.env.example` file as it is no longer needed; all configurations are now centralized in `backend/.env`. - Updated `docker-compose.yml` and `docker-compose.dev.yml` to reflect changes in environment variable handling, ensuring that the application reads from `backend/.env` exclusively. - Adjusted the nginx configuration to improve API request handling and ensure proper proxying for frontend interactions. These changes streamline the environment setup process and enhance the overall configuration management for local and production deployments.pull/24/head
parent
ea6f67786c
commit
8c12872c6e
69
.env.example
69
.env.example
|
|
@ -1,69 +0,0 @@
|
||||||
# Copy to `.env` at the repo root for Compose variable substitution.
|
|
||||||
# Secrets for the app itself still live in `backend/.env` (see backend/.env.example).
|
|
||||||
# Never commit a filled `.env`.
|
|
||||||
|
|
||||||
# --- Compose / production stack ----------------------------------------------------
|
|
||||||
# Used by docker-compose.yml for the container Postgres and frontend publish port.
|
|
||||||
|
|
||||||
DB_USERNAME=postgres
|
|
||||||
DB_PASSWORD=change-me-strong-password
|
|
||||||
DB_NAME=hrms
|
|
||||||
|
|
||||||
# Public SPA port only (production). Dev overlay defaults FRONTEND_PORT to 5173.
|
|
||||||
FRONTEND_PORT=80
|
|
||||||
|
|
||||||
# API workers behind nginx (production backend-api command).
|
|
||||||
UVICORN_WORKERS=2
|
|
||||||
|
|
||||||
# Schema sync on API startup (docker compose up -d --build).
|
|
||||||
# true = upgrade (if any on-disk revisions) + apply model drift in-memory.
|
|
||||||
# Revision .py files stay gitignored and are not written on the server.
|
|
||||||
DB_AUTO_MIGRATE=true
|
|
||||||
DB_AUTOGENERATE=true
|
|
||||||
|
|
||||||
# Empty = same-origin fetches; frontend nginx proxies to backend-api.
|
|
||||||
# Set only when the API is intentionally on another origin.
|
|
||||||
VITE_API_BASE=
|
|
||||||
|
|
||||||
# Optional: override mail service URL for containers (else host.docker.internal:5000).
|
|
||||||
# EMAIL_URL=http://host.docker.internal:5000
|
|
||||||
|
|
||||||
# Optional: point containers at host Postgres instead of Compose postgres
|
|
||||||
# (also set by docker-compose.dev.yml).
|
|
||||||
# DB_HOST=host.docker.internal
|
|
||||||
|
|
||||||
# --- Bulk ATS scoring engine (also read by ats-engine / backend) --------------------
|
|
||||||
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
|
|
||||||
# Must be a structured-outputs model family: gpt-5*, gpt-4.1*, o3*, o4*.
|
|
||||||
# "-chat-latest" variants are rejected -- they track the ChatGPT product surface and
|
|
||||||
# do not expose reasoning effort.
|
|
||||||
# Note gpt-4.1 is allowed but is not a reasoning model, so OPENAI_EFFORT is ignored
|
|
||||||
# for it (the adapter omits the parameter rather than sending a 400).
|
|
||||||
OPENAI_MODEL=gpt-5.4-mini
|
|
||||||
|
|
||||||
# Covers reasoning tokens AND the visible response on a reasoning model. Too low and
|
|
||||||
# the JSON truncates mid-object, failing the candidate with MODEL_RESPONSE_INVALID.
|
|
||||||
# Enforced floor is 2048. Do not lower this to save cost -- lower OPENAI_EFFORT.
|
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=4000
|
|
||||||
|
|
||||||
# none | minimal | low | medium | high | xhigh
|
|
||||||
OPENAI_EFFORT=low
|
|
||||||
|
|
||||||
OPENAI_MAX_RETRIES=3
|
|
||||||
OPENAI_TIMEOUT_SECONDS=120
|
|
||||||
|
|
||||||
# OpenAI prompt caching is automatic and cannot be turned off. This only controls
|
|
||||||
# whether a prompt_cache_key routing hint is sent to raise the cache hit rate.
|
|
||||||
OPENAI_ENABLE_PROMPT_CACHE=true
|
|
||||||
|
|
||||||
SCORING_CONCURRENCY=5
|
|
||||||
MAX_RESUMES_PER_REQUEST=50
|
|
||||||
MAX_PDF_SIZE_MB=10
|
|
||||||
MAX_JD_CHARS=30000
|
|
||||||
MAX_RESUME_CHARS=60000
|
|
||||||
|
|
||||||
# text | json
|
|
||||||
LOG_FORMAT=json
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
@ -57,3 +57,6 @@ frontend/dist/
|
||||||
**.pdf
|
**.pdf
|
||||||
**_**_**.py
|
**_**_**.py
|
||||||
Utopia-ai-hr-ats-portal 1.pem
|
Utopia-ai-hr-ats-portal 1.pem
|
||||||
|
|
||||||
|
# Local-only Compose overrides (never deployed)
|
||||||
|
docker.local.env
|
||||||
|
|
|
||||||
171
DOCKER.md
171
DOCKER.md
|
|
@ -1,116 +1,145 @@
|
||||||
# Docker
|
# Docker
|
||||||
|
|
||||||
Production stack is self-contained: Postgres, Redis, API, ATS engine, Taskiq
|
## How the browser reaches the API
|
||||||
workers, and the React SPA. Only the frontend is published on the host.
|
|
||||||
|
|
||||||
## Production
|
The SPA is on **http://127.0.0.1:5173** (nginx → `backend-api` on the Compose
|
||||||
|
network). The API is also published on **http://127.0.0.1:8000** for host tools
|
||||||
|
and `npm run dev` (`VITE_API_BASE=http://127.0.0.1:8000`).
|
||||||
|
|
||||||
```bash
|
```env
|
||||||
# 1. Root compose env (DB password, port, workers) — see .env.example
|
FRONTEND_PORT=5173
|
||||||
cp .env.example .env
|
BACKEND_PORT=8000
|
||||||
# Edit DB_PASSWORD (and JWT_SECRET_KEY inside backend/.env)
|
FRONTEND_URL=http://127.0.0.1:5173
|
||||||
|
|
||||||
# 2. App secrets (JWT, OpenAI, email, Buffer, …)
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
# Fill JWT_SECRET_KEY, OPENAI_API_KEY, DB_* matching the Compose postgres, …
|
|
||||||
|
|
||||||
# 3. Build and start
|
|
||||||
docker compose up -d --build
|
|
||||||
docker compose ps
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Service | Image | Host port |
|
Sole env file: **`backend/.env`** (no repo-root `.env`). Always pass it for
|
||||||
|---|---|---|
|
Compose variable substitution:
|
||||||
| `frontend` | `hrms-frontend:local` | `${FRONTEND_PORT:-80}` |
|
|
||||||
| `backend-api` | `hrms-backend:local` | (internal) |
|
```bash
|
||||||
| `ats-engine` | `hrms-ats-engine:local` | (internal) |
|
docker compose --env-file ./backend/.env up -d --build
|
||||||
| `redis` | `redis:7-alpine` | (internal) |
|
```
|
||||||
| `postgres` | `hrms-postgres:local` | (internal) |
|
|
||||||
| Taskiq workers / schedulers | `hrms-backend:local` | (internal) |
|
## Local (host Postgres)
|
||||||
|
|
||||||
|
In `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PROD_ENV=false
|
||||||
|
DB_USERNAME=...
|
||||||
|
DB_PASSWORD=...
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=hrms
|
||||||
|
DB_SSLMODE=
|
||||||
|
FRONTEND_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Containers set `IN_DOCKER=1`. With `PROD_ENV=false`, `db_setup` rewrites
|
||||||
|
`localhost` / `127.0.0.1` → `host.docker.internal` for the connection URL only
|
||||||
|
(SSL off unless `DB_SSLMODE` is set). Host Postgres must accept Docker-bridge
|
||||||
|
clients (`listen_addresses`, `pg_hba`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env # set JWT, OpenAI, DB_*, PROD_ENV=false
|
||||||
|
docker compose --env-file ./backend/.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Host access |
|
||||||
|
|---|---|
|
||||||
|
| `frontend` | `${FRONTEND_PORT:-80}` (all interfaces) |
|
||||||
|
| `backend-api` | Compose network only (`backend-api:8000`); nginx proxies |
|
||||||
|
| `ats-engine` | Compose network only (`ats-engine:8100`) |
|
||||||
|
| `redis` | Compose network only (`redis:6379`) |
|
||||||
|
| `postgres` | not started (optional `--profile postgres`) |
|
||||||
|
|
||||||
|
Optional loopback publishes for host tools (curl / redis-cli / Postman):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If bind fails on Windows because Cursor/VS Code still holds `:80` / `:8100` /
|
||||||
|
`:6379` after a previous run, clear **Ports** in the IDE or set free values in
|
||||||
|
`backend/.env` (`FRONTEND_PORT`, and with the overlay `ATS_PORT` / `REDIS_PORT` /
|
||||||
|
`BACKEND_PORT`).
|
||||||
|
|
||||||
|
Optional live-reload / bind mounts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional Compose Postgres (empty volume — not host data):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file ./backend/.env --profile postgres up -d postgres
|
||||||
|
# set DB_HOST=postgres in backend/.env, then recreate backend services
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production (RDS)
|
||||||
|
|
||||||
|
In `backend/.env`, set `PROD_ENV=true` and point plain `DB_*` at RDS (no
|
||||||
|
prefixed credential sets). Blank `DB_SSLMODE` → SSL `require`. Host is never
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Edit backend/.env: PROD_ENV=true, DB_* = RDS, JWT_SECRET_KEY, FRONTEND_PORT=80, …
|
||||||
|
docker compose --env-file ./backend/.env up -d --build
|
||||||
|
docker compose --env-file ./backend/.env ps
|
||||||
|
```
|
||||||
|
|
||||||
Browser → `http://<host>/` → nginx (same-origin) → `backend-api:8000`.
|
Browser → `http://<host>/` → nginx (same-origin) → `backend-api:8000`.
|
||||||
CV files live in the `attachments-data` named volume (shared by API + workers).
|
CV files live in the `attachments-data` named volume (shared by API + workers).
|
||||||
|
|
||||||
### Schema / migrations (automatic)
|
### Schema / migrations (automatic)
|
||||||
|
|
||||||
On every `backend-api` start (`docker compose up -d --build`):
|
On every `backend-api` start:
|
||||||
|
|
||||||
1. Fresh empty Postgres → create all tables from models and stamp a marker.
|
1. Fresh empty Postgres → create all tables from models and stamp a marker.
|
||||||
2. Otherwise → `alembic upgrade head` if any revision files exist in the image
|
2. Otherwise → `alembic upgrade head` if any revision files exist in the image
|
||||||
(they normally do not — versions stay gitignored and are excluded from builds).
|
(they normally do not — versions stay gitignored and are excluded from builds).
|
||||||
3. If `DB_AUTOGENERATE=true` (Compose default) → detect ORM drift (new/changed/removed
|
3. If `DB_AUTOGENERATE=true` → detect ORM drift and apply DDL **in-memory**.
|
||||||
columns or tables) and apply DDL **in-memory** — no `versions/*.py` is written
|
|
||||||
on the server.
|
|
||||||
4. Apply any pending `backend/migrations/manual/*.sql` (seed/RBAC batches only).
|
4. Apply any pending `backend/migrations/manual/*.sql` (seed/RBAC batches only).
|
||||||
|
|
||||||
Revision scripts under `backend/migrations/versions/` remain gitignored and are
|
Toggle in `backend/.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
|
||||||
listed in `.dockerignore` so they never ship in the production image.
|
|
||||||
|
|
||||||
Toggle with root `.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
|
|
||||||
|
|
||||||
### Verify
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose config
|
docker compose --env-file ./backend/.env config
|
||||||
curl -sf http://127.0.0.1/health # nginx → backend /health
|
curl -sf http://127.0.0.1:${FRONTEND_PORT:-8080}/health
|
||||||
curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
|
curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1:${FRONTEND_PORT:-8080}/
|
||||||
docker compose logs -f backend-api
|
docker compose --env-file ./backend/.env logs -f backend-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the SPA, sign in, confirm `/jobs` lists requisitions (Network: `/jobs/fetch`
|
|
||||||
same-origin JSON, not HTML).
|
|
||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
|
|
||||||
- Never bake `.env` into images (`.dockerignore` already excludes them).
|
- Never bake `.env` into images (`.dockerignore` already excludes them).
|
||||||
- Require a strong `DB_PASSWORD` and `JWT_SECRET_KEY` before any real deploy.
|
- Require a strong `DB_PASSWORD` and `JWT_SECRET_KEY` before any real deploy.
|
||||||
- Root `.env` is for Compose substitution; `backend/.env` is for the app process.
|
- Only `backend/.env` holds app + Compose substitution values.
|
||||||
|
- Do not put `DB_HOST` under Compose `environment:` (empty override blanks RDS).
|
||||||
|
|
||||||
### TLS
|
### TLS
|
||||||
|
|
||||||
This stack serves HTTP on the frontend port. Terminate TLS at a reverse proxy or
|
This stack serves HTTP on the frontend port. Terminate TLS at a reverse proxy or
|
||||||
cloud load balancer in front of port 80.
|
cloud load balancer in front of that port.
|
||||||
|
|
||||||
## Local development (host Postgres)
|
|
||||||
|
|
||||||
Restores published ports, bind-mounted attachments, and `--reload`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
| Override | Value |
|
|
||||||
|---|---|
|
|
||||||
| `DB_HOST` | `host.docker.internal` |
|
|
||||||
| Frontend | `${FRONTEND_PORT:-5173}` |
|
|
||||||
| API | `${BACKEND_PORT:-8000}` |
|
|
||||||
| ATS | `${ATS_PORT:-8100}` |
|
|
||||||
| Redis | `${REDIS_PORT:-6379}` |
|
|
||||||
| Attachments | `./backend/inbox/decoded_attachments` |
|
|
||||||
|
|
||||||
Container Postgres still starts (production default) but is unused while
|
|
||||||
`DB_HOST=host.docker.internal`. Host Postgres must accept Docker-bridge clients
|
|
||||||
(`listen_addresses = '*'`, `pg_hba` for the bridge subnet).
|
|
||||||
|
|
||||||
For the Vite HMR loop, run `npm run dev` on the host against
|
|
||||||
`frontend/.env.development` (`VITE_API_BASE=http://127.0.0.1:8000`).
|
|
||||||
|
|
||||||
## Data migration
|
## Data migration
|
||||||
|
|
||||||
The Compose Postgres volume starts empty. To move an existing host database:
|
The optional Compose Postgres volume starts empty. To move an existing host database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pg_dump -Fc hrms > hrms.dump
|
pg_dump -Fc hrms > hrms.dump
|
||||||
# with postgres published via the dev overlay, or `docker compose exec -T postgres …`
|
|
||||||
pg_restore -h 127.0.0.1 -p 5433 -U postgres -d hrms --clean --if-exists hrms.dump
|
pg_restore -h 127.0.0.1 -p 5433 -U postgres -d hrms --clean --if-exists hrms.dump
|
||||||
```
|
```
|
||||||
|
|
||||||
## Useful commands
|
## Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f backend-api
|
docker compose --env-file ./backend/.env logs -f backend-api
|
||||||
docker compose logs -f taskiq-worker
|
docker compose --env-file ./backend/.env logs -f taskiq-worker
|
||||||
docker compose restart backend-api
|
docker compose --env-file ./backend/.env restart backend-api
|
||||||
docker compose down # keep volumes
|
docker compose --env-file ./backend/.env down # keep volumes
|
||||||
docker compose down -v # wipe postgres + attachments + redis data
|
docker compose --env-file ./backend/.env down -v # wipe volumes
|
||||||
```
|
```
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -45,29 +45,19 @@ job and persists an evidence-based score.
|
||||||
**Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key.
|
**Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key.
|
||||||
Optional: Redis + Docker (only for background inbox sync / taskiq workers).
|
Optional: Redis + Docker (only for background inbox sync / taskiq workers).
|
||||||
|
|
||||||
### 1. Environment files
|
### 1. Environment file
|
||||||
|
|
||||||
Root `.env` (engine + scoring settings — see [.env.example](.env.example)):
|
Sole file: `backend/.env` (see [backend/.env.example](backend/.env.example)):
|
||||||
|
|
||||||
```
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
OPENAI_MODEL=gpt-5.4-mini
|
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=4000
|
|
||||||
OPENAI_EFFORT=low
|
|
||||||
SCORING_CONCURRENCY=5
|
|
||||||
MAX_RESUMES_PER_REQUEST=50
|
|
||||||
MAX_PDF_SIZE_MB=10
|
|
||||||
```
|
|
||||||
|
|
||||||
`backend/.env` (everything in `backend/.env.example`; the must-haves):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
PROD_ENV=false
|
||||||
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
|
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
|
||||||
JWT_SECRET_KEY=...
|
JWT_SECRET_KEY=...
|
||||||
OPENAI_API_KEY=sk-... # shared names with the root .env
|
OPENAI_API_KEY=sk-...
|
||||||
|
FRONTEND_PORT=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
> Windows note: write `.env` files as UTF-8 **without** BOM, and don't leave stray
|
> Windows note: write `.env` as UTF-8 **without** BOM, and don't leave stray
|
||||||
> non `KEY=VALUE` lines — python-dotenv warns on every load.
|
> non `KEY=VALUE` lines — python-dotenv warns on every load.
|
||||||
|
|
||||||
### 2. Fresh database — one manual step
|
### 2. Fresh database — one manual step
|
||||||
|
|
@ -120,17 +110,13 @@ See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and t
|
||||||
local host-Postgres overlay.
|
local host-Postgres overlay.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # set DB_PASSWORD
|
cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, DB_*, …
|
||||||
cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, …
|
docker compose --env-file ./backend/.env up -d --build
|
||||||
docker compose up -d --build
|
# SPA: http://localhost:8080/ health: http://localhost:8080/health
|
||||||
# SPA: http://localhost/ health: http://localhost/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Local day-to-day (host Postgres, exposed API ports, `--reload`):
|
Local and prod use the same command (`PROD_ENV` + `DB_*` in `backend/.env`).
|
||||||
|
Optional `--reload` / bind mounts: add `-f docker-compose.dev.yml`. See `DOCKER.md`.
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### First run
|
### First run
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,14 @@ Deliberate omissions:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# Sole secrets file: backend/.env (repo root .env is not used).
|
||||||
|
_BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env"
|
||||||
|
|
||||||
# Model families that support structured outputs (``responses.parse``) and a reasoning
|
# Model families that support structured outputs (``responses.parse``) and a reasoning
|
||||||
# effort setting. A prefix check rather than an exact allowlist: OpenAI ships point
|
# effort setting. A prefix check rather than an exact allowlist: OpenAI ships point
|
||||||
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
|
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
|
||||||
|
|
@ -44,7 +48,7 @@ class Settings(BaseSettings):
|
||||||
"""Runtime configuration. Immutable once constructed."""
|
"""Runtime configuration. Immutable once constructed."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=_BACKEND_ENV,
|
||||||
# utf-8-sig, not utf-8: Windows editors and PowerShell's `-Encoding utf8`
|
# utf-8-sig, not utf-8: Windows editors and PowerShell's `-Encoding utf8`
|
||||||
# write a BOM, which would otherwise become part of the first variable's
|
# write a BOM, which would otherwise become part of the first variable's
|
||||||
# name and silently blank out that setting.
|
# name and silently blank out that setting.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
DB_USERNAME=
|
# Sole secrets / config file for the whole monorepo (app + backend + Compose).
|
||||||
|
# Copy to backend/.env and fill in. Never commit a filled .env.
|
||||||
|
#
|
||||||
|
# Compose: docker compose --env-file ./backend/.env up -d --build
|
||||||
|
|
||||||
|
# true → RDS over SSL (asyncpg). false → local Postgres over asyncpg (no SSH).
|
||||||
|
PROD_ENV=false
|
||||||
|
|
||||||
|
DB_USERNAME=postgres
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_HOST=
|
DB_HOST=localhost
|
||||||
DB_PORT=
|
DB_PORT=5432
|
||||||
DB_NAME=
|
DB_NAME=hrms
|
||||||
|
# Blank: require when PROD_ENV=true, off when local. Override only if needed.
|
||||||
|
DB_SSLMODE=
|
||||||
|
|
||||||
|
DB_AUTO_MIGRATE=true
|
||||||
|
DB_AUTOGENERATE=true
|
||||||
|
|
||||||
EMAIL_URL=
|
EMAIL_URL=
|
||||||
EMAIL_API_TOKEN=
|
EMAIL_API_TOKEN=
|
||||||
# Optional overrides; blank falls back to EMAIL_URL / EMAIL_API_TOKEN.
|
# Optional overrides; blank falls back to EMAIL_URL / EMAIL_API_TOKEN.
|
||||||
|
|
@ -25,7 +39,7 @@ RESET_CODE_TTL_SECONDS=60
|
||||||
RESET_CODE_RESEND_SECONDS=30
|
RESET_CODE_RESEND_SECONDS=30
|
||||||
RESET_CODE_MAX_ATTEMPTS=5
|
RESET_CODE_MAX_ATTEMPTS=5
|
||||||
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://127.0.0.1:5173
|
||||||
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
||||||
CONFIRM_TOKEN_TTL_SECONDS=86400
|
CONFIRM_TOKEN_TTL_SECONDS=86400
|
||||||
CONFIRM_TOKEN_RESEND_SECONDS=60
|
CONFIRM_TOKEN_RESEND_SECONDS=60
|
||||||
|
|
@ -47,32 +61,27 @@ OPENAI_BASE_URL=
|
||||||
OPENAI_ORGANIZATION=
|
OPENAI_ORGANIZATION=
|
||||||
OPENAI_PROJECT=
|
OPENAI_PROJECT=
|
||||||
|
|
||||||
# ATS scoring (bulk-ats engine embedded via `pip install -e ..`).
|
# ATS scoring (bulk-ats engine). Shared OPENAI_* names above.
|
||||||
# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared.
|
|
||||||
OPENAI_EFFORT=low
|
OPENAI_EFFORT=low
|
||||||
OPENAI_ENABLE_PROMPT_CACHE=true
|
OPENAI_ENABLE_PROMPT_CACHE=true
|
||||||
|
OPENAI_TIMEOUT_SECONDS=120
|
||||||
SCORING_CONCURRENCY=5
|
SCORING_CONCURRENCY=5
|
||||||
MAX_RESUMES_PER_REQUEST=50
|
MAX_RESUMES_PER_REQUEST=50
|
||||||
MAX_PDF_SIZE_MB=10
|
MAX_PDF_SIZE_MB=10
|
||||||
MAX_JD_CHARS=30000
|
MAX_JD_CHARS=30000
|
||||||
MAX_RESUME_CHARS=60000
|
MAX_RESUME_CHARS=60000
|
||||||
|
|
||||||
# Inbox intake gate (inbox_classifier/): only mail judged to be a job application
|
# Inbox intake gate (inbox_classifier/).
|
||||||
# gets an inbox_messages row; every verdict is logged to inbox_message_triage.
|
|
||||||
# Model / token / effort / cache knobs are the OPENAI_* ones above.
|
|
||||||
# false restores the pre-gate behaviour exactly — the rollback lever.
|
|
||||||
INBOX_TRIAGE_ENABLED=true
|
INBOX_TRIAGE_ENABLED=true
|
||||||
# true: a provider outage or missing key ingests the mail and marks the verdict
|
|
||||||
# unclassified. false: skip it and leave it for a later /email/fetch.
|
|
||||||
INBOX_TRIAGE_FAIL_OPEN=true
|
INBOX_TRIAGE_FAIL_OPEN=true
|
||||||
INBOX_TRIAGE_CONCURRENCY=5
|
INBOX_TRIAGE_CONCURRENCY=5
|
||||||
INBOX_TRIAGE_MAX_SUBJECT_CHARS=300
|
INBOX_TRIAGE_MAX_SUBJECT_CHARS=300
|
||||||
INBOX_TRIAGE_MAX_BODY_CHARS=4000
|
INBOX_TRIAGE_MAX_BODY_CHARS=4000
|
||||||
# 0 disables the uncertainty branch; >0 routes low-confidence verdicts to the
|
|
||||||
# INBOX_TRIAGE_FAIL_OPEN policy.
|
|
||||||
INBOX_TRIAGE_MIN_CONFIDENCE=0
|
INBOX_TRIAGE_MIN_CONFIDENCE=0
|
||||||
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
# Compose overrides these on the network; keep docker DNS names for containers.
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
BACKEND_URL=http://backend-api:8000
|
||||||
TASKIQ_QUEUE_NAME=inbox
|
TASKIQ_QUEUE_NAME=inbox
|
||||||
TASKIQ_CV_QUEUE_NAME=cv_upload
|
TASKIQ_CV_QUEUE_NAME=cv_upload
|
||||||
TASKIQ_MAX_RETRIES=3
|
TASKIQ_MAX_RETRIES=3
|
||||||
|
|
@ -82,3 +91,17 @@ TASKIQ_DLQ_STREAM=taskiq:dlq
|
||||||
TASKIQ_IDLE_TIMEOUT_MS=600000
|
TASKIQ_IDLE_TIMEOUT_MS=600000
|
||||||
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
||||||
APP_VERSION=dev
|
APP_VERSION=dev
|
||||||
|
|
||||||
|
# Compose host ports (docker compose --env-file ./backend/.env …).
|
||||||
|
FRONTEND_PORT=5173
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
ATS_PORT=8100
|
||||||
|
REDIS_PORT=6379
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
UVICORN_WORKERS=2
|
||||||
|
# Empty = same-origin via nginx on :5173. For Vite on the host, use
|
||||||
|
# VITE_API_BASE=http://127.0.0.1:8000 (backend is published on BACKEND_PORT).
|
||||||
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
LOG_FORMAT=json
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
|
|
@ -837,8 +837,8 @@ bind-mounted into the worker so both processes see the same files.
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and fill it in. `.env` is git-ignored; `.env.example` is not.
|
Copy `.env.example` to `.env` and fill it in. `.env` is git-ignored; `.env.example` is not.
|
||||||
`db_setup.Settings` reads `backend/.env` or the repo-root `.env`; every other module reads its
|
`db_setup.Settings` reads `backend/.env` only (no repo-root `.env`); every other module reads its
|
||||||
own keys with `os.getenv`.
|
own keys with `os.getenv` from the same file.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
|
|
@ -1053,7 +1053,9 @@ Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](.
|
||||||
# Production
|
# Production
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# Local (host DB, published ports, --reload)
|
# Default (host Postgres or RDS via backend/.env PROD_ENV + DB_*)
|
||||||
|
docker compose up -d --build
|
||||||
|
# Optional live-reload / bind mounts
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
docker compose logs -f taskiq-worker
|
docker compose logs -f taskiq-worker
|
||||||
docker compose logs -f taskiq-cv-worker
|
docker compose logs -f taskiq-cv-worker
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""PostgreSQL connection, async SQLAlchemy ORM and session management.
|
"""PostgreSQL connection, async SQLAlchemy ORM and session management.
|
||||||
|
|
||||||
Configuration comes from the environment, with `.env` read from the repo root or
|
Configuration comes from the environment, with `.env` read from `backend/`
|
||||||
from `backend/` (`Db_USERNAME`, `Db_PASSWORD`, `Db_HOST`, `Db_PORT`, `Db_NAME`, and
|
(`DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `PROD_ENV`, and
|
||||||
the `DB_*` tuning fields below). Alembic lives in `alembic_setup.py`; `init_db()`
|
the `DB_*` tuning fields below). There is no repo-root `.env`. Alembic lives in
|
||||||
calls into it.
|
`alembic_setup.py`; `init_db()` calls into it.
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan) # migrate on startup
|
app = FastAPI(lifespan=lifespan) # migrate on startup
|
||||||
async def endpoint(db: AsyncSession = Depends(get_session)): ...
|
async def endpoint(db: AsyncSession = Depends(get_session)): ...
|
||||||
|
|
@ -13,13 +13,14 @@ calls into it.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, AsyncIterator, Sequence
|
from typing import Annotated, Any, AsyncIterator, Sequence
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
from sqlalchemy import MetaData, text
|
from sqlalchemy import MetaData, text
|
||||||
|
|
@ -32,40 +33,48 @@ from sqlalchemy.ext.asyncio import (
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
logger = logging.getLogger("db")
|
logger = logging.getLogger("db")
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
_TRUE = {"1", "true", "yes", "on"}
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1"})
|
||||||
|
|
||||||
|
|
||||||
|
def _running_in_docker() -> bool:
|
||||||
|
"""True inside a container (/.dockerenv) or when Compose sets IN_DOCKER=1."""
|
||||||
|
return Path("/.dockerenv").exists() or os.environ.get("IN_DOCKER", "").strip().lower() in _TRUE
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Every field is overridden by an environment variable of the same name."""
|
"""Every field is overridden by an environment variable of the same name."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=(BASE_DIR.parent / ".env", BASE_DIR / ".env"), extra="ignore"
|
env_file=BASE_DIR / ".env",
|
||||||
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
database_url: str = "" # full DSN; wins over the Db_* parts below
|
database_url: str = "" # full DSN; wins over the DB_* parts below
|
||||||
db_username: str = os.getenv("DB_USERNAME")
|
db_username: str = ""
|
||||||
db_password: str = os.getenv("DB_PASSWORD")
|
db_password: str = ""
|
||||||
db_host: str = os.getenv("DB_HOST")
|
db_host: str = "localhost"
|
||||||
db_port: int = int(os.getenv("DB_PORT"))
|
db_port: int = 5432
|
||||||
db_name: str = os.getenv("DB_NAME")
|
db_name: str = ""
|
||||||
db_sslmode: str = "" # e.g. "require" on Azure
|
db_sslmode: str = "" # blank = derive from PROD_ENV (require on RDS, off locally)
|
||||||
|
prod_env: bool = False # true → RDS (SSL); false → local psql over asyncpg
|
||||||
|
|
||||||
db_schemas: Annotated[list[str], NoDecode] = "app"
|
db_schemas: Annotated[list[str], NoDecode] = "app"
|
||||||
db_default_schema: str = "app" # schema for models that declare none
|
db_default_schema: str = "app"
|
||||||
db_echo: bool = False
|
db_echo: bool = False
|
||||||
db_pool_size: int = 5
|
db_pool_size: int = 5
|
||||||
db_max_overflow: int = 10
|
db_max_overflow: int = 10
|
||||||
db_pool_recycle: int = 1800
|
db_pool_recycle: int = 1800
|
||||||
db_connect_retries: int = 10
|
db_connect_retries: int = 10
|
||||||
db_auto_migrate: bool = True # run `upgrade head` on startup
|
db_auto_migrate: bool = True
|
||||||
db_autogenerate: bool = True # write a revision when models drift from the schema
|
db_autogenerate: bool = True
|
||||||
db_model_modules: Annotated[list[str], NoDecode] = [] # empty means auto-discover
|
db_model_modules: Annotated[list[str], NoDecode] = []
|
||||||
app_name: str = "hr-ats-portal"
|
app_name: str = "hr-ats-portal"
|
||||||
|
|
||||||
@field_validator("db_schemas", "db_model_modules", mode="before")
|
@field_validator("db_schemas", "db_model_modules", mode="before")
|
||||||
|
|
@ -75,8 +84,20 @@ class Settings(BaseSettings):
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("prod_env", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _bool(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in _TRUE
|
||||||
|
return value
|
||||||
|
|
||||||
def url(self, *, async_driver: bool = True) -> URL:
|
def url(self, *, async_driver: bool = True) -> URL:
|
||||||
"""DSN with the driver forced; `sslmode` is mapped to asyncpg's `ssl` mode name."""
|
"""DSN with the driver forced; `sslmode` is mapped to asyncpg's `ssl` mode name.
|
||||||
|
|
||||||
|
Local Docker: `DB_HOST=localhost` means the container itself, so rewrite to
|
||||||
|
`host.docker.internal` for the connection URL only (Settings.db_host unchanged).
|
||||||
|
Prod never rewrites — RDS hostname is used as-is.
|
||||||
|
"""
|
||||||
url = (
|
url = (
|
||||||
make_url(self.database_url)
|
make_url(self.database_url)
|
||||||
if self.database_url
|
if self.database_url
|
||||||
|
|
@ -89,10 +110,22 @@ class Settings(BaseSettings):
|
||||||
self.db_name,
|
self.db_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
not self.prod_env
|
||||||
|
and _running_in_docker()
|
||||||
|
and (url.host or "").lower() in _LOOPBACK_HOSTS
|
||||||
|
):
|
||||||
|
url = url.set(host="host.docker.internal")
|
||||||
|
|
||||||
query = dict(url.query)
|
query = dict(url.query)
|
||||||
if self.db_sslmode:
|
|
||||||
query.setdefault("sslmode", self.db_sslmode)
|
# PROD_ENV=true → RDS needs SSL. Local psql talks plain asyncpg (no SSL).
|
||||||
# asyncpg accepts ssl as an SSLMode name (require, verify-full, …), not "true".
|
sslmode = self.db_sslmode.strip() if self.db_sslmode else ("require" if self.prod_env else "")
|
||||||
|
if sslmode:
|
||||||
|
query.setdefault("sslmode", sslmode)
|
||||||
|
else:
|
||||||
|
query.pop("sslmode", None)
|
||||||
|
|
||||||
if async_driver and (mode := query.pop("sslmode", None)) is not None:
|
if async_driver and (mode := query.pop("sslmode", None)) is not None:
|
||||||
query["ssl"] = mode
|
query["ssl"] = mode
|
||||||
driver = "asyncpg" if async_driver else "psycopg2"
|
driver = "asyncpg" if async_driver else "psycopg2"
|
||||||
|
|
@ -199,11 +232,16 @@ async def close_db() -> None:
|
||||||
async def check_connection(retries: int | None = None, delay: float = 1.0) -> None:
|
async def check_connection(retries: int | None = None, delay: float = 1.0) -> None:
|
||||||
"""Wait for Postgres to answer `SELECT 1`, retrying with a capped backoff."""
|
"""Wait for Postgres to answer `SELECT 1`, retrying with a capped backoff."""
|
||||||
attempts = get_settings().db_connect_retries if retries is None else retries
|
attempts = get_settings().db_connect_retries if retries is None else retries
|
||||||
|
s = get_settings()
|
||||||
for attempt in range(1, max(attempts, 1) + 1):
|
for attempt in range(1, max(attempts, 1) + 1):
|
||||||
try:
|
try:
|
||||||
async with get_engine().connect() as conn:
|
async with get_engine().connect() as conn:
|
||||||
await conn.execute(text("SELECT 1"))
|
await conn.execute(text("SELECT 1"))
|
||||||
logger.info("connected to %s", database_url(hide_password=True))
|
logger.info(
|
||||||
|
"connected to %s [PROD_ENV=%s]",
|
||||||
|
database_url(hide_password=True),
|
||||||
|
s.prod_env,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if attempt >= attempts:
|
if attempt >= attempts:
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top
|
# Optional local overlay — NOT required for day-to-day use.
|
||||||
# of the production docker-compose.yml:
|
|
||||||
#
|
#
|
||||||
|
# Default workflow (host Postgres + loopback API ports) is in docker-compose.yml:
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Use this file only when you want --reload and bind-mounted source:
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
#
|
#
|
||||||
# - DB_HOST=host.docker.internal (host Postgres; container postgres still starts
|
# Do not set DB_HOST here. Keep PROD_ENV=false and DB_HOST=localhost in
|
||||||
# but is unused unless you flip DB_HOST back)
|
# backend/.env; db_setup rewrites localhost → host.docker.internal in Docker.
|
||||||
# - Publishes API / ATS / Redis / frontend ports for direct host access
|
|
||||||
# - Bind-mounts CV attachments to ./backend/inbox/decoded_attachments
|
|
||||||
# - Live source mounts + --reload on API / ATS / workers
|
|
||||||
#
|
|
||||||
# Frontend stays as the built nginx image — run `npm run dev` on the host for the
|
|
||||||
# Vite loop if you need HMR.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
|
||||||
ports:
|
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
|
||||||
|
|
||||||
backend-api:
|
backend-api:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
|
|
@ -38,8 +21,6 @@ services:
|
||||||
"8000",
|
"8000",
|
||||||
"--reload",
|
"--reload",
|
||||||
]
|
]
|
||||||
ports:
|
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
@ -57,8 +38,6 @@ services:
|
||||||
"8100",
|
"8100",
|
||||||
"--reload",
|
"--reload",
|
||||||
]
|
]
|
||||||
ports:
|
|
||||||
- "${ATS_PORT:-8100}:8100"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/srv/app
|
- ./app:/srv/app
|
||||||
|
|
||||||
|
|
@ -67,66 +46,28 @@ services:
|
||||||
- "${FRONTEND_PORT:-5173}:80"
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
|
|
||||||
taskiq-worker:
|
taskiq-worker:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
TASKIQ_QUEUE_NAME: inbox
|
|
||||||
TASKIQ_WORKER_NAME: worker-01
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
taskiq-scheduler:
|
taskiq-scheduler:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
TASKIQ_QUEUE_NAME: inbox
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
||||||
taskiq-cv-worker:
|
taskiq-cv-worker:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
TASKIQ_CV_QUEUE_NAME: cv_upload
|
|
||||||
TASKIQ_WORKER_NAME: cv-worker-01
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
taskiq-cv-scheduler:
|
taskiq-cv-scheduler:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
TASKIQ_CV_QUEUE_NAME: cv_upload
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
||||||
taskiq-mailbox-sync-worker:
|
taskiq-mailbox-sync-worker:
|
||||||
environment:
|
|
||||||
PYTHONPATH: /app
|
|
||||||
DB_HOST: host.docker.internal
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
|
||||||
TASKIQ_MAILBOX_SYNC_QUEUE_NAME: mailbox_sync
|
|
||||||
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Optional overlay: publish API / ATS / Redis on loopback for host tools
|
||||||
|
# (curl, redis-cli, Postman). Not required for the SPA - nginx proxies
|
||||||
|
# to backend-api on the Compose network.
|
||||||
|
#
|
||||||
|
# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d
|
||||||
|
#
|
||||||
|
# If bind fails because an IDE (Cursor/VS Code) still holds the port after a
|
||||||
|
# previous run, clear Port Forwarding in the IDE or override in backend/.env:
|
||||||
|
# BACKEND_PORT=8001
|
||||||
|
# ATS_PORT=8101
|
||||||
|
# REDIS_PORT=6380
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||||
|
|
||||||
|
backend-api:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"
|
||||||
|
|
||||||
|
ats-engine:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ATS_PORT:-8100}:8100"
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
# HR-ATS-Portal — production Compose stack (self-contained).
|
# HR-ATS-Portal — single Compose file for local and production.
|
||||||
#
|
#
|
||||||
# docker compose up -d --build
|
# Sole env file: backend/.env (no repo-root .env). Pass it for Compose
|
||||||
# docker compose ps
|
# variable substitution (${FRONTEND_PORT}, ${DB_*}, …):
|
||||||
# docker compose logs -f backend-api
|
|
||||||
#
|
#
|
||||||
# Public surface: only the frontend (default host port 80). The browser talks
|
# docker compose --env-file ./backend/.env up -d --build
|
||||||
# same-origin to nginx, which proxies API paths to backend-api. Postgres, Redis,
|
# docker compose --env-file ./backend/.env ps
|
||||||
# the API, ATS engine and Taskiq workers stay on the Compose network.
|
# docker compose --env-file ./backend/.env logs -f backend-api
|
||||||
#
|
#
|
||||||
# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts):
|
# Local (PROD_ENV=false, DB_HOST=localhost in backend/.env):
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
# Containers reach host Postgres via host.docker.internal (db_setup rewrite
|
||||||
|
# when IN_DOCKER=1). API / ATS / Redis stay on the Compose network by default
|
||||||
|
# (avoids IDE/Cursor stale port-forwards fighting Docker on Windows). Opt in:
|
||||||
|
# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d
|
||||||
|
#
|
||||||
|
# Prod (PROD_ENV=true, DB_* = RDS in backend/.env — edit manually):
|
||||||
|
# Same command. No host rewrite; SSL require when DB_SSLMODE is blank.
|
||||||
|
#
|
||||||
|
# Optional Compose Postgres (empty volume, not host/RDS data):
|
||||||
|
# docker compose --env-file ./backend/.env --profile postgres up -d postgres
|
||||||
|
#
|
||||||
|
# Optional live-reload / bind mounts (not required day-to-day):
|
||||||
|
# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
#
|
#
|
||||||
# See DOCKER.md for env checklist and verification.
|
# See DOCKER.md for env checklist and verification.
|
||||||
|
|
||||||
|
|
@ -27,26 +38,21 @@ x-backend-build: &backend-build
|
||||||
|
|
||||||
x-backend-env: &backend-env
|
x-backend-env: &backend-env
|
||||||
PYTHONPATH: /app
|
PYTHONPATH: /app
|
||||||
# Production default: container Postgres on the Compose network. The dev overlay
|
# Lets db_setup rewrite DB_HOST=localhost → host.docker.internal (local only).
|
||||||
# overrides DB_HOST to host.docker.internal. Credentials come from root .env so
|
IN_DOCKER: "1"
|
||||||
# they stay in lockstep with the postgres service (backend/.env is for host runs).
|
# Credentials and endpoints come from backend/.env via `env_file` below.
|
||||||
DB_HOST: ${DB_HOST:-postgres}
|
# DB_HOST is deliberately ABSENT here: an empty `environment:` override would
|
||||||
DB_PORT: ${DB_PORT:-5432}
|
# blank env_file / RDS. Do not name credential keys under environment.
|
||||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
#
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
# REDIS_URL and BACKEND_URL are compose-network DNS names, correct everywhere.
|
||||||
DB_NAME: ${DB_NAME:-hrms}
|
|
||||||
# On every API boot: upgrade any on-disk revisions (usually none in images),
|
|
||||||
# then apply ORM drift in-memory (no revision files written — versions stay
|
|
||||||
# gitignored and out of the image).
|
|
||||||
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
|
||||||
DB_AUTOGENERATE: ${DB_AUTOGENERATE:-true}
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
# Prefer root/.env EMAIL_URL. Fall back to host-gateway when mail is on this machine.
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
|
||||||
BACKEND_URL: http://backend-api:8000
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
# Apify token, if kept at repo root rather than in backend/.env. Harmless when
|
||||||
|
# unset: the app treats empty as absent.
|
||||||
|
APIFY_API_TOKEN: ${APIFY_API_TOKEN:-${APIFY_TOKEN:-}}
|
||||||
|
|
||||||
# Shared CV storage. Named volume in production so API + workers see the same
|
# Shared CV storage. Named volume so API + workers see the same files.
|
||||||
# files without a host path. Dev overlay remounts ./backend/inbox/decoded_attachments.
|
# Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments.
|
||||||
x-attachments: &attachments
|
x-attachments: &attachments
|
||||||
- attachments-data:/app/inbox/decoded_attachments
|
- attachments-data:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
|
|
@ -55,15 +61,22 @@ x-backend-service: &backend-service
|
||||||
image: hrms-backend:local
|
image: hrms-backend:local
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
env_file:
|
env_file:
|
||||||
|
# backend/.env is the source of truth (plain DB_* + PROD_ENV).
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
|
# Optional local overrides (required:false). Do not set DB_HOST=postgres
|
||||||
|
# here unless you intentionally start the postgres profile.
|
||||||
|
- path: ./docker.local.env
|
||||||
|
required: false
|
||||||
environment: *backend-env
|
environment: *backend-env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
# required:false — default stack never starts postgres (host Postgres or RDS).
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
required: false
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
|
|
@ -73,7 +86,8 @@ services:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: hrms-redis
|
container_name: hrms-redis
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
# Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379.
|
# No host publish by default (Compose DNS redis:6379). Optional loopback:
|
||||||
|
# docker-compose.host-ports.yml
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -84,19 +98,29 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
# --- Postgres (always on in production) ------------------------------------------
|
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
|
||||||
|
# Default local path is host Postgres (DB_HOST=localhost → host.docker.internal).
|
||||||
|
# Prod uses AWS RDS. Opt in only when you want a disposable Compose DB:
|
||||||
|
#
|
||||||
|
# docker compose --profile postgres up -d postgres
|
||||||
|
# # then set DB_HOST=postgres in backend/.env and recreate backend services
|
||||||
|
#
|
||||||
postgres:
|
postgres:
|
||||||
|
profiles: ["postgres"]
|
||||||
build:
|
build:
|
||||||
context: ./docker/postgres
|
context: ./docker/postgres
|
||||||
image: hrms-postgres:local
|
image: hrms-postgres:local
|
||||||
container_name: hrms-postgres
|
container_name: hrms-postgres
|
||||||
environment:
|
environment:
|
||||||
# Interpolated from the shell or root .env, NOT from backend/.env.
|
# Compose substitution needs: docker compose --env-file ./backend/.env …
|
||||||
|
# (there is no repo-root .env). Defaults apply if the flag is omitted.
|
||||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: ${DB_NAME:-hrms}
|
POSTGRES_DB: ${DB_NAME:-hrms}
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||||
# Not published in production. Dev overlay can bind 127.0.0.1:${POSTGRES_PORT:-5433}:5432.
|
ports:
|
||||||
|
# 5433: the host's own Postgres owns 5432.
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -126,7 +150,9 @@ services:
|
||||||
"--proxy-headers",
|
"--proxy-headers",
|
||||||
"--forwarded-allow-ips=*",
|
"--forwarded-allow-ips=*",
|
||||||
]
|
]
|
||||||
# Not published in production — browsers reach the API via frontend nginx.
|
# Published for host tools / Vite (`VITE_API_BASE=http://127.0.0.1:8000`).
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
volumes: *attachments
|
volumes: *attachments
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|
@ -150,9 +176,12 @@ services:
|
||||||
container_name: hrms-ats-engine
|
container_name: hrms-ats-engine
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
- path: ./.env
|
- path: ./docker.local.env
|
||||||
required: false
|
required: false
|
||||||
# Not published in production.
|
environment:
|
||||||
|
IN_DOCKER: "1"
|
||||||
|
# No host publish by default (backend uses ats-engine:8100). Optional:
|
||||||
|
# docker-compose.host-ports.yml
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|
@ -168,7 +197,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
# --- React portal (only published host port) -------------------------------------
|
# Portal on FRONTEND_PORT (default 5173). API also on BACKEND_PORT (8000).
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|
@ -181,7 +210,7 @@ services:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-80}:80"
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and hits a
|
# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and can
|
||||||
# different listener (WSL/Docker on :8000) instead of the Windows uvicorn on 127.0.0.1.
|
# hit a different listener than the Windows uvicorn / Docker publish.
|
||||||
|
#
|
||||||
|
# Docker Compose (prod-like): leave empty and run `npm run dev` with the
|
||||||
|
# host-ports overlay so Vite can proxy — or open the built SPA on :8080
|
||||||
|
# (nginx same-origin proxy; no VITE_API_BASE needed).
|
||||||
|
#
|
||||||
|
# Hybrid (Vite on host + API in Docker): publish API first, then:
|
||||||
|
# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d
|
||||||
VITE_API_BASE=http://127.0.0.1:8000
|
VITE_API_BASE=http://127.0.0.1:8000
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,15 @@ server {
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
|
||||||
# Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so
|
# Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so
|
||||||
# fetch('/jobs/fetch') stays on this host; without this block nginx would
|
# fetch('/jobs/fetch') stays on this host. OpenAPI (/docs, /redoc,
|
||||||
# serve index.html for those paths (200 HTML) and the Jobs board would
|
# /openapi.json) is intentionally NOT proxied.
|
||||||
# parse an empty payload. OpenAPI (/docs, /redoc, /openapi.json) is
|
#
|
||||||
# intentionally NOT proxied — keep the schema off the public edge.
|
# Paths that are BOTH React routes (/jobs, /inbox, …) and API prefixes must
|
||||||
location ~ ^/(health|users|roles|permissions|permission-tags|managers|inbox|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search)(/|$) {
|
# require a sub-path: otherwise a cold open / refresh of /jobs is stolen by
|
||||||
|
# the proxy and returns a FastAPI 404 instead of index.html.
|
||||||
|
|
||||||
|
# SPA page roots that also prefix API calls — sub-path required.
|
||||||
|
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
|
||||||
proxy_pass http://backend-api:8000;
|
proxy_pass http://backend-api:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
@ -29,6 +33,22 @@ server {
|
||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-only prefixes (no SPA page at the bare path).
|
||||||
|
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet)(/|$) {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_request_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# One SPA at `/`. Without this, /auth/confirm-email (a router path, not a
|
# One SPA at `/`. Without this, /auth/confirm-email (a router path, not a
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ export default defineConfig({
|
||||||
base: '/',
|
base: '/',
|
||||||
build: { outDir: 'dist', emptyOutDir: true, sourcemap: true },
|
build: { outDir: 'dist', emptyOutDir: true, sourcemap: true },
|
||||||
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
||||||
server: { port: 5173 },
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
// Same-origin style for local Vite when VITE_API_BASE is empty.
|
||||||
|
// Requires API published on the host (docker-compose.host-ports.yml).
|
||||||
|
proxy: {
|
||||||
|
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox)(/|$)': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
preview: { port: 4173 },
|
preview: { port: 4173 },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue