pull/24/head
ahmed.mujtaba 2026-08-21 13:00:22 +05:00
parent c536def585
commit a8b213be39
18 changed files with 511 additions and 232 deletions

View File

@ -31,6 +31,11 @@ frontend/
# Candidate CVs live on the bind mount, not inside an image.
backend/inbox/decoded_attachments/
# Alembic revision scripts stay out of images (gitignored; never ship to prod).
# Schema drift is applied filelessly at API boot when DB_AUTOGENERATE=true.
backend/migrations/versions/*.py
!backend/migrations/versions/.gitkeep
docs/
tests/
scripts/

View File

@ -1,4 +1,38 @@
# Copy to .env and fill in. Never commit .env.
# 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=
@ -15,7 +49,6 @@ OPENAI_MODEL=gpt-5.4-mini
OPENAI_MAX_OUTPUT_TOKENS=4000
# none | minimal | low | medium | high | xhigh
# Per-model support varies; the API rejects a level the model does not implement.
OPENAI_EFFORT=low
OPENAI_MAX_RETRIES=3

1
.gitignore vendored
View File

@ -57,4 +57,3 @@ frontend/dist/
**.pdf
**_**_**.py
Utopia-ai-hr-ats-portal 1.pem
db_setup.py

116
DOCKER.md Normal file
View File

@ -0,0 +1,116 @@
# 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.
## Production
```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
```
| 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) |
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`):
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.
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`).
### 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
```
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.
### 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`).
## Data migration
The 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
```

View File

@ -115,82 +115,24 @@ docker compose up redis taskiq-worker taskiq-scheduler
## Docker
Every service has its own image and its own container, all in one
[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run**
— it sits behind a compose profile, and the stack talks to the PostgreSQL server
already running on the host.
Self-contained production stack (Postgres in Compose; only the SPA is published).
See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and the
local host-Postgres overlay.
```bash
docker compose build
docker compose up -d
docker compose ps
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
```
| Service | Image | Host port | Built from |
|---|---|---|---|
| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) |
| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same |
| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) |
| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) |
| `redis` | `redis:7-alpine` | 6379 | — |
| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) |
The backend image builds from the **repo root**, not `./backend`: `job/candidate`
imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively,
so a `./backend` context produces workers that die on `No module named 'app'`.
### The shared file mount
`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container
that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the
identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same
file the worker opens, and absolute paths stored in the database resolve in either
direction (`inbox.plugins.resolve_attachment_path` also falls back to
basename-under-that-folder for rows written by a host process). Point it elsewhere with
`ATTACHMENTS_DIR=/some/host/path`.
### Talking to the host
`backend/.env` is written for host processes, so compose overrides the three values a
container needs: `DB_HOST=host.docker.internal` (the local Postgres),
`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and
`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the
Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`.
> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind
> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1`
> first — so both listen and requests silently reach whichever won. Same for 5173. Use
> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run.
`VITE_API_BASE` is inlined into the bundle at **build** time (default
`http://localhost:8000`), so changing the API origin means rebuilding the frontend
image, not restarting the container.
### The Postgres profile
The image is defined alongside everything else, but the `postgres` profile keeps it out
of `docker compose build` and `docker compose up` — bringing it up is always explicit:
Local day-to-day (host Postgres, exposed API ports, `--reload`):
```bash
docker compose --profile postgres build postgres
docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only
value that changes — services reach it on 5432 over the compose network) and recreate
the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it
does not share the host server's data.
### Live-code overlay
[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no
services or images, it only adds source bind mounts and `--reload` to the ones above:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
### 4. First run
### First run
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
`candidates.create` + `candidates.view` (RBAC screen or seed a role).

View File

@ -3,10 +3,6 @@
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
#
# The backend imports this same package as a library; this image is the separate
# service form of it, so it can be scaled, restarted or pointed at a different model
# independently of the portal API.
#
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
#
# docker build -f app/Dockerfile -t hrms-ats-engine:local .
@ -20,16 +16,17 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /srv
RUN groupadd --system app && useradd --system --gid app --home-dir /srv --shell /usr/sbin/nologin app
COPY pyproject.toml ./
COPY app/ ./app/
# Installs the pinned dependencies from pyproject.toml along with the package. The
# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's
# source bind mount is what actually executes.
RUN pip install --no-cache-dir .
# Installs the pinned dependencies from pyproject.toml along with the package.
RUN pip install --no-cache-dir . \
&& chown -R app:app /srv
USER app
EXPOSE 8100
# No module-level `app` object exists on purpose (app/main.py), so the factory form
# is mandatory here.
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]

View File

@ -1,8 +1,7 @@
# syntax=docker/dockerfile:1
#
# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and
# scheduler, CV worker and scheduler) run from this one image; docker-compose picks
# the process with `command:`.
# Backend image. The FastAPI API and all Taskiq processes run from this one image;
# docker-compose picks the process with `command:`.
#
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
#
@ -22,21 +21,23 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app
RUN groupadd --system app && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for
# this in a host environment — copying it in is the container equivalent, and its
# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned
# above.
# resolves under PYTHONPATH=/app.
COPY backend/ /app/
COPY app/ /app/app/
# Decoded CV attachments are read and written here. docker-compose bind-mounts the
# host folder over this path so every container shares one set of files; creating it
# in the image keeps an un-mounted container from failing on first write.
RUN mkdir -p /app/inbox/decoded_attachments
# Decoded CV attachments are read and written here. Compose mounts a named volume
# (prod) or a host bind (dev) over this path; creating it in the image keeps an
# un-mounted container from failing on first write.
RUN mkdir -p /app/inbox/decoded_attachments \
&& chown -R app:app /app
USER app
EXPOSE 8000

View File

@ -1046,19 +1046,23 @@ and a module called `alembic.py` would shadow the installed package.
## Docker
The repo-root `docker-compose.yml` runs Redis plus the four Taskiq processes (inbox
worker/scheduler and CV-upload worker/scheduler); the API itself is expected to run on the
host (the compose file points the containers at `host.docker.internal` for the database).
Production Compose is self-contained (Postgres in Docker; only the SPA is published).
Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](../DOCKER.md)**.
```bash
docker compose up -d # from the repo root
# Production
docker compose up -d --build
# Local (host DB, published ports, --reload)
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
```
`backend/Dockerfile` builds a `python:3.12-slim` image whose default command is the Taskiq
worker. `backend/inbox/decoded_attachments` is bind-mounted so the worker can read the
attachments the API wrote.
`backend/Dockerfile` builds a `python:3.12-slim` image (non-root `app` user) used by
the API and every Taskiq process. In production, CV attachments live in the
`attachments-data` named volume; the dev overlay bind-mounts
`backend/inbox/decoded_attachments`.
---

View File

@ -23,8 +23,9 @@ from pathlib import Path
from typing import Any, AsyncIterator, Callable, Sequence
from alembic import command
from alembic.autogenerate import compare_metadata
from alembic.autogenerate import compare_metadata, produce_migrations
from alembic.config import Config
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import MetaData, text
@ -197,7 +198,10 @@ def context_options() -> dict[str, Any]:
s = get_settings()
return {
"compare_type": True,
"compare_server_default": True,
# Server-default string forms differ between reflection and models
# (e.g. now() vs CURRENT_TIMESTAMP); comparing them re-applies the same
# ALTER on every boot when we sync filelessly.
"compare_server_default": False,
"include_schemas": bool(s.db_schemas),
"version_table_schema": s.db_default_schema or None,
"include_object": _include_object,
@ -233,18 +237,110 @@ async def current() -> str | None:
return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision())
MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image
async def upgrade(revision: str = "head") -> None:
if revision == "head" and not head():
logger.info("no alembic revisions on disk; skipping upgrade")
return
await _run(lambda c: command.upgrade(config(c), revision))
logger.info("upgraded to %s", revision)
async def stamp(revision: str = "head") -> None:
target = revision
if target == "head" and not head():
target = MODELS_STAMP
await _run(lambda c: command.stamp(config(c), target))
logger.info("stamped database at %s", target)
async def _schema_is_empty() -> bool:
"""True when the app schema has never been populated (fresh Compose volume)."""
schema = get_settings().db_default_schema or "public"
async with get_engine().connect() as conn:
row = (
await conn.execute(
text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = :schema AND table_name = 'users' LIMIT 1"
),
{"schema": schema},
)
).first()
return row is None
async def bootstrap_empty() -> None:
"""Create every model table and stamp a revision marker.
Several historical revisions assume tables (e.g. job_posts) that were never
given a create_table in the chain they only exist on DBs that grew via
autogenerate. A brand-new Compose Postgres volume therefore cannot
`upgrade head`. Creating from metadata then stamping is the production
bootstrap for that case; existing databases keep the normal upgrade path.
Revision `.py` files are gitignored and excluded from images; stamp uses
`models` when the versions directory is empty.
"""
metadata = target_metadata()
async with get_engine().begin() as conn:
await conn.run_sync(metadata.create_all)
await stamp("head")
logger.info("bootstrapped empty database from models")
async def downgrade(revision: str = "-1") -> None:
await _run(lambda c: command.downgrade(config(c), revision))
logger.info("downgraded to %s", revision)
def _apply_upgrade_ops(connection: Connection) -> int:
"""Apply ORM→DB diffs in-process without writing a revision file."""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
ctx = MigrationContext.configure(connection, opts=opts)
script = produce_migrations(ctx, target_metadata())
if script.upgrade_ops.is_empty():
return 0
operations = Operations(ctx)
applied = 0
stack = [script.upgrade_ops]
while stack:
elem = stack.pop(0)
if hasattr(elem, "ops"):
stack.extend(elem.ops)
else:
operations.invoke(elem)
applied += 1
return applied
async def apply_model_drift() -> bool:
"""Sync the live schema to the ORM without creating migration files.
Used by Docker/prod boots so `versions/*.py` can stay gitignored and out of
the image. Returns True when at least one DDL op was applied.
"""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
diffs = await _run(
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
)
if not diffs:
logger.info("schema matches the models")
return False
logger.info("%s schema difference(s) detected; applying without revision files", len(diffs))
applied = await _run(_apply_upgrade_ops)
logger.info("applied %s schema operation(s)", applied)
return applied > 0
async def autogenerate(message: str = "auto") -> str | None:
"""Write a revision if the models have drifted; return its id, or None."""
"""Write a revision if the models have drifted; return its id, or None.
Local/CLI only. Docker boots use `apply_model_drift` instead so revision
files are never written on the server.
"""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
diffs = await _run(
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
@ -308,12 +404,21 @@ async def _lock() -> AsyncIterator[None]:
async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None:
"""Apply pending revisions, fresh model drift, then manual SQL, under the lock."""
"""Bring the DB in line with models, then run manual SQL, under the lock.
Empty Compose volumes bootstrap from models (create_all + stamp). Every boot
upgrades any on-disk revisions (skipped when versions are absent from the
image), then when DB_AUTOGENERATE is on applies ORM drift in-memory so
no `versions/*.py` files are written on the server.
"""
should_autogen = get_settings().db_autogenerate if autogen is None else autogen
async with _lock():
await upgrade()
if should_autogen and await autogenerate(message):
if await _schema_is_empty():
await bootstrap_empty()
else:
await upgrade()
if should_autogen:
await apply_model_drift()
await run_manual_sql()
logger.info("database at revision %s", await current())

View File

@ -0,0 +1,8 @@
{
"account": "",
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
"client_secret": "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",
"refresh_token": "1//038IfgCu3D42fCgYIARAAGAMSNwF-L9IrYAZ_DJUqwC9ETwLtH23D46j61gWMwFQjRWPklFZIiLmv7Q3-TOgcNxTrjO30jgkeYOo",
"type": "authorized_user",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1 @@
{"installed":{"client_id":"679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com","project_id":"hrms-ats-portal","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}

View File

View File

View File

@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from db_setup import lifespan as db_lifespan
from inbox.app import router as inbox_router
from users.app import router as users_router
@ -88,6 +89,13 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get("/health")
async def health():
# Liveness only — no DB. Compose healthchecks and load balancers hit this.
return JSONResponse(content={"status":"ok","status_code":200})
app.include_router(inbox_router)
app.include_router(users_router)
app.include_router(role_router)

View File

@ -1,28 +1,49 @@
# Live-code overlay. Mounts the source folders into the running containers, so what
# executes is what is on disk on the host — edit, save, uvicorn reloads.
# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top
# of the production docker-compose.yml:
#
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# The CV attachments mount from the base file still applies: compose merges volumes
# by target path, and /app/inbox/decoded_attachments is nested under the /app mount,
# so the daemon mounts the parent first and the attachments folder on top.
# - 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
#
# Two mounts per Python service, not one: /app is the backend tree and /app/app is the
# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over
# /app would hide the engine baked into the image and every worker would fail on
# import.
#
# The frontend stays as the built nginx image — a Vite dev server wants node_modules
# on the mount, which is slow and fragile across a Windows bind mount. Run
# `npm run dev` on the host for the frontend loop.
# Frontend stays as the built nginx image — run `npm run dev` on the host for the
# Vite loop if you need HMR.
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", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
[
"uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--reload",
]
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
- ./backend:/app
- ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
ats-engine:
command:
@ -36,30 +57,77 @@ services:
"8100",
"--reload",
]
ports:
- "${ATS_PORT:-8100}:8100"
volumes:
- ./app:/srv/app
frontend:
ports:
- "${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
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments

View File

@ -1,32 +1,23 @@
# HR-ATS-Portal — every service and every image, in one file.
# HR-ATS-Portal — production Compose stack (self-contained).
#
# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend
# docker compose up -d
# docker compose up -d --build
# docker compose ps
# docker compose logs -f backend-api
#
# ── Postgres ──────────────────────────────────────────────────────────────────────
# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined
# and buildable here but never starts with a plain `docker compose up`. The stack
# talks to the PostgreSQL server already running on the host, via
# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
# process, wrong inside a container).
# 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.
#
# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*'
# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet).
# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts):
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# To build/run the containerised database instead, see the comments on that service.
#
# ── Ports ─────────────────────────────────────────────────────────────────────────
# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or
# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process
# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1
# first — both listen, and requests reach whichever won.
#
# ── Overlay ───────────────────────────────────────────────────────────────────────
# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
# defines no services or images of its own; it only overrides the ones here:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# See DOCKER.md for env checklist and verification.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
x-backend-build: &backend-build
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
@ -36,26 +27,28 @@ x-backend-build: &backend-build
x-backend-env: &backend-env
PYTHONPATH: /app
# backend/.env is written for host processes; these are the values a container needs.
#
# DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in
# the shell (or a root .env) to point the whole stack at the container below instead
# — that is the only value that has to change, since services reach it over the
# compose network on 5432, not the published host port.
DB_HOST: ${DB_HOST:-host.docker.internal}
# 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}
REDIS_URL: redis://redis:6379/0
# Prefer root/.env EMAIL_URL (e.g. http://3.140.173.13:5000). Fall back to the
# host-gateway alias when the mail service runs on this machine's :5000.
# 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
# The one shared folder. Every process that decodes, scores or serves a CV reads and
# writes the same host directory, so a file written by the API is the same file the
# worker opens. Absolute paths stored in the DB match across services because the
# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
# falls back to basename-under-this-directory for rows written by a host process.
# 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.
x-attachments: &attachments
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
- attachments-data:/app/inbox/decoded_attachments
x-backend-service: &backend-service
build: *backend-build
@ -69,13 +62,10 @@ x-backend-service: &backend-service
depends_on:
redis:
condition: service_healthy
# required:false — the default stack uses the host's Postgres and never starts
# this one, and that must not be an error. When the profile IS active, startup
# waits for it to pass pg_isready.
postgres:
condition: service_healthy
required: false
restart: unless-stopped
logging: *default-logging
services:
# --- Redis (broker + result backend for taskiq) ----------------------------------
@ -83,8 +73,7 @@ services:
image: redis:7-alpine
container_name: hrms-redis
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_PORT:-6379}:6379"
# Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379.
volumes:
- redis-data:/data
healthcheck:
@ -93,24 +82,59 @@ services:
timeout: 5s
retries: 5
restart: unless-stopped
logging: *default-logging
# --- Postgres (always on in production) ------------------------------------------
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.
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.
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
logging: *default-logging
# --- portal API ------------------------------------------------------------------
backend-api:
<<: *backend-service
container_name: hrms-backend-api
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "${BACKEND_PORT:-8000}:8000"
command:
[
"uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--workers",
"${UVICORN_WORKERS:-2}",
"--proxy-headers",
"--forwarded-allow-ips=*",
]
# Not published in production — browsers reach the API via frontend nginx.
volumes: *attachments
healthcheck:
# python:slim has no curl and the API exposes no /health route, so this is a
# plain TCP check against the uvicorn socket.
test:
[
"CMD",
"python",
"-c",
"import socket;socket.create_connection(('127.0.0.1',8000),3).close()",
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)",
]
interval: 15s
timeout: 5s
@ -125,13 +149,10 @@ services:
image: hrms-ats-engine:local
container_name: hrms-ats-engine
env_file:
# OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
# overrides it when present, and the stack still comes up when it is not.
- ./backend/.env
- path: ./.env
required: false
ports:
- "${ATS_PORT:-8100}:8100"
# Not published in production.
healthcheck:
test:
[
@ -145,15 +166,14 @@ services:
retries: 5
start_period: 20s
restart: unless-stopped
logging: *default-logging
# --- React portal -----------------------------------------------------------------
# --- React portal (only published host port) -------------------------------------
frontend:
build:
context: ./frontend
args:
# Empty = same-origin; nginx proxies API paths to backend-api (see nginx.conf).
# A baked http://localhost:8000 / LAN IP sends the browser to a *different*
# listener than the SPA (Cursor steals 127.0.0.1:8000) and empties /jobs.
VITE_API_BASE: ${VITE_API_BASE:-}
image: hrms-frontend:local
container_name: hrms-frontend
@ -161,13 +181,14 @@ services:
backend-api:
condition: service_healthy
ports:
- "${FRONTEND_PORT:-5173}:80"
- "${FRONTEND_PORT:-80}:80"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
logging: *default-logging
# --- background processing (same image as backend-api, different command) ---------
taskiq-worker:
@ -255,45 +276,7 @@ services:
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
volumes: *attachments
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
# The profile is what keeps it out of `docker compose build` and `docker compose up`.
# Nothing about the default stack changes by its presence in this file.
#
# docker compose --profile postgres build postgres # build the image
# docker compose --profile postgres up -d postgres # run it, host port 5433
#
# Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
# x-backend-env) and recreate the services. The volume starts empty, so Alembic
# rebuilds the schema on first boot; it does not share the host server's data.
postgres:
profiles: ["postgres"]
build:
context: ./docker/postgres
image: hrms-postgres:local
container_name: hrms-postgres
environment:
# Interpolated from the shell or a root .env, NOT from backend/.env — compose
# variable substitution and container environment are different things.
# Defaults match backend/.env.example.
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-hrms}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
# 5433: the host's own Postgres server owns 5432. Only for host-side tools —
# containers reach this one on 5432 over the compose network.
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
volumes:
redis-data:
postgres-data:
attachments-data:

View File

@ -1,15 +1,8 @@
# syntax=docker/dockerfile:1
#
# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK.
#
# Every service in docker-compose.yml points at the Postgres already running on the
# host (DB_HOST=host.docker.internal). This image exists so the database can be
# containerised on demand — a clean machine, a throwaway test run, a second dev — and
# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
# a deliberate act:
#
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
# Postgres image used by the production Compose stack (DB_HOST=postgres).
# The volume starts empty on first boot; Alembic rebuilds the schema. It does
# not share the host server's data — migrate with pg_dump/pg_restore if needed.
#
# Context is ./docker/postgres.

View File

@ -5,11 +5,20 @@ server {
root /usr/share/nginx/html;
index index.html;
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
client_max_body_size 25m;
# Security headers on every response.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY 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
# 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. backend-api is the compose service name.
location ~ ^/(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|docs|openapi\.json|redoc)(/|$) {
# 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)(/|$) {
proxy_pass http://backend-api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
@ -17,11 +26,12 @@ server {
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;
}
# One SPA at `/`. `vite dev` and `vite preview` serve index.html for every
# unmatched path; vite.config.js notes that a static deploy needs the equivalent
# rewrite rule. This is it without it /auth/confirm-email (a router path, not a
# One SPA at `/`. Without this, /auth/confirm-email (a router path, not a
# file) 404s when a confirmation email link is opened cold.
location / {
try_files $uri $uri/ /index.html;
@ -31,11 +41,17 @@ server {
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
location = /index.html {
add_header Cache-Control "no-store";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
gzip on;