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
|
||||
**_**_**.py
|
||||
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
|
||||
|
||||
Production stack is self-contained: Postgres, Redis, API, ATS engine, Taskiq
|
||||
workers, and the React SPA. Only the frontend is published on the host.
|
||||
## How the browser reaches the API
|
||||
|
||||
## 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
|
||||
# 1. Root compose env (DB password, port, workers) — see .env.example
|
||||
cp .env.example .env
|
||||
# Edit DB_PASSWORD (and JWT_SECRET_KEY inside backend/.env)
|
||||
|
||||
# 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
|
||||
```env
|
||||
FRONTEND_PORT=5173
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_URL=http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
| Service | Image | Host port |
|
||||
|---|---|---|
|
||||
| `frontend` | `hrms-frontend:local` | `${FRONTEND_PORT:-80}` |
|
||||
| `backend-api` | `hrms-backend:local` | (internal) |
|
||||
| `ats-engine` | `hrms-ats-engine:local` | (internal) |
|
||||
| `redis` | `redis:7-alpine` | (internal) |
|
||||
| `postgres` | `hrms-postgres:local` | (internal) |
|
||||
| Taskiq workers / schedulers | `hrms-backend:local` | (internal) |
|
||||
Sole env file: **`backend/.env`** (no repo-root `.env`). Always pass it for
|
||||
Compose variable substitution:
|
||||
|
||||
```bash
|
||||
docker compose --env-file ./backend/.env up -d --build
|
||||
```
|
||||
|
||||
## 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`.
|
||||
CV files live in the `attachments-data` named volume (shared by API + workers).
|
||||
|
||||
### 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.
|
||||
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).
|
||||
3. If `DB_AUTOGENERATE=true` (Compose default) → detect ORM drift (new/changed/removed
|
||||
columns or tables) and apply DDL **in-memory** — no `versions/*.py` is written
|
||||
on the server.
|
||||
3. If `DB_AUTOGENERATE=true` → detect ORM drift and apply DDL **in-memory**.
|
||||
4. Apply any pending `backend/migrations/manual/*.sql` (seed/RBAC batches only).
|
||||
|
||||
Revision scripts under `backend/migrations/versions/` remain gitignored and are
|
||||
listed in `.dockerignore` so they never ship in the production image.
|
||||
|
||||
Toggle with root `.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
|
||||
Toggle in `backend/.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
docker compose config
|
||||
curl -sf http://127.0.0.1/health # nginx → backend /health
|
||||
curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
|
||||
docker compose logs -f backend-api
|
||||
docker compose --env-file ./backend/.env config
|
||||
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:${FRONTEND_PORT:-8080}/
|
||||
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
|
||||
|
||||
- Never bake `.env` into images (`.dockerignore` already excludes them).
|
||||
- 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
|
||||
|
||||
This stack serves HTTP on the frontend port. Terminate TLS at a reverse proxy or
|
||||
cloud load balancer in front of port 80.
|
||||
|
||||
## 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`).
|
||||
cloud load balancer in front of that port.
|
||||
|
||||
## 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
|
||||
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
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend-api
|
||||
docker compose logs -f taskiq-worker
|
||||
docker compose restart backend-api
|
||||
docker compose down # keep volumes
|
||||
docker compose down -v # wipe postgres + attachments + redis data
|
||||
docker compose --env-file ./backend/.env logs -f backend-api
|
||||
docker compose --env-file ./backend/.env logs -f taskiq-worker
|
||||
docker compose --env-file ./backend/.env restart backend-api
|
||||
docker compose --env-file ./backend/.env down # keep volumes
|
||||
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.
|
||||
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)):
|
||||
|
||||
```
|
||||
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):
|
||||
Sole file: `backend/.env` (see [backend/.env.example](backend/.env.example)):
|
||||
|
||||
```
|
||||
PROD_ENV=false
|
||||
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set DB_PASSWORD
|
||||
cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, …
|
||||
docker compose up -d --build
|
||||
# SPA: http://localhost/ health: http://localhost/health
|
||||
cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, DB_*, …
|
||||
docker compose --env-file ./backend/.env up -d --build
|
||||
# SPA: http://localhost:8080/ health: http://localhost:8080/health
|
||||
```
|
||||
|
||||
Local day-to-day (host Postgres, exposed API ports, `--reload`):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
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`.
|
||||
|
||||
### First run
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,14 @@ Deliberate omissions:
|
|||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
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
|
||||
# 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
|
||||
|
|
@ -44,7 +48,7 @@ class Settings(BaseSettings):
|
|||
"""Runtime configuration. Immutable once constructed."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file=_BACKEND_ENV,
|
||||
# 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
|
||||
# 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_HOST=
|
||||
DB_PORT=
|
||||
DB_NAME=
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
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_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_MAX_ATTEMPTS=5
|
||||
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
FRONTEND_URL=http://127.0.0.1:5173
|
||||
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
||||
CONFIRM_TOKEN_TTL_SECONDS=86400
|
||||
CONFIRM_TOKEN_RESEND_SECONDS=60
|
||||
|
|
@ -47,32 +61,27 @@ OPENAI_BASE_URL=
|
|||
OPENAI_ORGANIZATION=
|
||||
OPENAI_PROJECT=
|
||||
|
||||
# ATS scoring (bulk-ats engine embedded via `pip install -e ..`).
|
||||
# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared.
|
||||
# ATS scoring (bulk-ats engine). Shared OPENAI_* names above.
|
||||
OPENAI_EFFORT=low
|
||||
OPENAI_ENABLE_PROMPT_CACHE=true
|
||||
OPENAI_TIMEOUT_SECONDS=120
|
||||
SCORING_CONCURRENCY=5
|
||||
MAX_RESUMES_PER_REQUEST=50
|
||||
MAX_PDF_SIZE_MB=10
|
||||
MAX_JD_CHARS=30000
|
||||
MAX_RESUME_CHARS=60000
|
||||
|
||||
# Inbox intake gate (inbox_classifier/): only mail judged to be a job application
|
||||
# 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 intake gate (inbox_classifier/).
|
||||
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_CONCURRENCY=5
|
||||
INBOX_TRIAGE_MAX_SUBJECT_CHARS=300
|
||||
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
|
||||
|
||||
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_CV_QUEUE_NAME=cv_upload
|
||||
TASKIQ_MAX_RETRIES=3
|
||||
|
|
@ -82,3 +91,17 @@ TASKIQ_DLQ_STREAM=taskiq:dlq
|
|||
TASKIQ_IDLE_TIMEOUT_MS=600000
|
||||
MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local
|
||||
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
|
||||
|
||||
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
|
||||
own keys with `os.getenv`.
|
||||
`db_setup.Settings` reads `backend/.env` only (no repo-root `.env`); every other module reads its
|
||||
own keys with `os.getenv` from the same file.
|
||||
|
||||
### Database
|
||||
|
||||
|
|
@ -1053,7 +1053,9 @@ Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](.
|
|||
# Production
|
||||
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 logs -f taskiq-worker
|
||||
docker compose logs -f taskiq-cv-worker
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""PostgreSQL connection, async SQLAlchemy ORM and session management.
|
||||
|
||||
Configuration comes from the environment, with `.env` read from the repo root or
|
||||
from `backend/` (`Db_USERNAME`, `Db_PASSWORD`, `Db_HOST`, `Db_PORT`, `Db_NAME`, and
|
||||
the `DB_*` tuning fields below). Alembic lives in `alembic_setup.py`; `init_db()`
|
||||
calls into it.
|
||||
Configuration comes from the environment, with `.env` read from `backend/`
|
||||
(`DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `PROD_ENV`, and
|
||||
the `DB_*` tuning fields below). There is no repo-root `.env`. Alembic lives in
|
||||
`alembic_setup.py`; `init_db()` calls into it.
|
||||
|
||||
app = FastAPI(lifespan=lifespan) # migrate on startup
|
||||
async def endpoint(db: AsyncSession = Depends(get_session)): ...
|
||||
|
|
@ -13,13 +13,14 @@ calls into it.
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, AsyncIterator, Sequence
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
from sqlalchemy import MetaData, text
|
||||
|
|
@ -32,40 +33,48 @@ from sqlalchemy.ext.asyncio import (
|
|||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlmodel import SQLModel
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("db")
|
||||
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):
|
||||
"""Every field is overridden by an environment variable of the same name."""
|
||||
|
||||
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
|
||||
db_username: str = os.getenv("DB_USERNAME")
|
||||
db_password: str = os.getenv("DB_PASSWORD")
|
||||
db_host: str = os.getenv("DB_HOST")
|
||||
db_port: int = int(os.getenv("DB_PORT"))
|
||||
db_name: str = os.getenv("DB_NAME")
|
||||
db_sslmode: str = "" # e.g. "require" on Azure
|
||||
|
||||
database_url: str = "" # full DSN; wins over the DB_* parts below
|
||||
db_username: str = ""
|
||||
db_password: str = ""
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 5432
|
||||
db_name: str = ""
|
||||
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_default_schema: str = "app" # schema for models that declare none
|
||||
db_default_schema: str = "app"
|
||||
db_echo: bool = False
|
||||
db_pool_size: int = 5
|
||||
db_max_overflow: int = 10
|
||||
db_pool_recycle: int = 1800
|
||||
db_connect_retries: int = 10
|
||||
db_auto_migrate: bool = True # run `upgrade head` on startup
|
||||
db_autogenerate: bool = True # write a revision when models drift from the schema
|
||||
db_model_modules: Annotated[list[str], NoDecode] = [] # empty means auto-discover
|
||||
db_auto_migrate: bool = True
|
||||
db_autogenerate: bool = True
|
||||
db_model_modules: Annotated[list[str], NoDecode] = []
|
||||
app_name: str = "hr-ats-portal"
|
||||
|
||||
@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 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:
|
||||
"""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 = (
|
||||
make_url(self.database_url)
|
||||
if self.database_url
|
||||
|
|
@ -89,10 +110,22 @@ class Settings(BaseSettings):
|
|||
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)
|
||||
if self.db_sslmode:
|
||||
query.setdefault("sslmode", self.db_sslmode)
|
||||
# asyncpg accepts ssl as an SSLMode name (require, verify-full, …), not "true".
|
||||
|
||||
# PROD_ENV=true → RDS needs SSL. Local psql talks plain asyncpg (no SSL).
|
||||
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:
|
||||
query["ssl"] = mode
|
||||
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:
|
||||
"""Wait for Postgres to answer `SELECT 1`, retrying with a capped backoff."""
|
||||
attempts = get_settings().db_connect_retries if retries is None else retries
|
||||
s = get_settings()
|
||||
for attempt in range(1, max(attempts, 1) + 1):
|
||||
try:
|
||||
async with get_engine().connect() as conn:
|
||||
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
|
||||
except Exception as exc:
|
||||
if attempt >= attempts:
|
||||
|
|
|
|||
|
|
@ -1,33 +1,16 @@
|
|||
# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top
|
||||
# of the production docker-compose.yml:
|
||||
# Optional local overlay — NOT required for day-to-day use.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# - DB_HOST=host.docker.internal (host Postgres; container postgres still starts
|
||||
# but is unused unless you flip DB_HOST back)
|
||||
# - 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.
|
||||
# Do not set DB_HOST here. Keep PROD_ENV=false and DB_HOST=localhost in
|
||||
# backend/.env; db_setup rewrites localhost → host.docker.internal in Docker.
|
||||
|
||||
services:
|
||||
redis:
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||
|
||||
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:
|
||||
[
|
||||
"uvicorn",
|
||||
|
|
@ -38,8 +21,6 @@ services:
|
|||
"8000",
|
||||
"--reload",
|
||||
]
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
|
@ -57,8 +38,6 @@ services:
|
|||
"8100",
|
||||
"--reload",
|
||||
]
|
||||
ports:
|
||||
- "${ATS_PORT:-8100}:8100"
|
||||
volumes:
|
||||
- ./app:/srv/app
|
||||
|
||||
|
|
@ -67,66 +46,28 @@ services:
|
|||
- "${FRONTEND_PORT:-5173}:80"
|
||||
|
||||
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:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||
|
||||
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:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
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:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||
|
||||
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:
|
||||
- ./backend:/app
|
||||
- ./app:/app/app
|
||||
|
||||
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:
|
||||
- ./backend:/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
|
||||
# docker compose ps
|
||||
# docker compose logs -f backend-api
|
||||
# Sole env file: backend/.env (no repo-root .env). Pass it for Compose
|
||||
# variable substitution (${FRONTEND_PORT}, ${DB_*}, …):
|
||||
#
|
||||
# Public surface: only the frontend (default host port 80). The browser talks
|
||||
# same-origin to nginx, which proxies API paths to backend-api. Postgres, Redis,
|
||||
# the API, ATS engine and Taskiq workers stay on the Compose network.
|
||||
# docker compose --env-file ./backend/.env up -d --build
|
||||
# docker compose --env-file ./backend/.env ps
|
||||
# docker compose --env-file ./backend/.env logs -f backend-api
|
||||
#
|
||||
# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
# Local (PROD_ENV=false, DB_HOST=localhost in backend/.env):
|
||||
# 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.
|
||||
|
||||
|
|
@ -27,26 +38,21 @@ x-backend-build: &backend-build
|
|||
|
||||
x-backend-env: &backend-env
|
||||
PYTHONPATH: /app
|
||||
# Production default: container Postgres on the Compose network. The dev overlay
|
||||
# overrides DB_HOST to host.docker.internal. Credentials come from root .env so
|
||||
# they stay in lockstep with the postgres service (backend/.env is for host runs).
|
||||
DB_HOST: ${DB_HOST:-postgres}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
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}
|
||||
# Lets db_setup rewrite DB_HOST=localhost → host.docker.internal (local only).
|
||||
IN_DOCKER: "1"
|
||||
# Credentials and endpoints come from backend/.env via `env_file` below.
|
||||
# DB_HOST is deliberately ABSENT here: an empty `environment:` override would
|
||||
# blank env_file / RDS. Do not name credential keys under environment.
|
||||
#
|
||||
# REDIS_URL and BACKEND_URL are compose-network DNS names, correct everywhere.
|
||||
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
|
||||
# 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
|
||||
# files without a host path. Dev overlay remounts ./backend/inbox/decoded_attachments.
|
||||
# Shared CV storage. Named volume so API + workers see the same files.
|
||||
# Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments.
|
||||
x-attachments: &attachments
|
||||
- attachments-data:/app/inbox/decoded_attachments
|
||||
|
||||
|
|
@ -55,15 +61,22 @@ x-backend-service: &backend-service
|
|||
image: hrms-backend:local
|
||||
working_dir: /app
|
||||
env_file:
|
||||
# backend/.env is the source of truth (plain DB_* + PROD_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
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# required:false — default stack never starts postgres (host Postgres or RDS).
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
|
|
@ -73,7 +86,8 @@ services:
|
|||
image: redis:7-alpine
|
||||
container_name: hrms-redis
|
||||
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:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
|
|
@ -84,19 +98,29 @@ services:
|
|||
restart: unless-stopped
|
||||
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:
|
||||
profiles: ["postgres"]
|
||||
build:
|
||||
context: ./docker/postgres
|
||||
image: hrms-postgres:local
|
||||
container_name: hrms-postgres
|
||||
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_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-hrms}
|
||||
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:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
@ -126,7 +150,9 @@ services:
|
|||
"--proxy-headers",
|
||||
"--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
|
||||
healthcheck:
|
||||
test:
|
||||
|
|
@ -150,9 +176,12 @@ services:
|
|||
container_name: hrms-ats-engine
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
- path: ./.env
|
||||
- path: ./docker.local.env
|
||||
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:
|
||||
test:
|
||||
[
|
||||
|
|
@ -168,7 +197,7 @@ services:
|
|||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
# --- React portal (only published host port) -------------------------------------
|
||||
# Portal on FRONTEND_PORT (default 5173). API also on BACKEND_PORT (8000).
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
|
@ -181,7 +210,7 @@ services:
|
|||
backend-api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-80}:80"
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||
interval: 15s
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and hits a
|
||||
# different listener (WSL/Docker on :8000) instead of the Windows uvicorn on 127.0.0.1.
|
||||
# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and can
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ server {
|
|||
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
|
||||
# fetch('/jobs/fetch') stays on this host; without this block nginx would
|
||||
# serve index.html for those paths (200 HTML) and the Jobs board would
|
||||
# parse an empty payload. OpenAPI (/docs, /redoc, /openapi.json) is
|
||||
# intentionally NOT proxied — keep the schema off the public edge.
|
||||
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)(/|$) {
|
||||
# fetch('/jobs/fetch') stays on this host. OpenAPI (/docs, /redoc,
|
||||
# /openapi.json) is intentionally NOT proxied.
|
||||
#
|
||||
# Paths that are BOTH React routes (/jobs, /inbox, …) and API prefixes must
|
||||
# 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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
|
@ -29,6 +33,22 @@ server {
|
|||
proxy_connect_timeout 10s;
|
||||
proxy_send_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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ export default defineConfig({
|
|||
base: '/',
|
||||
build: { outDir: 'dist', emptyOutDir: true, sourcemap: true },
|
||||
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 },
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue