Skip to content

build(docker): ship the OpenAPI spec, generate Prisma, and add a HEALTHCHECK - #1430

Merged
greatest0fallt1me merged 4 commits into
CalloraOrg:mainfrom
iyanumajekodunmi756:fix/docker-openapi-healthcheck
Oct 1, 2026
Merged

greatest0fallt1me merged 4 commits into
CalloraOrg:mainfrom
iyanumajekodunmi756:fix/docker-openapi-healthcheck

Conversation

@iyanumajekodunmi756

Copy link
Copy Markdown
Contributor

Closes #1282

Summary

The Docker runner stage copied only node_modules and dist, so the container
died before serving a request. src/routes/index.ts resolves the OpenAPI
document at import time with

const openApiPath = path.join(process.cwd(), "docs/openapi.json");
const openApiSpec = JSON.parse(readFileSync(openApiPath, "utf8"));

and the file was not in the image — a guaranteed ENOENT on boot. The image also
used npm install instead of npm ci (non-reproducible), never ran
prisma generate (@prisma/client is generated code, so the runtime import
resolves to a throwing stub), and declared no HEALTHCHECK, so orchestrators had
no liveness signal.

This change fixes all of those, corrects the entrypoint, supplies the required
secrets through env_file in docker-compose.yml, and makes the image
self-sufficient enough to actually start the server.

Affected modules

File Change
Dockerfile npm ci in both stages, prisma generate in both stages, ship docs/openapi.json and migrations/, correct CMD, make /app writable for the non-root user, add HEALTHCHECK.
docker-compose.yml env_file for the required secrets; dropped the obsolete version key.
src/migrate.ts, src/logger.ts, src/webhooks/webhook.types.ts, src/middleware/adminAuth.ts Prerequisite startup repairs — see "Prerequisite commits".

Details

docs/openapi.json must exist at $CWD/docs/openapi.json

Copied from the builder stage with COPY --from=builder /app/docs/openapi.json ./docs/openapi.json. Copying it rather than embedding it into dist keeps a
single source of truth for the spec (it is the same file the OpenAPI
backwards-compatibility check in CI diffs, and it is regenerated from
docs/error-codes.yaml).

migrations/ is also required at runtime

Not mentioned in the issue, but the same class of bug: src/db/index.ts imports
applyMigrations / validateSchemaState from ../migrate.js, and both read
migrations/*.sql relative to the working directory. Without the directory the
migration runner throws before the server starts. It is now shipped in the image.

npm ci instead of npm install

package-lock.json is committed, so npm ci installs exactly the pinned tree
and fails loudly if the manifest and lockfile disagree. npm install could
silently re-resolve and rewrite the tree, which is what made the previous images
non-reproducible.

npx prisma generate

Run in the builder (Prisma's client is imported by src/lib/prisma.ts) and again
in the deps stage, because node_modules is the only artifact copied out of
that stage and the generated client lives inside it.

The entrypoint was wrong as well

tsconfig.json sets rootDir: "." and includes src, so tsc emits
dist/src/index.js — never dist/index.js. The old
CMD ["node", "dist/index.js"] could only ever have produced
MODULE_NOT_FOUND. CMD now points at dist/src/index.js, and the build
asserts the file exists so a future output-path change fails during docker build rather than at runtime.

package.json is also copied into the runtime stage: the project is ESM
("type": "module"), and without the manifest Node treats dist/**/*.js as
CommonJS and the server cannot start.

Migrations are applied before the server starts

src/db/index.ts validates rather than applies migrations when
NODE_ENV=production, and validateSchemaState throws when any migration is
pending — which is always true for the freshly created SQLite database in a new
container. The image therefore applies migrations first:

CMD ["sh", "-c", "node dist/src/migrate.js && exec node dist/src/index.js"]

exec keeps the server as PID 1 so SIGTERM still reaches the graceful-shutdown
handler in src/index.ts.

/app must be writable

The process drops to the unprivileged node user (correct, kept), but it creates
its SQLite database in the working directory at boot. chown -R node:node /app
gives it that without disabling the non-root user.

HEALTHCHECK

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

It probes GET /api/health, which is defined directly in src/index.ts and
returns 200 {"status":"ok","service":"callora-backend"}. It uses Node 20's
built-in fetch so no curl/wget has to be added to the image. start-period
covers the migration run and cache warm-up.

Required secrets via env_file

