Skip to content

Baseline scaffold: NestJS API + Postgres + strict CORS + Docker#4

Merged
support371 merged 1 commit into
mainfrom
codex/scaffold-nestjs-backend-with-docker-setup
Feb 28, 2026
Merged

Baseline scaffold: NestJS API + Postgres + strict CORS + Docker#4
support371 merged 1 commit into
mainfrom
codex/scaffold-nestjs-backend-with-docker-setup

Conversation

@support371

@support371 support371 commented Feb 28, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Provide a minimal, production-oriented backend for the existing SPA so the app can be run locally with Postgres and a real API instead of purely in-browser state.
  • Enforce the frontend invariant that API base URL is read at runtime from NEXT_PUBLIC_API_BASE_URL (or APP_API_BASE_URL for static builds) with a localhost fallback to avoid hardcoded endpoints.
  • Implement secure defaults for the backend (Helmet) and replicate the strict allowlist CORS behavior used in Azure Functions to avoid permissive/wildcard CORS.

Description

  • Added a small NestJS app under apps/api exposing GET /api/hello returning { message: "Hello from NestJS (dev starter)." } and GET /api/health returning { ok: true } implemented in apps/api/src/app.controller.ts.
  • Implemented global strict CORS middleware in apps/api/src/cors.middleware.ts that parses CORS_ALLOWED_ORIGINS as a comma-separated allowlist, sets Access-Control-Allow-Origin only when the request origin is present and allowlisted, responds 204 for allowed OPTIONS preflight, and never uses wildcard CORS.
  • Applied Helmet globally in the Nest bootstrap (apps/api/src/main.ts) to add sane security headers on API responses.
  • Added containerization and tooling: apps/api/Dockerfile, root docker-compose.yml (services: postgres and api), root .env.example, and codebase.yaml describing the services, ports, env vars, and run commands.
  • Preserved SPA invariants by adding a small runtime config loader app/config.js and updating app/index.html to set window.API_BASE_URL from NEXT_PUBLIC_API_BASE_URL or APP_API_BASE_URL with a http://localhost:7072 fallback, and updated README.md with local dev, curl examples, and SPA run instructions.

Testing

  • Performed Node syntax/type checks: node --check app/config.js and node --check apps/api/src/main.ts, both completed successfully.
  • Attempted npm install inside apps/api but it failed in this environment due to registry access (403 Forbidden), so package installation/npm run start:dev was not executed here.
  • Attempted docker compose config to validate compose output but Docker CLI is not available in this environment, so container brings-up were not run here.
  • Verified that app/index.html and app/config.js changes are syntactically loadable and that the SPA now derives API_BASE_URL at runtime per the specified precedence (tested with static node --check).

Codex Task

Summary by Sourcery

Introduce a NestJS-based API scaffold with strict CORS and Dockerized Postgres to provide a real backend for the SPA and align the frontend with runtime-configured API base URLs.

New Features:

  • Add a minimal NestJS API exposing /api/hello and /api/health endpoints for backend scaffolding.
  • Introduce a runtime configuration layer for the SPA that derives window.API_BASE_URL from environment-driven values with a localhost fallback.

Enhancements:

  • Apply Helmet and a custom strict CORS middleware globally in the API to enforce secure, allowlist-based cross-origin access.
  • Document the new backend stack, CORS behavior, and local development workflow in the README, including example curl calls and SPA/API run instructions.
  • Describe the project services and their ports in a codebase manifest for easier orchestration and discoverability.

Build:

  • Add Dockerfile for the NestJS API and a docker-compose stack wiring the API to a local Postgres instance.

Deployment:

  • Provide environment templates and configuration to run the SPA, API, and Postgres locally via Docker and standard Node tooling.

Documentation:

  • Update README to explain the new repository layout, local Docker workflow, API endpoints, CORS preflight behavior, and SPA runtime configuration.

Chores:

  • Add TypeScript configuration and package manifest for the new NestJS API project.

Open with Devin

@sourcery-ai

sourcery-ai Bot commented Feb 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduce a minimal NestJS API with strict CORS, containerized Postgres, and runtime-configured API base URL for the SPA, plus docs and tooling to run the stack locally or via Docker.

Sequence diagram for SPA calling NestJS API with strict CORS

