commited
parent
c536def585
commit
a8b213be39
|
|
@ -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/
|
||||
|
|
|
|||
37
.env.example
37
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -57,4 +57,3 @@ frontend/dist/
|
|||
**.pdf
|
||||
**_**_**.py
|
||||
Utopia-ai-hr-ats-portal 1.pem
|
||||
db_setup.py
|
||||
|
|
@ -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
|
||||
```
|
||||
78
README.md
78
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"]}}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue