Skip to main content
CDN is optional pluggable file storage: GraphQL uploads and backend code call tools.cdn to store files and resolve delivery URLs without importing a vendor SDK.

Why

Enable CDN when you ship upload features (profile pictures, attachments). Omit tools.cdn entirely when uploads are disabled — the backend does not load a CDN client and mutations return a friendly “Upload is not enabled” response.

How it works

Optional config

When tools.cdn is absent from configuration:
  • buildTools does not import or construct a CDN client (tools.cdn is undefined).
  • Upload mutations check if (cdn === undefined) and return { success: false, reason: 'Upload is not enabled' } — see apps/backend/src/gql/mutations/updateProfilePicture.ts.
When tools.cdn is present, the loader dynamic-imports the implementation package for config.client.

Per-environment defaults

Two URLs (object storage providers)

Object storage adapters use two unrelated URLs: See Cloudflare R2 for Cloudflare-specific setup.

Extensibility

Future work stays compatible with today’s contract:
  • AWS S3 — new CDNClientType + tooling-cdn-s3 package
  • Private buckets — extend bucket config (visibility) and optionally getFileUrl options for presigned URLs
  • Org-scoped files — domain layer stores { bucket, id } + org FK; key prefixes are adapter internals

Adding a new bucket

  1. Add a constant in apps/backend/src/constants/cdn.ts (e.g. CDN_BUCKET_ATTACHMENTS).
  2. Create the bucket in your object-storage provider and configure public delivery (publicUrl).
  3. Add a buckets[] entry in production.ts / staging.ts (and env vars for publicUrl).
  4. Call tools.cdn.uploadFile(file, CDN_BUCKET_ATTACHMENTS) from the resolver or utility.
  5. Run make test module=backend.

Adding a new CDN provider

  1. Add CDNClientType value and Zod schema branch in apps/shared/src/cdn/.
  2. Create apps/tools/cdn/<impl>/ implementing CDNType.
  3. Add a case in apps/backend/src/tools/cdn/loader.ts with exhaustive never check.
  4. Add tooling-cdn-<impl> to apps/backend/package.json and Makefile ALL_FILTERS.
  5. Document under apps/documentation/tooling/cdn/.
  6. Run make deps-install and make test module=tooling-cdn-<impl>.

Object lifecycle (no in-place overwrite)

CDNType exposes uploadFile and deleteFile only — there is no replaceFile or in-place update. That is intentional for public, cacheable buckets. Blog article saves write {articleId}/{timestamp}.mdx, update the DB content_path, then delete the previous object — see Blog system — CDN lifecycle. Persist bucket + id (object key) in the database, not the public URL. Delivery URLs are derived at read time via getFileUrl.

Gotchas

  • Local stub URLs (local-cdn://…) are not fetchable in <img> tags — use R2 in staging or accept broken previews in dev.
  • Do not use per-user dynamic bucket names — use fixed logical names from constants.
  • Omit tools.cdn in production only when upload features are intentionally disabled.
  • Blog editorial churn — each article content save costs two mutating CDN operations when replacing existing content; plan for R2 request billing if authors save frequently.

What’s next?