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). Omittools.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
Whentools.cdn is absent from configuration:
buildToolsdoes not import or construct a CDN client (tools.cdnisundefined).- Upload mutations check
if (cdn === undefined)and return{ success: false, reason: 'Upload is not enabled' }— seeapps/backend/src/gql/mutations/updateProfilePicture.ts.
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-s3package - Private buckets — extend bucket config (
visibility) and optionallygetFileUrloptions for presigned URLs - Org-scoped files — domain layer stores
{ bucket, id }+ org FK; key prefixes are adapter internals
Adding a new bucket
- Add a constant in
apps/backend/src/constants/cdn.ts(e.g.CDN_BUCKET_ATTACHMENTS). - Create the bucket in your object-storage provider and configure public delivery (
publicUrl). - Add a
buckets[]entry inproduction.ts/staging.ts(and env vars forpublicUrl). - Call
tools.cdn.uploadFile(file, CDN_BUCKET_ATTACHMENTS)from the resolver or utility. - Run
make test module=backend.
Adding a new CDN provider
- Add
CDNClientTypevalue and Zod schema branch inapps/shared/src/cdn/. - Create
apps/tools/cdn/<impl>/implementingCDNType. - Add a
caseinapps/backend/src/tools/cdn/loader.tswith exhaustivenevercheck. - Add
tooling-cdn-<impl>toapps/backend/package.jsonandMakefileALL_FILTERS. - Document under
apps/documentation/tooling/cdn/. - Run
make deps-installandmake 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.cdnin 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?
- Local CDN — dev/test noop client
- Cloudflare R2 — production object storage
- Configuration — swapping pluggable tools
- Tooling system — loaders and workspace packages
- Cloudflare deployment — DNS, TLS, and CDN in front of your app