sequenceDiagram
  actor User
  participant Browser_SPA
  participant NestJS_API
  participant strictCors
  participant AppController

  User->>Browser_SPA: Open http://localhost:3000
  Browser_SPA->>Browser_SPA: Load config_js and index_html
  Browser_SPA->>Browser_SPA: Set window_API_BASE_URL from __APP_CONFIG__ or fallback

  User->>Browser_SPA: Trigger API call
  Browser_SPA->>NestJS_API: OPTIONS /api/hello with Origin http://localhost:3000
  NestJS_API->>strictCors: Invoke strictCors middleware
  strictCors-->>NestJS_API: Validate origin and method
  NestJS_API-->>Browser_SPA: 204 No Content with Access_Control_Allow_Origin

  Browser_SPA->>NestJS_API: GET /api/hello with Origin http://localhost:3000
  NestJS_API->>strictCors: Invoke strictCors middleware
  strictCors-->>NestJS_API: Set CORS headers
  NestJS_API->>AppController: Route to hello handler
  AppController-->>NestJS_API: { message: Hello from NestJS (dev starter). }
  NestJS_API-->>Browser_SPA: 200 OK JSON response
Loading

Class diagram for NestJS API modules and CORS middleware

classDiagram
  class AppModule {
  }

  class AppController {
    +hello() any
    +health() any
  }

  class StrictCorsMiddleware {
    +strictCors(req Request, res Response, next NextFunction) void
  }

  class MainBootstrap {
    +bootstrap() Promise~void~
  }

  AppModule --> AppController : registers
  MainBootstrap --> AppModule : bootstraps
  MainBootstrap --> StrictCorsMiddleware : uses

  class Request
  class Response
  class NextFunction

  StrictCorsMiddleware ..> Request : depends_on
  StrictCorsMiddleware ..> Response : depends_on
  StrictCorsMiddleware ..> NextFunction : depends_on
Loading

Flow diagram for strict CORS middleware decision logic

flowchart LR
  A["Incoming_request"] --> B{Method_is_OPTIONS?}
  B -->|Yes| C{Origin_present_and_allowlisted?}
  B -->|No| D{Origin_present_and_allowlisted?}

  C -->|Yes| E["Set CORS headers"] --> F["Respond 204 No Content"]
  C -->|No| G["Do not set CORS headers"] --> H["Call next()"]

  D -->|Yes| E
  D -->|No| G

  F --> I["End_request"]
  H --> I
Loading

Flow diagram for SPA runtime API base URL resolution

flowchart LR
  A["Load app/index.html"] --> B["Load app/config.js"]
  B --> C["Initialize window.__APP_CONFIG__"]
  C --> D{NEXT_PUBLIC_API_BASE_URL set?}
  D -->|Yes| E["Use NEXT_PUBLIC_API_BASE_URL"]
  D -->|No| F{APP_API_BASE_URL set?}
  F -->|Yes| G["Use APP_API_BASE_URL"]
  F -->|No| H["Use fallback http://localhost:7072"]

  E --> I["Normalize trailing slash and set window.API_BASE_URL"]
  G --> I
  H --> I
Loading

File-Level Changes

Change Details Files
Add a minimal NestJS backend exposing hello/health endpoints with security middleware and strict CORS.
  • Create Nest bootstrap that applies Helmet and custom strict CORS middleware, reading PORT from env with a 7072 default.
  • Implement AppModule and AppController under the /api path with GET /api/hello and GET /api/health endpoints returning static JSON payloads.
  • Define a strict CORS Express middleware that resolves an allowlist from CORS_ALLOWED_ORIGINS, conditionally sets CORS headers, handles OPTIONS preflight with 204 for allowed origins, and never uses wildcard origins.
apps/api/src/main.ts
apps/api/src/app.module.ts
apps/api/src/app.controller.ts
apps/api/src/cors.middleware.ts
Set up build, TypeScript config, and Dockerization for the NestJS API service.
  • Add package.json with NestJS, Helmet, and supporting dependencies plus scripts for dev, build, and production start.
  • Introduce tsconfig and tsconfig.build configs targeting ES2021, enabling decorators and strict compilation with dist output.
  • Create a multi-stage Dockerfile that builds the Nest app in a builder stage and runs it in a slim production node:20-alpine image listening on port 7072.
apps/api/package.json
apps/api/tsconfig.json
apps/api/tsconfig.build.json
apps/api/Dockerfile
Provide a docker-compose stack and codebase manifest for local API + Postgres + SPA workflows.
  • Add docker-compose.yml defining postgres with persisted volume and an api service wired to use it via DATABASE_URL and exposed ports.
  • Introduce codebase.yaml describing spa, api, and postgres services with run commands, env defaults, and ports for tooling/automation.
  • Add a shared .env.example to centralize default environment variables used by docker-compose and services.
docker-compose.yml
codebase.yaml
.env.example
Wire the SPA to consume the API via runtime-configured base URL while preserving static-hosting semantics.
  • Add app/config.js bootstrap to ensure window.APP_CONFIG exists with NEXT_PUBLIC_API_BASE_URL and APP_API_BASE_URL keys.
  • Load config.js in index.html and compute window.API_BASE_URL from NEXT_PUBLIC_API_BASE_URL, falling back to APP_API_BASE_URL, then to http://localhost:7072, trimming any trailing slash.
  • Document the precedence and usage of these configuration values in the README, including examples for static deployments and local dev.