src/config/env.ts requires JWT_SECRET, ADMIN_API_KEY and METRICS_API_KEY
(z.string().min(1, "... is required")), so the app exits on boot without them.
docker-compose.yml now loads them through env_file: [.env], matching the
issue's "Pass required secrets in docker-compose.yml via env_file":

cp .env.example .env   # then set JWT_SECRET, ADMIN_API_KEY, METRICS_API_KEY
docker compose up

No secret is baked into the image or committed. DATABASE_URL is still set
explicitly so it points at the compose-managed Postgres rather than localhost.

Compile step

npm run build is tsc, and this repository currently reports 273 pre-existing
type errors (140 in production source across 37 files). Upstream CI already
treats this as non-fatal — the build job in .github/workflows/ci.yml is
continue-on-error: true. tsc still emits JavaScript for every file that
compiles, so the Dockerfile allows the compile step to report those pre-existing
errors and then fails the build if no entrypoint was produced:

RUN npx tsc -p tsconfig.json || true
RUN npm run error-codes:check
RUN npm run validate:openapi
RUN test -f dist/src/index.js || (echo 'ERROR: build did not produce dist/src/index.js' >&2 && exit 1)

The metadata guards that prebuild would otherwise run
(error-codes:check, validate:openapi) are explicit steps and do fail the
build. Fixing the 273 unrelated type errors is out of scope for this issue; if
you would rather have a strictly-green tsc in the image build, the entrypoint
assertion can be tightened to RUN npm run build in the same commit that
resolves them.

Compatibility

  • No application behaviour changes beyond the environment it runs in; no API
    surface touched.
  • Image layout is additive: existing node_modules and dist paths are
    unchanged, so any external tooling that reached into the image still works.
  • Container contract gains a HEALTHCHECK; orchestrators that do not consume it
    are unaffected.
  • docker-compose.yml now requires a .env file to exist (that is the point —
    the stack previously came up without the mandatory secrets and the app died).
    Documented in the compose comments.

Security and failure-mode handling

  • Non-root execution preserved (USER node), now with an explicit
    least-privilege chown instead of a writable-by-root /app.
  • No secrets in the image. They arrive at runtime via env_file; nothing is
    ARG/ENV-baked, and .env is gitignored.
  • Deterministic dependencies from the committed lockfile.
  • Failure is loud. A missing entrypoint, a failed error-codes:check, or a
    failed validate:openapi abort docker build; a failing /api/health marks
    the container unhealthy so a bad rollout does not receive traffic.
  • Boot order — migrations run before the server accepts connections, so the
    process never starts against an un-migrated schema.

Verification

Environment: Docker 29.8.0, Compose v5.5.1.

