Skip to main content

Asset CDN Management

Status: COMPLETE — all binary assets served from Cloudflare R2 CDN. Binary assets are gitignored. Only CSS, HTML slides, and Python files tracked in git. GTM symlink removed — all apps use CDN URLs directly.
How to manage assets across Syntropy apps using Cloudflare R2 CDN.

Architecture

All Syntropy apps share a single R2 bucket with namespaced prefixes:
syntropyhealth-assets/
├── shared/              ← Cross-app assets (logos, favicons, partner icons)
│   ├── brand/           ← syntropy.svg, syntropy.png, favicons
│   └── icons/           ← sidebar/, chat/, devices/, partners/
├── journals/            ← Syntropy Journals app-specific
│   ├── img/             ← hero, bg, ai-icons, stock
│   └── partners/        ← partner logo SVGs
└── gtm/                 ← GTM app-specific (booking-demo, one-pager)
Public URL: https://pub-fb02de4e493b4e6a8c61de37de19575b.r2.dev Controlled by env var ASSETS_CDN_BASE_URL. When empty, all paths serve locally (no CDN). When set, asset_url() rewrites paths transparently.

Credentials

Already configured at the GitHub org level:
SecretWhereValue
CLOUDFLARE_API_TOKENGH Secrets (repo)cfat_TlOn...
CLOUDFLARE_ACCOUNT_IDGH Secrets (repo)803cc0d1...
ASSETS_CDN_BASE_URLGH Variable + envs/https://pub-fb02...r2.dev

Migration Steps (for a new app)

1. Add asset_url() resolver

Copy syntropy_journals/app/utils/asset_url.py into your app. The function:
  • Returns paths unchanged when ASSETS_CDN_BASE_URL is empty
  • Passes through external URLs (http://, https://)
  • Maps brand files to shared/brand/
  • Maps icon paths to shared/icons/
  • Maps everything else to {app_name}/ prefix
Adjust the mapping logic for your app’s prefix (e.g., gtm/ instead of journals/).

2. Add sync function to scripts/sync-assets.sh

The sync script already supports multiple apps. To add a new app:
# In scripts/sync-assets.sh, add:
sync_myapp() {
    log "Syncing MyApp assets..."
    local assets_dir="$MONOREPO_ROOT/apps/myapp/assets"
    find "$assets_dir" -type f | while read -r file; do
        local rel="${file#$assets_dir/}"
        log "  myapp/$rel"
        [ -z "$DRY_RUN" ] && wrangler r2 object put --remote \
            "$BUCKET/myapp/$rel" --file "$file" \
            --content-type "$(file --mime-type -b "$file")" 2>/dev/null
    done
}
Update the case statement at the bottom to include your app.

3. Wrap asset paths

In every file that uses static asset paths:
from your_app.utils.asset_url import asset_url

# Before:
rx.image(src="/img/hero.webp")

# After:
rx.image(src=asset_url("/img/hero.webp"))
For centralized path dicts, use comprehensions:
ICONS = {k: asset_url(v) for k, v in {
    "dashboard": "/img/icons/dashboard.svg",
    "settings": "/img/icons/settings.svg",
}.items()}

4. Add CI guardrail

Copy scripts/ci/check_asset_paths.py and adjust _SCAN_DIRS for your app’s source directories. This validates all asset paths resolve to real files.

5. Set env var

Add ASSETS_CDN_BASE_URL to your environment files and Railway/deployment config. The CDN is opt-in: leave it empty for local dev, set it for test/prod.

6. Upload assets

# From the Journals repo (sync script lives here):
make assets-sync                     # Upload all apps
./scripts/sync-assets.sh --app gtm   # Upload GTM only

CI Integration

The deploy workflow syncs assets before Railway deploy:
- name: Sync assets to Cloudflare R2
  if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  run: |
    npm install -g wrangler@3
    ./scripts/sync-assets.sh --app journals

Shared Assets

The shared/ prefix contains assets used by multiple apps:
  • Brand: syntropy.svg, syntropy.png, favicons
  • Icons: sidebar, chat avatars, device icons, partner logos
These are uploaded by sync_shared() in the sync script and sourced from the Journals repo’s assets/ directory (canonical source).

Verifying

# Check a specific asset exists on CDN:
curl -sI "https://pub-fb02de4e493b4e6a8c61de37de19575b.r2.dev/shared/brand/syntropy.svg"

# Run CI guardrail locally:
APP_ENV=test uv run python scripts/ci/check_asset_paths.py