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
**_**_**.py
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
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
```

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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