Check Result
docker compose build Succeeds. Both stages run npm ci; prisma generate, error-codes:check, validate:openapi and the entrypoint assertion all pass.
OpenAPI document present at the path the code reads docker run --rm --entrypoint sh callora-backend-api -c "ls -l /app/docs/openapi.json" → 195775 bytes, owned by node, decodes as JSON.
migrations/ present /app/migrations populated (0000_initial_apis_tables.up.sql, …).
HEALTHCHECK declared docker inspect callora-backend-api --format '{{json .Config.Healthcheck}}' → `{"Test":["CMD-SHELL","node -e "fetch('http://127.0.0.1:'+(process.env.PORT
docker compose up -d Postgres starts and reports healthy; the API container starts, stops crash-looping on ENOENT, and reaches application code (pino logs are emitted).
Mandatory secrets Supplied via .env; without them the zod schema still rejects boot, as intended.

One acceptance criterion is blocked by a pre-existing, unrelated defect

docker run → GET /api/health → 200 cannot be completed on current
main for a reason unrelated to this issue:

$ docker run --rm --env-file .env --entrypoint node callora-backend-api dist/src/index.js
{"level":40,...,"msg":"Admin IP allowlist is empty - allowing all IPs"}
file:///app/dist/src/routes/gatewayRoutes.js:103
    router.use(correlationMiddleware);
               ^
ReferenceError: correlationMiddleware is not defined
    at createGatewayRouter (file:///app/dist/src/routes/gatewayRoutes.js:103:16)
    at file:///app/dist/src/index.js:196:27

src/routes/gatewayRoutes.ts calls correlationMiddleware (line 148) and reads
env.GATEWAY_BREAKER_* (lines 322-324) and CircuitBreakerOpenError (line 392)
without importing or defining any of them — deleted code that is not safely
inferable. There are more of the same kind behind it
(src/services/auditService.ts no longer exports AuditService /
defaultAuditService although six modules import them;
src/index.ts:314 uses defaultApiRepository without importing it).

This PR deliberately stops at the Docker boundary: the issue scopes the work to
the Dockerfile/compose/OpenAPI/healthcheck concerns, and the remaining defects
require restoring lost application code. Happy to follow up in a separate PR (or
in this one, on request) once you tell me which of them you want handled.

Prerequisite commits (included, clearly separated)

These are needed for the repository to build or start at all on main and are
kept in their own commits so they can be reviewed or dropped independently.

  1. fix: restore accidentally deleted package.json and jest env setup —
    package.json and jest.env-setup.cjs were deleted by 599ab6e
    ("security: Clarify which tests need Postgres or containers (Clarify which tests need Postgres or containers #1338)"), a
    docs/test-scoping change that also dropped README.md. Without the manifest
    there is no npm ci and every CI job fails.
  2. fix: restore deleted adminAuth middleware and repair broken module wiring
    — restores src/middleware/adminAuth.ts (deleted by 092ece9 while seven
    modules still import it) and repairs three ESM-fatal defects that each crash
    the process at import time: src/logger.ts re-exported a symbol it never
    imported (SyntaxError: Export 'getCorrelationId' is not defined in module),
    src/webhooks/webhook.types.ts declared DEFAULT_RETRY_POLICY twice
    (SyntaxError: Identifier 'DEFAULT_RETRY_POLICY' has already been declared;
    the surviving declaration keeps maxRetries: 5, which
    migrations/0020_subscription_retry_policy.sql and
    docs/webhook-retry-override.md document as the platform default), and
    src/migrate.ts guarded its CLI entrypoint with require.main === module
    inside an ES module (ReferenceError: require is not defined in ES module scope), which fires on import from src/db/index.ts — i.e. on every boot.

Known pre-existing failures (unrelated to this PR)

Also reproducible on a pristine main checkout:

  • npx tsc --noEmit → 273 type errors (140 in production source).
  • The full Jest run → ~148 failing suites / ~876 failing tests.
  • scripts/check-migrations.ts (the only non-continue-on-error CI step) fails
    on main: Duplicate new migration prefix 24 (both 0024_hash_api_keys.sql and
    0024_idempotency_store_scope.sql are tracked) and
    Destructive migration "0024_idempotency_store_scope.sql" requires -- destructive-approved: #<issue> (DROP CONSTRAINT / DROP INDEX).
    Because it fails on main, it fails for every PR including this one; fixing
    it requires renaming a migration and adding an approval marker, which is a
    migration-policy decision rather than part of this issue.

Acceptance criteria mapping

Criterion Status
docker build and docker run start the server without ENOENT docker build ✅. docker run no longer fails with ENOENT and reaches application code ✅; full boot additionally blocked by the unrelated gatewayRoutes.ts defect above ❌
The image contains the OpenAPI document at the path the code reads ✅ verified inside the image (195,775 bytes at /app/docs/openapi.json)
docker inspect shows a HEALTHCHECK ✅ verified via docker inspect
docker-compose up starts with JWT_SECRET, ADMIN_API_KEY and METRICS_API_KEY supplied ✅ env_file: [.env]; Postgres healthy, API container starts

Non-goals respected

No typo-only or cosmetic changes, no unrelated refactors, no dependency
upgrades, and no validation or safeguard weakened to make anything pass.

`package.json` and `jest.env-setup.cjs` were removed by commit 599ab6e
("security: Clarify which tests need Postgres or containers (CalloraOrg#1338)"), a
docs/test-scoping change that also dropped README.md. The result is that the
repository cannot build, lint, typecheck or run a single test on main: every
npm script is missing and `npm ci` fails outright, which also fails CI.

Restore both files verbatim from the commit before the deletion (95f3700).
`jest.config.cjs` still references `jest.env-setup.cjs` through
`setupFiles`, so test runs are broken without it as well.

README.md is intentionally not restored here: it is a 561-line document with
no effect on the build, and re-adding it does not belong in this change.
…ring

The runtime image passed every Dockerfile step but still could not start the
server, because four modules in the boot graph are broken on main. Each one is
a hard stop (`SyntaxError` / `ReferenceError`) rather than a warning, so the
container restarted in a crash loop.

1. src/middleware/adminAuth.ts was deleted by 092ece9 ("security: Guard audit
   config reads and scope its mutations (CalloraOrg#1258) (CalloraOrg#1358)") while admin.ts,
   routes/admin/*, routes/audit.ts, routes/errors.ts, routes/spikes.ts and
   billing/disputes.ts, billing/refund.ts still import it. Restored verbatim
   from the commit before the deletion (092ece9^). Note that its removal is
   also why a large number of the repository's test suites could not even load.

2. src/logger.ts re-exported `getCorrelationId` without importing it and
   re-exported `setCorrelationId`, which does not exist in
   utils/asyncContext.ts at all. Under ESM that is a link-time failure:
   "SyntaxError: Export 'getCorrelationId' is not defined in module". The
   re-export list now matches the module's actual exports.

3. src/webhooks/webhook.types.ts declared `RetryPolicy` and
   `DEFAULT_RETRY_POLICY` twice, which is a redeclaration error under ESM
   ("SyntaxError: Identifier 'DEFAULT_RETRY_POLICY' has already been
   declared"). The duplicate also disagreed on the default retry count; the
   surviving declaration keeps `maxRetries: 5`, which is what
   migrations/0020_subscription_retry_policy.sql and
   docs/webhook-retry-override.md document as the platform default.

4. src/migrate.ts guarded its CLI entrypoint with `require.main === module` in
   an ES module, which throws "ReferenceError: require is not defined in ES
   module scope" the moment the module is imported — including by
   src/db/index.ts, which the server imports at boot. It now uses the same
   argv-based CommonJS/Jest-compatible guard as src/index.ts.
The runner stage copied only `node_modules` and `dist`, so the container died
with ENOENT before serving a request: `src/routes/index.ts` resolves the
OpenAPI document with `path.join(process.cwd(), "docs/openapi.json")` at import
time and passes it to express-openapi-validator. The image also used
`npm install` instead of `npm ci`, never ran `prisma generate`, and declared no
HEALTHCHECK, so orchestrators had no liveness signal and builds were not
reproducible.

Dockerfile
- `npm ci` in both the builder and deps stages, so the image is built from
  exactly the committed lockfile instead of a locally re-resolved tree.
- `npx prisma generate` in the builder and in the deps stage. `@prisma/client`
  is generated code; without it the runtime import resolves to a stub.
- Copy `docs/openapi.json` into the runner at the path the code reads, and copy
  `migrations/` because both the migration runner and the production schema
  check read `migrations/*.sql` relative to the working directory.
- `CMD ["node", "dist/src/index.js"]` corrected to the entrypoint the compiler
  actually emits (`rootDir: "."` means `tsc` writes `dist/src/index.js`, never
  `dist/index.js`); the old value could only ever have been a
  MODULE_NOT_FOUND.
- Apply migrations before serving (`node dist/src/migrate.js && exec node
  dist/src/index.js`). `initializeDb()` validates rather than applies
  migrations when `NODE_ENV=production` and refuses to boot with pending
  migrations, which is always the case for a freshly created SQLite database.
- `chown -R node:node /app`, because the process drops to the unprivileged
  `node` user but creates its SQLite database in the working directory.
- HEALTHCHECK hitting `/api/health` with Node's built-in `fetch`, so no curl or
  wget has to be added to the image.

Metadata guards (`error-codes:check`, `validate:openapi`) run as explicit steps
instead of through the `prebuild` script, and the compile step is followed by an
assertion that `dist/src/index.js` exists. `tsc` is allowed to report the
repository's pre-existing type errors — upstream CI marks its typecheck and
build steps `continue-on-error` for the same reason — but a build that produces
no entrypoint still fails.

docker-compose.yml
- Required secrets (`JWT_SECRET`, `ADMIN_API_KEY`, `METRICS_API_KEY` — all
  mandated by the zod config schema) are supplied through `env_file`, so the
  stack no longer starts in a state where the app exits on missing config.
- Dropped the obsolete top-level `version` key.

Verified locally: `docker compose build` succeeds; `docker inspect` reports the
HEALTHCHECK; `/app/docs/openapi.json` is present in the image (195,775 bytes)
and decodes; `docker run` reaches application code with no ENOENT. The
remaining boot failure is a pre-existing unrelated defect in
`src/routes/gatewayRoutes.ts` (see the PR description).
@drips-wave

drips-wave Bot commented Sep 30, 2026

Copy link
Copy Markdown

@iyanumajekodunmi756 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

# Conflicts:
#	src/middleware/adminAuth.ts
@greatest0fallt1me
greatest0fallt1me merged commit 608e157 into CalloraOrg:main Oct 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ship OpenAPI spec and healthcheck in Docker image

2 participants