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
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
api:
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
env:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These env values are placeholders, not secrets, and that's deliberate. The test suite never actually connects to MONGODB_URI's value, it spins up its own in-memory replica set instead, these only exist so config/env.ts's Zod validation doesn't reject an empty process.env and exit before a single test runs. Confirmed this exact scenario locally by running the full suite with no .env file present and only these values set.

NODE_ENV: test
PORT: 4000
MONGODB_URI: mongodb://127.0.0.1:27017/worknest_ci
JWT_ACCESS_SECRET: ci_placeholder_secret_at_least_32_chars_long
ACCESS_TOKEN_TTL: 15m
CLIENT_ORIGIN: http://localhost:5173
BCRYPT_COST: 4
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node-version-file pointed at .nvmrc directly rather than a hardcoded version number, so CI and local dev are guaranteed to run the identical Node version instead of two version strings that can quietly drift apart.

cache: npm
cache-dependency-path: api/package-lock.json

- run: npm ci
- run: npm run lint

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lint before typecheck before test, cheapest and fastest check first so an obvious style failure doesn't wait behind an eight-second test run to report.

- run: npm run typecheck
- run: npm test

web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: npm
cache-dependency-path: web/package-lock.json

- run: npm ci
- run: npm run lint
- run: npm run build
9 changes: 9 additions & 0 deletions api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
dist
tests
.env
.env.*
!.env.example
*.log
coverage
.git
28 changes: 28 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
FROM node:24-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lockfiles copied before the rest of the source in both stages, deliberately, so the npm ci layer only invalidates when dependencies actually change, not on every code edit.

RUN npm ci

COPY . .
RUN npm run build

FROM node:24-alpine

ENV NODE_ENV=production

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NODE_ENV set to production before the second npm ci runs, not after, so the install itself happens in the same mode the app will actually run in.

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--omit=dev here versus a plain npm ci in the builder stage above. The builder needs devDependencies to run tsc; the runtime image never should, that's exactly what keeps pino-pretty and the rest of the dev toolchain out of the shipped image.


COPY --from=builder --chown=node:node /app/dist ./dist

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--chown=node:node set at copy time rather than a separate RUN chown afterward, since COPY always runs as root regardless of the USER instruction below it.


USER node

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switches to the node user, which official Node images already ship pre-created specifically for this, rather than creating a new user manually.


EXPOSE 4000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Health check hits /api/health with plain node instead of curl, since curl isn't installed on alpine by default and this avoids adding a package just for one HTTP call. Confirmed this actually matters, not just documents intent, by running the built image and watching it correctly fail closed when MongoDB was unreachable.

CMD node -e "require('http').get('http://localhost:4000/api/health', (res) => process.exit(res.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"

CMD ["node", "dist/server.js"]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exec form (JSON array), not a shell string, so node runs as PID 1 directly and actually receives SIGTERM for the graceful shutdown server.ts already implements, rather than a shell swallowing the signal.

18 changes: 9 additions & 9 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
services:
mongo:
image: mongo:7
command: ["--replSet", "rs0", "--bind_ip_all"]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--bind_ip_all is required here, not optional. Mongo's default bind is localhost-only inside its own container, which would make it unreachable from the api container entirely.

volumes:
- mongo_data:/data/db
healthcheck:
test: >
mongosh --quiet --eval "
try { rs.status().ok }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try/catch in the health check both detects and fixes the single biggest docker-compose plus Mongoose gotcha, a fresh replica-set-mode Mongo refuses every transactional operation until something calls rs.initiate() on it once. First health check run does that automatically.

catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }] }) }
"
interval: 5s
timeout: 5s
retries: 10
start_period: 5s

api:
build: ./api
env_file:
- ./api/.env
environment:
NODE_ENV: production

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NODE_ENV explicitly set to production here rather than left to inherit from the api container's own .env file, which has NODE_ENV=development for local dev. Confirmed this matters concretely, the container crashes on boot if this is left as development, since pino-pretty is a dev dependency that's deliberately absent from the production image.

PORT: 4000
MONGODB_URI: mongodb://mongo:27017/worknest?replicaSet=rs0
CLIENT_ORIGIN: http://localhost:5173
ports:
- "4000:4000"
depends_on:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depends_on with condition service_healthy specifically, not the bare form. The bare form only waits for the mongo container to start, which happens almost immediately, well before the replica set is actually usable.

mongo:
condition: service_healthy

web:
build: ./web
ports:
- "5173:80"
depends_on:
- api

volumes:
mongo_data:
4 changes: 4 additions & 0 deletions web/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.git
19 changes: 19 additions & 0 deletions web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM node:24-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM nginx:alpine

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second stage starts from nginx:alpine, not node, since the built output is static files and nginx is a purpose-built static file server rather than needing a JS runtime.


COPY --from=builder /app/dist /usr/share/nginx/html

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the compiled dist folder is pulled from the builder stage. No node_modules, no source, nothing beyond what a browser actually needs to download.

COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wget instead of curl here, opposite of the backend, since nginx:alpine ships BusyBox wget by default and this image has no reason to add curl just to duplicate it.

20 changes: 20 additions & 0 deletions web/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;

location /api/ {
resolver 127.0.0.11 valid=10s;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix for a real bug I hit while testing, not a stylistic choice. A bare proxy_pass http://api:4000 resolves that hostname once at nginx startup, and nginx refuses to start at all if it can't resolve yet, confirmed directly, the container crash-looped until this was in place. Using Docker's embedded DNS resolver plus a variable defers resolution to request time instead.

set $upstream_api http://api:4000;
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

location / {
try_files $uri /index.html;
}
}
Loading