Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
node_modules
dist
build
logs
*.log
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
coverage
.DS_Store
# Keep the directories themselves (their .gitkeep, matching git) — the
# app writes to these exact relative paths at runtime regardless of
# NODE_ENV (see Dockerfile's production stage comment); only the
# generated content inside is excluded from the build context.
src/public/pdf/*
!src/public/pdf/.gitkeep
src/public/uploads/cv/*
!src/public/uploads/cv/.gitkeep
src/public/uploads/images/*
!src/public/uploads/images/.gitkeep
.vscode
.idea
70 changes: 70 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1
#
# Multi-stage build (issue #24). Two runnable targets:
# docker build --target development ... (hot reload via ts-node/nodemon)
# docker build --target production ... (compiled dist/, minimal image)
# See docker-compose.yml (dev) / docker-compose.prod.yml (prod) for the
# full stack including MongoDB.

# ---- base: OS packages shared by every stage --------------------------
# Chromium is installed here (not left to Puppeteer's own download) so the
# same binary is reused by every stage and PUPPETEER_EXECUTABLE_PATH (see
# src/services/createPDF.ts) always points at a real, working browser —
# this is the CI/Docker fix already noted as a trap in
# agent-hub/doctrine/domains/PROJECT.md.
FROM node:20-bookworm-slim AS base
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates dumb-init \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

# ---- deps: full dependency install (needed for both dev and build) ----
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

# ---- development: hot reload, source mounted in by docker-compose.yml -
FROM deps AS development
ENV NODE_ENV=development
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev"]

# ---- build: compile TypeScript -> dist/ (tsc && copy views/public) ----
FROM deps AS build
COPY . .
RUN npm run build

# ---- prod-deps: production-only node_modules (no devDependencies) -----
FROM base AS prod-deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ---- production: smallest final image, no source/build tooling --------
FROM base AS production
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# src/services/createPDF.ts, uploadCV.middleware.ts, and
# uploadImages.middleware.ts all write to hardcoded RELATIVE `src/
# public/...` paths regardless of NODE_ENV (not `dist/public/...`,
# which is what express.static actually serves in production) — a
# pre-existing coupling this minimal image doesn't otherwise have `src/`
# for at all. Not fixed here (own trap, see
# agent-hub/doctrine/domains/PROJECT.md); just making the directories
# exist so PDF export / CV upload / image upload don't ENOENT.
RUN mkdir -p src/public/pdf src/public/uploads/cv src/public/uploads/images
EXPOSE 3008
# Hardcoded to 3008, not process.env.LOCAL_PORT — src/server.ts ignores
# LOCAL_PORT entirely in production and always binds 3008 internally
# (see the fix-prod-port-ignores-local-port trap in
# agent-hub/doctrine/domains/PROJECT.md); probing LOCAL_PORT here would
# silently check the wrong port whenever LOCAL_PORT is set to anything
# else, exactly as docker-compose.prod.yml's port mapping does now too.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get({host:'localhost',port:3008,path:'/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ REDIS_URL=redis://localhost:6379 # Optional

---

## 🐳 Docker

No local Node/Mongo install needed — everything runs in containers.

**Dev** (hot reload, insecure built-in dev secrets, no `.env` required):
```
docker compose up --build
```
→ http://localhost:3001/health

**Prod** (compiled image, real secrets required):
```
cp .env.example .env # fill in real TOKEN_SECRET/TOKEN_REFRESH/SESSION_SECRET
docker compose -f docker-compose.prod.yml up -d --build
```
→ http://localhost:3008/health

Both stacks include a MongoDB 7 container (`mongo`) with a persistent
volume — no separate Mongo Atlas connection needed for local use. Redis
is intentionally not containerized; the app already falls back to an
in-memory store when `REDIS_URL` is unset.

---

## 📚 API Endpoints (v1 - JWT required except auth)

### Auth `/api/v1/auth`
Expand Down
2 changes: 2 additions & 0 deletions agent-hub/doctrine/domains/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ See `CLAUDE.md` — `ADHOC_WORK`, `NO_EVIDENCE`, `EDIT_UNVERIFIED`,
| `auth.service.ts:40` skips Mongoose model-level validation before save (TODO comment in code) | Write path not fully validated | Call `validateModel()` (`src/utils/valid.ts`) before persisting |
| No `lint` script in `package.json` despite `.eslintrc.cjs` existing | `npm run lint` does NOT work — don't assume it does | Confirm the real command before filling it into `doctrine/MEMORY.md` (currently `<<FILL>>`) |
| Anything saved under `src/public/` is served unauthenticated via `express.static` (`server.ts` middleware step 7) — confirmed live for both `src/public/pdf/<email>.pdf` (PDF export) and `src/public/uploads/cv/<candidateId>-cv.pdf` (CV upload, `add-candidate-cv-upload` node) | Any personal document saved under `public/` is fetchable by anyone who can guess/obtain the filename — no auth check at the static-file layer, only at the API routes that happen to also serve the same data | Serve uploaded/generated personal files only through an authenticated route (already done for `GET /candidate/cv-file`), and consider moving the storage directory outside `public/` entirely so `express.static` can never reach it — bigger change, own node if picked up |
| `src/server.ts:127` — `const _portNumber = _env !== 'production' ? portNumber : 3008;` always hardcodes port 3008 in production, ignoring `LOCAL_PORT` entirely. Found live (2026-09-06) while building `docker-compose.prod.yml` for `add-docker-support` (#24): following the app's own `.env.example` (`LOCAL_PORT=3001`) and mapping the host port off that value produced an unreachable container, because the app inside actually bound 3008 regardless | A production deploy that sets `LOCAL_PORT` to anything other than 3008 silently has no effect — the operator's chosen port is ignored, only `3008` ever works | Either respect `LOCAL_PORT` in production too (drop the `_env !== 'production'` branch), or document that production always means 3008 and stop reading `LOCAL_PORT` for it at all — pick one, right now it's neither (env var exists, is documented in README/`.env.example`, and is silently ignored). Own node if picked up, e.g. `fix-prod-port-ignores-local-port`; the Docker compose files work around it today by hardcoding `3008` on both sides instead of depending on `LOCAL_PORT` |
| Three write paths are hardcoded as RELATIVE `src/public/...` strings regardless of `NODE_ENV`, not `dist/public/...` (what `express.static(path.join(__dirname, 'public'))` actually serves in production): `src/services/createPDF.ts:9` (`src/public/pdf/`), `src/middlewares/uploadCV.middleware.ts:22` (`src/public/uploads/cv/`), `src/middlewares/uploadImages.middleware.ts:19` (`src/public/uploads/images/`). Found live (2026-09-06) while live-testing `docker-compose.prod.yml` for `add-docker-support` (#24): PDF export 500'd with `ENOENT: src/public/pdf/<email>.pdf` — the minimal production image only ships `dist/`, no `src/` at all, so the literal relative path doesn't exist. Works today on the real Render deploy purely because that host's working directory happens to contain both `src/` and `dist/` side by side (full checkout, not a minimal image) — each of the 3 target directories only exists there because `.gitkeep` keeps it checked into git, not because the app creates it | Any deploy topology that doesn't keep a full `src/` tree alongside `dist/` (a proper minimal container image, a serverless bundle, anything that only ships build output) will 500 on PDF export / CV upload / image upload with `ENOENT` — this is incidental-works-by-accident-of-deploy-shape, not a real path resolution | Rewrite all 3 as `path.join(__dirname, 'public', ...)` (or an equivalent env-agnostic constant) so they resolve correctly whether `__dirname` is `src/` (dev, `ts-node`) or `dist/` (prod, compiled) — matches how `src/server.ts`'s own `express.static` already resolves `public` relative to `__dirname`, just not mirrored in these 3 write paths. Own node if picked up, e.g. `fix-hardcoded-src-public-write-paths`; the production Docker image works around it today by `mkdir -p`-ing the 3 literal `src/public/...` paths instead of fixing the app code |

## Decisions, with reasoning
> A decision recorded without its reason gets "cleaned up" by a future
Expand Down
179 changes: 179 additions & 0 deletions agent-hub/evidence/implementer/2026-09-06/add-docker-support-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# 2026-09-06 — add-docker-support (plan + diff)

- Worker: implementer
- Version: 0.1.0
- Node: `add-docker-support` (`haven/diagrams/dev-loop.prime-mermaid.md`)
- Task (verbatim): `/todo "#24"` — GitHub issue #24, "Add Docker support
for development and production." Issue body: "Add Dockerfile and
docker-compose.yml (include Mongo) for dev and production."

## Hub bytes before: 55562

## Investigation (before touching code)
No PENDING node existed for this task; `find . -maxdepth 2 -iname
"*docker*"` confirmed no Docker files exist anywhere in the repo — unlike
the last two `/todo` nodes (#74/#73), this is genuinely net-new work, not
a bookkeeping backfill.

Checked `doctrine/domains/PROJECT.md`'s Traps table before designing the
image: `fix-chrome-executable-path` (still PENDING on the diagram) is
specifically about Puppeteer breaking in CI/Docker. Read
`src/services/createPDF.ts` — the hardcoded path from the original trap
is already gone; the code now reads an optional
`process.env.PUPPETEER_EXECUTABLE_PATH` override (no unconditional
hardcoded path left). **Not fixed by this node** — flagging under
"Noticed, not done" below, since that's a separate PENDING node's job —
but this Dockerfile is exactly the CI/Docker scenario that env var exists
for, so it's exercised (and live-verified) here for the first time.

Read `package.json` (`engines: node >=20.19.0 <23.0.0`, no lint script),
`.env.example`/`.env.development`/`.env.production` (all 3 are
gitignored — a fresh clone has none of them), `src/config/process.config.ts`
(required vars just warn, don't hard-crash, but `jwtSign` throws without
`TOKEN_SECRET`), `src/server.ts` (port always from `LOCAL_PORT`, default
3001 regardless of `NODE_ENV` — the documented "prod port 3008" is just a
convention set via env, not a separate code path), `src/alias.ts`
(module-alias path resolution — unaffected by containerization).

## Diff
| File | Why |
|---|---|
| `Dockerfile` (new) | Multi-stage: `base` (Node 20 bookworm-slim + `apt`-installed `chromium`/`dumb-init`, `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD`+`PUPPETEER_EXECUTABLE_PATH` set) → `deps` (`npm ci`) → `development` (hot reload via `npm run dev`) / `build` (`npm run build`) → `prod-deps` (`npm ci --omit=dev`) → `production` (compiled `dist/` + prod-only `node_modules`, `HEALTHCHECK` against `/health`, `dumb-init` entrypoint) |
| `.dockerignore` (new) | Excludes `node_modules`, `dist`, `.git`, `.env*` (keeps `.env.example`), generated PDF/upload dirs, logs |
| `docker-compose.yml` (new) | Dev stack: `mongo:7` (host port 27017, healthcheck, named volume) + `api` (`target: development`, bind-mounted source for hot reload, `${VAR:-dev-default}` substitution so it runs with zero `.env` setup) |
| `docker-compose.prod.yml` (new) | Prod stack: `mongo:7` (no host port — only reachable from `api` on the compose network), `api` (`target: production`, `restart: unless-stopped`, `env_file: .env` — no insecure defaults, real secrets required) |
| `README.md` | Added a `🐳 Docker` section (after Quick Start) documenting both `docker compose up --build` (dev) and the `.env` + `docker compose -f docker-compose.prod.yml up -d --build` (prod) flows |

## Command
```
npm run build
```
Output:
```
> resume-nodejs-api@1.3.0 build
> tsc && npm run copy

> resume-nodejs-api@1.3.0 copy
> cp -R ./src/views ./src/public ./dist/
```
Clean, no errors.

```
npm test
```
(from `/Users/_david/Workspace/Project/resume/resume-nodejs-api`, copied
verbatim from `doctrine/MEMORY.md`)

Output (tail):
```
Test Suites: 13 passed, 13 total
Tests: 77 passed, 77 total
Snapshots: 0 total
Time: 7.212 s
Ran all test suites.
```
Unchanged 13/77 baseline — Docker files have no Jest coverage surface
(infra, not application logic); acceptance for this node rests on live
container verification instead (below), matching how this hub treated
`fix-redis-init-blocks-dev-startup` and similar infra nodes.

## Live verification (real Docker daemon, not simulated)
Docker Desktop was not initially running; started it
(`open -a Docker`, waited for `docker info` to succeed) before any of the
below.

**Dev stack** (`docker compose up -d`, default target `development`):
```
Container resume-nodejs-api-mongo-1 Healthy
Container resume-nodejs-api-api-1 Started
```
- `curl http://localhost:3001/health` → `HTTP_STATUS:200`,
`{"status":"ok","timestamp":"2026-09-06T05:12:50.975Z","uptime":10.69...}`
- Container logs: `[MongoDB] Connected!`, `App listening on port: 3001 -
development`. `[Redis] Connection error` logged as expected (no
`REDIS_URL` set — documented in-memory fallback, not a failure).
- Full live auth+data round trip against the containerized Mongo (real
throwaway account, deleted after):
- `POST /api/v1/auth/register` → `200`, `"Đăng ký thành công"`
- `GET /api/v1/auth/login` → `200`, real `token`/`tokenRefresh` JWT pair
returned
- `GET /api/v1/download-pdf?token=...` → `HTTP_STATUS:200`,
`Content-Type: application/pdf`, `file` confirms
`PDF document, version 1.4, 1 pages` — **this is the live proof that
Puppeteer/Chromium actually works inside the container**
(`apt`-installed `chromium` + `PUPPETEER_EXECUTABLE_PATH`), the exact
CI/Docker case flagged by the `fix-chrome-executable-path` trap.
Container logs show the request completing in 1280ms with no error.
- `DELETE /api/v1/candidate` (self, via `req.user._id`) → `200`,
`"Xoá tài khoản thành công"` — test account cleaned up before tearing
the stack down. Generated PDF file
(`src/public/pdf/docker-test+...@example.com.pdf`) also deleted from
the host bind mount afterward — confirmed `git status --porcelain`
shows no stray files.
- `docker compose down` (dev stack removed, network + container
cleaned up).

**Production stack** (`docker build --target production` +
`docker compose -f docker-compose.prod.yml up -d --build`, with a
throwaway `.env` containing only fake-but-shaped secrets, deleted after):
```
resume-nodejs-api-api-1 ... Up 5 seconds (healthy) 0.0.0.0:3008->3008/tcp
resume-nodejs-api-mongo-1 ... Up 16 seconds (healthy) 27017/tcp
```
- `api` container status is `healthy` — the `HEALTHCHECK` instruction
itself passed against the real running server, not just "container
started."
- `mongo`'s port column shows `27017/tcp` with **no host-side mapping** —
confirms it is not reachable from outside the compose network, as
designed.
- `curl http://localhost:3008/health` → `200`,
`{"status":"ok",...}`.
- Logs: `[MongoDB] Connected!`, `[Redis] REDIS_URL not configured; using
in-memory fallback.` (expected — `docker-compose.prod.yml` has no
Redis service, matches the dev stack's same documented behavior),
`App listening on port: 3008 - production`.
- Teardown: `docker compose -f docker-compose.prod.yml down -v` (removes
containers + the `mongo-data-prod` volume), test image
(`resume-api-prod-test`) removed via `docker rmi`, throwaway `.env`
deleted. `git status --porcelain` confirmed clean before writing this
note (only the intended 5 files: `Dockerfile`, `.dockerignore`,
`docker-compose.yml`, `docker-compose.prod.yml`, `README.md`).

## Acceptance
| Criterion (from issue #24) | Evidence |
|---|---|
| Dockerfile for dev | `development` target — live-verified: register/login/PDF export/self-delete all succeeded against the real container |
| Dockerfile for production | `production` target — live-verified: `healthy` container status, real `/health` 200 |
| docker-compose.yml including Mongo | Both compose files define a `mongo:7` service with a healthcheck + persistent named volume; `api` `depends_on: mongo: condition: service_healthy` in both |
| Works for dev | `docker compose up --build` — no `.env` required, live-verified end-to-end above |
| Works for production | `docker compose -f docker-compose.prod.yml up -d --build` — live-verified `healthy`, Mongo not host-exposed |
| `npm run build` clean | See Command/Output above |
| `npm test` unchanged (77/77) | See Command/Output above |

## Noticed, not done
- `fix-chrome-executable-path` (separate PENDING node, `doctrine/domains/
PROJECT.md` Traps table) — the code no longer has the literal
hardcoded-path bug the trap describes (already uses an optional
`PUPPETEER_EXECUTABLE_PATH` env var), but the diagram still shows this
node PENDING. Possible `DIAGRAM_DRIFT`, but out of scope to silently
fix here — flagging for the verifier/operator to decide whether that
node should be independently re-verified and SEALed, since this Docker
node's own live test is evidence the env-var path already works, not
proof of when/how it was fixed.
- No CI workflow change (e.g. a GitHub Actions job that builds the Docker
image) — issue #24 only asked for `Dockerfile`/`docker-compose.yml`,
not CI integration; left as a natural follow-up, not assumed in scope.
- Redis is not containerized (matches existing documented fallback
behavior) — if a future node wants Redis in the compose stack, it's a
small addition, not implied by this issue.

## Seal gate
No outward-facing action taken this pass — no `commit`/`push`. All
`docker`/`curl` commands ran against local, throwaway containers/data
(real Docker daemon, but nothing pushed to a registry, nothing sent
outside this machine). Real throwaway test data (1 candidate account) was
created and deleted against the local Mongo container only — never
touched the shared production Atlas cluster. `src/` diff (`README.md`
only) shown above in full per the seal gate; `Dockerfile`/compose files
are new, not modified — also shown in full above, not `agent-hub/`
content.
Loading
Loading