Repository navigation
chore: add Docker images, docker-compose, and CI workflow #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: | ||
| 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" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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 |
| 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 ./ | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 \ | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"] | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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"] | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules | ||
| dist | ||
| *.log | ||
| .git |
| 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.