app/config.js
app/index.html
README.md
Update repository documentation to describe the new backend scaffold, local dev flows, and CORS behavior.
  • Replace the old single-file SPA-focused README with a repo layout overview covering app/, apps/api, docker-compose, and codebase.yaml.
  • Add sections for Docker-based local development, running the API without Docker, and running the SPA via a static server.
  • Document strict CORS behavior and provide curl-based preflight examples, as well as expected responses for hello and health endpoints.
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@support371
support371 marked this pull request as ready for review February 28, 2026 03:05
@support371
support371 merged commit 63cf3a7 into main Feb 28, 2026
1 of 2 checks passed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In strictCors, getAllowedOrigins() is recomputed on every request; consider resolving and storing the allowed-origins set once at startup (or memoizing it) to avoid unnecessary per-request parsing of the environment variable.
  • The CORS middleware only sets Vary: Origin when the origin is allowed; to avoid incorrect caching of responses across different origins, it’s safer to set Vary: Origin whenever an Origin header is present, even if the origin is not allowlisted.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `strictCors`, `getAllowedOrigins()` is recomputed on every request; consider resolving and storing the allowed-origins set once at startup (or memoizing it) to avoid unnecessary per-request parsing of the environment variable.
- The CORS middleware only sets `Vary: Origin` when the origin is allowed; to avoid incorrect caching of responses across different origins, it’s safer to set `Vary: Origin` whenever an `Origin` header is present, even if the origin is not allowlisted.

## Individual Comments

### Comment 1
<location path="apps/api/src/cors.middleware.ts" line_range="3-13" />
<code_context>
+const CORS_ALLOWED_HEADERS =
+  'Origin,X-Requested-With,Content-Type,Accept,Authorization';
+
+const getAllowedOrigins = (): Set<string> =>
+  new Set(
+    (process.env.CORS_ALLOWED_ORIGINS ?? '')
+      .split(',')
</code_context>
<issue_to_address>
**suggestion (performance):** Avoid recomputing allowed origins on every request by caching the parsed environment variable.

`getAllowedOrigins()` currently splits and trims the env string and creates a new `Set` on every call, which adds avoidable overhead on high-traffic paths. Consider computing this set once at module load (or lazily on first call) and reusing it, unless you specifically need to support dynamic changes to `CORS_ALLOWED_ORIGINS`.

```suggestion
const CORS_ALLOWED_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOWED_HEADERS =
  'Origin,X-Requested-With,Content-Type,Accept,Authorization';

const ALLOWED_ORIGINS: Set<string> = new Set(
  (process.env.CORS_ALLOWED_ORIGINS ?? '')
    .split(',')
    .map((origin) => origin.trim())
    .filter(Boolean),
);

const getAllowedOrigins = (): Set<string> => ALLOWED_ORIGINS;
```
</issue_to_address>

### Comment 2
<location path="apps/api/src/cors.middleware.ts" line_range="28-29" />
<code_context>
+    res.setHeader('Access-Control-Allow-Headers', CORS_ALLOWED_HEADERS);
+  }
+
+  if (req.method === 'OPTIONS' && isAllowed) {
+    res.status(204).send();
+    return;
+  }
</code_context>
<issue_to_address>
**issue:** Clarify behavior for disallowed preflight (OPTIONS) requests to avoid ambiguous responses.

Right now, preflight requests from disallowed origins just fall through to `next()`, likely ending as a 404 or other generic response that the browser interprets as a CORS failure without a clear API signal. Consider explicitly handling `OPTIONS` when `!isAllowed` (e.g., 403 or 204 without CORS headers) so behavior is consistent and independent of downstream middleware or routes.
</issue_to_address>

### Comment 3
<location path="apps/api/Dockerfile" line_range="5-14" />
<code_context>
+WORKDIR /usr/src/app
+
+COPY package*.json ./
+RUN npm install
+
+COPY tsconfig*.json ./
+COPY src ./src
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /usr/src/app
+ENV NODE_ENV=production
+
+COPY package*.json ./
+RUN npm install --omit=dev
+COPY --from=builder /usr/src/app/dist ./dist
+
</code_context>
<issue_to_address>
**suggestion (performance):** Consider using a lockfile-aware install (`npm ci`) and sharing the dependency layer between build and runtime images.

Dependencies are installed twice with `npm install` (builder and runner), which slows builds and ignores any lockfile. If you commit/copy a lockfile (e.g. `package-lock.json`), you can switch to `npm ci` in both stages and either copy `node_modules` from the builder to the runner or share the install layer, improving build speed and reproducibility.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +3 to +13
const CORS_ALLOWED_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOWED_HEADERS =
'Origin,X-Requested-With,Content-Type,Accept,Authorization';

