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
ahmed.mujtaba 2026-08-24 20:38:15 +05:00
parent ea6f67786c
commit 8c12872c6e
14 changed files with 365 additions and 318 deletions

View File

@ -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

3
.gitignore vendored
View File

@ -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
View File

@ -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
``` ```

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 },
}) })