docs(devops): security policy, Caddy and DigitalOcean self-hosting, dev container, API debugging

Merged
EV
14d ago
+2777-20

Summary

Six DevOps / docs issues, each an addition next to what exists (nothing removed, no default changed):

  • Security reporting (#7010): a SECURITY.md policy; reports go through GitHub private vulnerability reporting (or security@ever.co) instead of huntr; the issue template chooser links it.
  • Cal button (#6769): a "Book a call" Cal.com button and link in the README contact section.
  • Deploy to DigitalOcean (#9156): a README button and its App Platform template (.do/deploy.template.yaml): API, web UI and worker from the published GHCR images, an internal Redis, a dev Postgres, one origin with /api, /graphql, /public and /swg (Swagger UI) routed to the API, every secret asked for at deploy time.
  • Caddy + docker compose (#7323): docker-compose.caddy.yml + .deploy/caddy/Caddyfile + .env.caddy.sample: automatic HTTPS for one domain, Postgres, the API (REST, GraphQL, uploads, Swagger UI) and web UI behind one origin, Redis and the worker that deliver events.
  • Dev Containers (#5570): .devcontainer/devcontainer.json (Node 26, Docker-in-Docker, GitHub CLI, ports 3000/4200), Codespaces-aware API/web UI URLs, README badges for Codespaces and Dev Containers.
  • Debugging startup files (#9573): the root cause fixed (copied package source maps pointed at files that do not exist), working VS Code configurations, and start:api:debug / start:worker:debug to break in startup code.

Why

  • #7010: huntr no longer takes reports for this project; reporters had no current private channel, and the repository had no security policy.
  • #6769: requested contact option.
  • #9156, #7323: there was no one-click or single-command way to self-host with HTTPS. Both templates refuse to start without real secrets rather than shipping defaults (the API itself refuses default JWT/session secrets and seed passwords in production).
  • #5570: contributors on Windows/macOS spend a long time on toolchain setup; a dev container makes yarn bootstrap && yarn start reproducible, and enables Codespaces.
  • #9573: the API and worker builds copy every compiled @gauzy/* package into dist/apps/<app>/node_modules/@gauzy, three levels deeper than dist/packages, but the copied .map files kept sources relative to the old place. They resolved to dist/apps/<app>/packages/... (which does not exist), so breakpoints in packages/*/src never bound and mapped stack traces pointed nowhere. The old launch configurations also pointed at files that do not exist (start:gauzy, src/main.ts).

What changed

Repository root / docs

  • SECURITY.md (new): supported versions, scope, how to report privately (GitHub advisory form preferred, security@ever.co), what to include, test only your own installation, what happens next (fix on develop, GHSA + CVE, credit).
  • README.md: security paragraph and contact list point to private reporting and SECURITY.md; Cal.com button + link; Deploy to DO button and explanation (existing DO bullet kept); new sections "Self-hosting with Caddy (automatic HTTPS)", "Dev Container / GitHub Codespaces", "Debugging the API in VS Code"; Codespaces and Dev Containers badges next to the Gitpod one.
  • .github/ISSUE_TEMPLATE/config.yml: first contact link "Report a security vulnerability" (advisory form, with security@ever.co as the email alternative). huntr stays in .cspell.json.

Self-hosting templates

  • .do/deploy.template.yaml (new): api (ghcr.io/ever-co/gauzy-api, health check /api/health), webapp (gauzy-webapp), worker (gauzy-worker, a worker component), redis (Docker Hub redis:alpine, an internal-only service at redis:6379; App Platform dev databases are PostgreSQL only), dev Postgres db; DB settings bound from ${db.*} with TLS on (DB_CA_CERT: ${db.CA_CERT}); REDIS_ENABLED=true on the API and worker; API_BASE_URL / CLIENT_BASE_URL / ALLOWED_ORIGINS = ${APP_URL}; heap capped for the instance size (the images default to 12288 MB, as prod's k8s manifest also caps); ingress /api, /graphql, /public, /swg, /swg-json, /swg-yaml → api (path kept), / → webapp. The four JWT/session secrets are app-level (the API and worker share them) and the three DEMO_*_PASSWORD values are on the API, all type: SECRET with no value.
  • docker-compose.caddy.yml, .deploy/caddy/Caddyfile, .env.caddy.sample (new): only Caddy publishes ports (80, 443 tcp+udp); /api*, /graphql (incl. the graphql-ws upgrade), /public/* and the Swagger UI (/swg*, /swg-json, /swg-yaml) go to the API, the rest to the web UI (the Scalar reference at /docs is left out on purpose: its page loads its script from a CDN, which here would run on the web app's origin; the Caddyfile says how to add it); Caddy drops a client-sent CF-Connecting-IP; TRUST_PROXY=1 (the API is reachable only through Caddy); every secret and DB_PASS is ${KEY:?...}, so compose refuses to start without it. Redis and the worker always run with REDIS_ENABLED=true, as in docker-compose.yml: without them the API registers no outbox dispatch, so webhooks, the search index, entitlement grants and GraphQL subscriptions would receive no events.
  • Generation hints: openssl rand -hex 64 for the JWT/session secrets and the database password, openssl rand -hex 24 for the DEMO_*_PASSWORD values (the sign-in form takes at most 50 characters).
  • .gitignore, .dockerignore: .env.caddy (the filled-in copy holds secrets).

packages/config

  • database-helpers.ts: decodeCaCertificate() — DB_CA_CERT keeps accepting base64 and now also accepts the PEM text itself (what DigitalOcean and other managed databases hand out). Detection is by the PEM header, which base64 can never contain. .env.sample comment updated, with how to quote PEM text in an env file (unquoted, dotenv reads only its first line).
  • database-tls-options.spec.ts (new).

apps/api / apps/worker build

  • apps/api/config/source-map-rebase.js (new): rewrites the relative sources of each copied .map for its new directory (absolute and URL sources untouched, relative sourceRoot folded in). Never throws: a map it cannot read is copied as is.
  • apps/api/config/webpack-package-copy-patterns.js: every package copy uses it (used by both the API and the worker builds). Cached per file content (copy-webpack-plugin transform cache) for every package except @gauzy/config, whose existing environment selection already runs uncached and now composes with it.
  • apps/api/config/source-map-rebase.test.mjs (new, node:test).

Dev tooling

  • .vscode/launch.json: all three entries kept and fixed in place. Debug Server (attach 9229) now continues an --inspect-brk process, skips Node internals and resolves maps under dist/apps/*/node_modules/@gauzy. Launch Program builds and starts the API in watch mode under the debugger (calls Nx directly: yarn ng replaces NODE_OPTIONS, through which js-debug attaches). Debug Nest Framework runs the last built dist/apps/api/main.js under the debugger. WSL note (localRoot / remoteRoot).
  • package.json: start:api:debug, start:worker:debug (--inspect=inspect-brk). Existing scripts unchanged.
  • .devcontainer/devcontainer.json (new) and .devcontainer/codespaces-env.sh (new): in a codespace, the container shells set API_BASE_URL to the forwarded API URL (https://<codespace>-3000.<domain>) and CLIENT_BASE_URL / ALLOWED_ORIGINS to the web UI's (-4200). Your own value wins: the shell's, then one in .env.local, then one in .env (anything but the localhost default). Whichever value is used is exported and listed in ENV_LOCAL_NO_OVERRIDE (below), so the web UI, the API and the worker agree on it. Nothing changes outside Codespaces. README explains that port 3000 must be Public for the browser to call it.

apps/api / apps/worker env loading

  • load-env.ts (both): ENV_LOCAL_NO_OVERRIDE, a comma-separated list of variables whose value in the starting environment the tracked .env.local does not override. Opt-in: without it nothing changes, and a list written in .env or .env.local has no effect. Needed because both load .env.local with override, which replaced the forwarded Codespaces URLs with localhost.
  • load-env.spec.ts (new, both).

CI (static-checks.yml, platform-gates, each step if: always())

  • node --test apps/api/config/source-map-rebase.test.mjs
  • node --test .devcontainer/codespaces-env.test.mjs
  • tools/scripts/self-hosting-templates-check.mjs (new gate) + its cases. Secrets: reads the required secrets from packages/core/src/lib/bootstrap/validate-secrets.ts and checks that the DO template, the compose file and its env sample ask for each and ship none (each sample assignment matched as a whole: only nothing, or empty quotes optionally followed by a comment, counts as empty, so a base64 value ending in = and KEY= # note, which compose reads as # note, count as values). Routes: reads the REST prefix, the GraphQL path, the uploaded files' serveRoot and the Swagger UI path from the API source and checks that the Caddyfile and the DO ingress send each to the API (DO with preserve_path_prefix) and the web app's paths to the web app: to the webapp component on DO, and through the catch-all handle { reverse_proxy webapp:... } in the Caddyfile. Each path is resolved by its proxy's own precedence: App Platform takes the longest prefix. For Caddy, the gate reads handle, handle_path and route blocks and sorts them as Caddy's sortRoutes does, so a scoped or shadowing block is caught and a prefix stripped by handle_path is reported. Node built-ins only, like the other gates.

Flags / settings and defaults

  • Nothing existing changes behaviour. All new deployment files are opt-in.
  • ENV_LOCAL_NO_OVERRIDE (new, API and worker): unset by default, which keeps today's precedence (.env.local over the environment over .env). Only the dev container sets it, in a codespace.
  • DB_CA_CERT: base64 works exactly as before; PEM text is additionally accepted.
  • start:api / start:worker unchanged; the :debug variants are opt-in.
  • Build output: only the copied .map files change (their sources paths); emitted JavaScript is byte-identical.
  • Caddy compose: GAUZY_DOMAIN (default localhost), optional GAUZY_PUBLIC_URL, GAUZY_HTTP_PORT / GAUZY_HTTPS_PORT (80/443), GAUZY_IMAGE_TAG (latest).

Tests

All run locally on this branch (Windows, Node 26.10.0):

  • NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/config/jest.config.ts --rootDir packages/config database-tls-options → 9 passed. Whole packages/config suite → 10 suites, 158 tests passed. npx tsc -p packages/config/tsconfig.lib.json --noEmit → exit 0.
  • node --test apps/api/config/source-map-rebase.test.mjs → 16 pass, 0 fail (includes a CONTROL case showing the map tsc writes does not resolve once copied; each run uses its own temporary directory).
  • Real webpack + copy-webpack-plugin with the new patterns over compiled core, config, contracts (ad-hoc script, not committed): 3341 maps copied, every rebased source resolves to the same file as the original map's (3340 exist; 1 is missing for the original too in that checkout); second build from webpack's memory cache identical. Over all compiled packages: 9141 maps / 9677 sources, 0 mismatches, transform cost ~0.8 s for a full copy.
  • node --test tools/scripts/self-hosting-templates-check.test.mjs → 45 pass, 0 fail. node tools/scripts/self-hosting-templates-check.mjs → PASSED. Controls on mutated copies: a new required secret, ${DB_PASS:-default} and a filled-in sample → 5 problems, exit 1; /graphql dropped from both templates and serveRoot moved in the API source → 4 problems, exit 1; the Swagger routes dropped from the Caddyfile and /swg-json from the DO ingress → 5 problems, exit 1. Mutation controls on the gate: the previous empty-value pattern, and a web app check that only rejects api each fail the new case written for it. For the Caddy reader, 10 mutations each fail at least one case: skipping the sort, Caddy's matcher rule or its length rule; not reading handle_path or route; not marking the stripped prefix; taking a snippet for a site; counting nested reverse_proxy lines; treating * as a path; and never closing blocks.
  • node --test .devcontainer/codespaces-env.test.mjs → 8 pass, 0 fail (values read as dotenv reads them: inside quotes, before a # comment, trimmed; outside a codespace nothing is set; inside, the forwarded URLs; the tracked .env.local's localhost values count as defaults; a shell value, then .env.local, then .env wins; every value used is listed in ENV_LOCAL_NO_OVERRIDE, once, also from a nested shell). Controls: the file check disabled → 1 fail; the previous versions of the script → 3 fail, then 1 fail. Gate: the old unanchored empty-value check → 2 fail.
  • NODE_OPTIONS=--experimental-vm-modules npx jest -c apps/api/jest.config.ts load-env plugins.matrix -t "loadEnv|keptOverEnvLocal|API boot order" → 13 passed (the 7 new cases and the existing boot-order cases through preload-env). Worker load-env.spec.ts → 3 passed. Control with the restore line disabled → 1 fail.
  • End to end: codespaces-env.sh sourced in bash with Codespaces variables, then the API's own load-env.ts (Node type stripping) in a workspace holding the tracked .env.local → API_BASE_URL / CLIENT_BASE_URL / ALLOWED_ORIGINS are the forwarded URLs, DB_TYPE still comes from .env.local; a custom URL in .env → that URL; a URL already in the shell → that URL; without the Codespaces variables → http://localhost:3000 as before.
  • node tools/scripts/gate-wiring-check.mjs → PASSED (33 gates wired, each if: always()).
  • docker compose -f docker-compose.caddy.yml --env-file <filled test env> config -q → exit 0 (Docker Compose v5.4.0). With the blank .env.caddy.sample → exit 1, "required variable … is missing a value: set … in .env.caddy". Services: db, redis, api, webapp, worker, caddy; only caddy publishes ports; REDIS_ENABLED=true on api and worker; the API waits for a healthy Redis.
  • .devcontainer/devcontainer.json validated against the official devContainer.base.schema.json (Ajv + jsonc-parser): valid (including the object-form postCreateCommand); a deliberately invalid control is rejected. Image tag and both features exist in their registries.
  • .do/deploy.template.yaml: strict YAML parse (unique keys) plus checks: no duplicate env keys per component, valid scopes, no SECRET with a value; the GHCR images it references are pullable anonymously.
  • .vscode/launch.json validated against js-debug 1.104.0's own node launch/attach schema (unknown properties rejected): all three configurations valid (the previous Debug Nest Framework entry was not: protocol was removed from js-debug).
  • Prettier --check on every new/changed file except README.md (develop's README is not Prettier-formatted; the additions follow its existing style). ESLint on the changed JavaScript: 0 errors.

Manual verification still needed

  • Deploy to DO button: one click-through on a DigitalOcean account, before #9156 is closed. Known risk: DigitalOcean's button page lists four combinations (one service or one static site, each with or without a Dev Database), and its examples all build from a git repository. This template has three services (API, web app, internal Redis) and a worker, all from prebuilt GHCR / Docker Hub images, plus a Dev Database. The button may refuse it. If it does, the follow-up (owner decision) is a smaller button template, or manual App Platform steps. doctl was not available here. Worth checking in the same run: the internal redis service (internal ports only) and the worker reaching it at redis:6379, and GraphQL subscriptions over /graphql.
  • Caddy stack: docker compose -f docker-compose.caddy.yml --env-file .env.caddy up -d on a host with a DNS name (no Docker daemon here, so the stack and caddy adapt were not run; the compose file was validated client-side only).
  • Dev container / Codespaces: one container build, and in a browser codespace the web UI reaching the API once port 3000 is Public (also whether the Angular dev server accepts the forwarded host name).
  • VS Code debugging: a breakpoint in packages/core/src/lib/bootstrap/index.ts with Debug Server + yarn start:api:debug, and with Launch Program. No VS Code here; the configurations were validated against js-debug's schema and js-debug's attach behaviour was read from its source.

Risk and rollback

  • Low. All deployment files and scripts are new and opt-in; .vscode/launch.json only affects VS Code users. The one runtime change, ENV_LOCAL_NO_OVERRIDE in the API and worker env loaders, does nothing unless that variable is set.
  • The build change touches only .map files of the copied packages. In the Docker images, where TypeScript sources are not shipped, the source paths shown in mapped stack traces change text (they did not resolve to a file before either); line and column numbers are unchanged.
  • The DO template now runs four components plus the dev database (API 2 GB, web UI 0.5 GB, worker 1 GB, Redis 0.5 GB), so a click costs more than the API + web UI alone; without the worker and Redis, events are never delivered.
  • Rollback: revert the PR, or individual commits.
  • GitGuardian's three "Generic Password" findings are false positives: they are the DEMO_*_PASSWORD: ${DEMO_*_PASSWORD:?set … in .env.caddy} lines of docker-compose.caddy.yml, required-variable references with an error message and no value (the self-hosting-templates-check gate enforces exactly that shape).

Decisions (owner answers)

  • SECURITY.md: no fixed acknowledgement time or disclosure window for now ("we aim to acknowledge your report promptly"). Wrapped at 80 columns for markdownlint MD013.
  • DO template keeps the dev database (production: false).
  • If the button rejects image components, switching to source builds is a follow-up.
  • Docs-site pages for Caddy self-hosting and debugging are a follow-up.

Closes #7010
Closes #6769
Closes #7323
Closes #5570
Closes #9573
Refs #9156 (implemented; close after the click-test above)

🤖 Generated with Claude Code