const getAllowedOrigins = (): Set<string> =>
new Set(
(process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (performance): Avoid recomputing allowed origins on every request by caching the parsed environment variable.

getAllowedOrigins() currently splits and trims the env string and creates a new Set on every call, which adds avoidable overhead on high-traffic paths. Consider computing this set once at module load (or lazily on first call) and reusing it, unless you specifically need to support dynamic changes to CORS_ALLOWED_ORIGINS.

Suggested change
const CORS_ALLOWED_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOWED_HEADERS =
'Origin,X-Requested-With,Content-Type,Accept,Authorization';
const getAllowedOrigins = (): Set<string> =>
new Set(
(process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
);
const CORS_ALLOWED_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOWED_HEADERS =
'Origin,X-Requested-With,Content-Type,Accept,Authorization';
const ALLOWED_ORIGINS: Set<string> = new Set(
(process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
);
const getAllowedOrigins = (): Set<string> => ALLOWED_ORIGINS;

Comment on lines +28 to +29
if (req.method === 'OPTIONS' && isAllowed) {
res.status(204).send();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue: Clarify behavior for disallowed preflight (OPTIONS) requests to avoid ambiguous responses.

Right now, preflight requests from disallowed origins just fall through to next(), likely ending as a 404 or other generic response that the browser interprets as a CORS failure without a clear API signal. Consider explicitly handling OPTIONS when !isAllowed (e.g., 403 or 204 without CORS headers) so behavior is consistent and independent of downstream middleware or routes.

Comment thread apps/api/Dockerfile
Comment on lines +5 to +14
RUN npm install

COPY tsconfig*.json ./
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /usr/src/app
ENV NODE_ENV=production

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (performance): Consider using a lockfile-aware install (npm ci) and sharing the dependency layer between build and runtime images.

Dependencies are installed twice with npm install (builder and runner), which slows builds and ignores any lockfile. If you commit/copy a lockfile (e.g. package-lock.json), you can switch to npm ci in both stages and either copy node_modules from the builder to the runner or share the install layer, improving build speed and reproducibility.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread docker-compose.yml
depends_on:
- postgres
ports:
- "7072:7072"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Docker port mapping hardcoded while container PORT is configurable, causing unreachable API

The docker-compose.yml hardcodes the port mapping as "7072:7072" (host:container), but the container's internal PORT environment variable is configurable via ${PORT:-7072}. If a user sets PORT=8080 in their .env file, the NestJS app inside the container will listen on port 8080, but the port mapping still forwards host port 7072 to container port 7072 — where nothing is listening.

Root Cause and Impact

The mismatch is between line 18 (PORT: ${PORT:-7072}) and line 24 ("7072:7072"). The main.ts at apps/api/src/main.ts:11 uses Number(process.env.PORT ?? 7072) to determine the listen port, so the app will bind to whatever PORT is set to. But the Docker port mapping doesn't reference the same variable for the container-side port.

The fix should use the PORT variable in the port mapping as well, e.g. "${PORT:-7072}:${PORT:-7072}", so the host-to-container mapping stays in sync with the actual listen port.

Impact: Any user who customizes the PORT variable (a reasonable thing to do) will find the API completely unreachable from the host, with no obvious error message — the container starts fine but traffic never reaches the app.

Suggested change
- "7072:7072"
- "${PORT:-7072}:${PORT:-7072}"
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +28 to +33
if (req.method === 'OPTIONS' && isAllowed) {
res.status(204).send();
return;
}

next();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Disallowed-origin OPTIONS preflight requests are forwarded to route handlers instead of being rejected

When an OPTIONS preflight request arrives from a disallowed (or missing) origin, the CORS middleware calls next(), forwarding the request to the NestJS route handler. The handler processes it as a normal GET-like request and returns a 200 with a response body, instead of the middleware terminating it early.

Detailed Explanation

At apps/api/src/cors.middleware.ts:28-31, the early 204 return only fires when req.method === 'OPTIONS' && isAllowed. When isAllowed is false, execution falls through to next() on line 33. This means:

  1. A preflight from http://evil.com hits the actual controller (e.g., AppController.hello()), doing unnecessary work.
  2. The response is 200 with a JSON body rather than a clear rejection (e.g., 403 or 204 with no CORS headers), which is inconsistent with the "strict CORS" intent described in the PR.
  3. While browsers will still block the cross-origin request (no Access-Control-Allow-Origin header is set), non-browser clients see a successful response, which is misleading.

The middleware should terminate disallowed OPTIONS requests early, e.g., with res.status(403).end().

Suggested change
if (req.method === 'OPTIONS' && isAllowed) {
res.status(204).send();
return;
}
next();
if (req.method === 'OPTIONS') {
if (isAllowed) {
res.status(204).send();
} else {
res.status(403).end();
}
return;
}
next();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant