6.9 KiB
AGENTS.md
Guidance for docker/prod/, the production all-in-one image. Shared
principles and TS/JS style live in the root AGENTS.md.
What this is
One public image (ghcr.io/oxynote/oxynote) running Caddy, web (Nuxt SSR),
core and auth-realtime under launcher/ (@oxynote/launcher, pnpm), a
TypeScript supervisor run by node under tini, plus an embedded Postgres that
OXYNOTE_DB_DSN swaps for an external one. Valkey, an S3-compatible store,
SMTP and
changedetection.io are optional (without a store, objects live on the data
volume, as does the search index, which core rebuilds from Postgres when
missing). docker-compose.example.yaml is the canonical deployment and
README.md the operator reference. docker-compose.local.yaml is the
override make prod-run layers on it to build the image locally and add a
mailpit, since the example runs without email.
make prod-build(repo root) builds it. Core's binary must come from goreleaser, nevergo build; the target stages it into.build/for the Dockerfile's COPY, so a baredocker buildfails on that COPY by design.make e2e-prodis the only thing that exercises the image as an operator installs it, and CI runs it only as a release gate. Run it locally for any change to the Dockerfile, the Caddyfile or the launcher. See e2e/AGENTS.md.make prod-publishis the release build, run only by release.yml on a tag. It usesgoreleaser build(notrelease, which would also publishghcr.io/oxynote/core), so core reports the tag as its version andproductionas its environment, and pushes exactly two tags:latestand the bare semver (v1.2.3→:1.2.3), each forlinux/amd64andlinux/arm64. Local builds (prod-build,e2e-prod) target only the Docker engine's own architecture, so CI's release gate tests amd64 alone.server.jsonis the image's entry in the official MCP Registry. After a release, set itsversionto the tag and runmcp-publisher publish docker/prod/server.json. The URL path must match the Caddyfile's/coreroute.
Invariants
- The trust boundary is the loopback bind. Core's
/api/x/*and auth-realtime's/api/internal/*have no auth. Inside the container core (8180), auth-realtime (8181) and web (3000) bind127.0.0.1; only caddy listens on the wildcard (8080). Ports are constants inlauncher/src/mapping.ts, the internal address variables are rejected by the unknown-variable guard, and the supervisor dials the container's own external addresses at boot and refuses to serve if anything answers. e2e/tests/prod-trust-boundary.test.ts asserts the same from outside. Changing any of this is a security decision. - The embedded Postgres is reached only through its socket, and only with
a password.
listen_addressesis empty (5432 stays in the boot check and the trust-boundary table),pg_hba.confis shipped in the image and passed ashba_file, and the app role owns its database and nothing else. Core dials the data sources workspace members configure, so trust auth or a superuser app role hands every organization's data to anyone who can add one. The major is fixed by thepostgresql18package and the launcher's/usr/libexec/postgresql18paths; a data directory only opens under the major that wrote it, so a major bump needs an upgrade path for existing volumes. Caddyfileis a sibling of docker/Caddyfile: same routes, same 403 blocks on/core/api/x/*and/auth-realtime/api/internal/*; only the upstreams differ. Change both together.- The public namespace is minimal and vendor-neutral. Operators see only
the flat
OXYNOTE_*variables declared inlauncher/src/env.ts. Derive from shared values (oneOXYNOTE_PUBLIC_URL, DSNs carrying credentials) instead of adding variables, generate secrets instead of exposing them, and never leak an internal dependency's name into a public variable. An undeclaredOXYNOTE_*variable is a boot error. - Secrets precedence (
launcher/src/secrets.ts): explicit env (never persisted) → existing volume file → generate and persist 0600. The data-source override may carry a whole keyring; the generated file holds one key. Never weaken this path. - Sentry DSNs are build-time only (esbuild
--definefrom Dockerfile ARGs);OXYNOTE_CRASH_REPORTING_DISABLEDis the only runtime switch. The launcher keeps its own DSN (bakedLauncherSentryDsn) to report a child killed outright or a boot that fails before any child exists, and reads the disable switch fromprocess.envbecause an invalid environment is one of the failures worth reporting. - Crash policy: die, don't restart. Any child exit tears the rest down and exits nonzero; restarts belong to the container runtime.
- Every emitted line is a JSON record with a
servicefield (launcher,core,auth-realtime,web,caddy).childRecord(launcher/src/logging.ts) addsserviceto the child's own object and changes nothing else; a non-JSON line becomes{msg, service}. The launcher's own events go through pino at a fixed info level (OXYNOTE_LOG_LEVELgoverns the services only). Each stream has its own line splitter;mutedrops output a child cannot switch off (nitro's listen banner). - Supply chain: every
FROMis pinned by digest; caddy and tini are downloaded at pinned versions and checksum-verified. Caddy is Apache-2.0 (LICENSE ships at/oxynote/licenses/caddy/) and trademarked: bundle it unmodified and unrebranded.
launcher/
Follows server/auth-realtime's
conventions: tabs-at-8 prettier, no semicolons, explicit .js import
extensions, type-aware eslint, knip, vitest with concurrent tests and the
context-local expect. src/index.ts is the only module with side effects
and is excluded from coverage; everything else is a factory taking its
dependencies and is fully tested.
cd docker/prod/launcher
pnpm run qa # check-lint + test
pnpm run build:bundle # esbuild -> dist/launcher.mjs; SENTRY_*_DSN env become baked defines
The runtime is pinned alpine with alpine's nodejs package plus
icu-data-full (so Intl formats every locale during SSR). The image ships
no node_modules: auth-realtime and the launcher are single esbuild bundles
with sentry folded in, and web is nitro's minified self-contained output
(NITRO_MINIFY=true). The web builder fails if a *.node file lands in the
output.
Both bundles carry a createRequire banner. Sentry's opentelemetry
dependencies require() node builtins at runtime and an --format=esm
bundle has no require to give them; without the banner the container
fails at startup with Dynamic require of "util" is not supported, and no
unit test catches it because the bundle is only built in the image.