Custom Domains
Custom domains let users serve images and profiles from their own subdomain. It's handy for branding, SEO, and avoiding CORS issues.
As well as (or instead of) pixelflare.cc/:user/:album/:image, you'd get cdn.example.com/:album/:image. And your profile becomes cdn.example.com/ instead of pixelflare.cc/u/:user.
How It Works
graph TB
User[User visits cdn.example.com]
DNS[DNS resolves to Cloudflare]
Worker[Worker with custom domain middleware]
KV[KV Cache: hostname -> owner]
DB[D1 Database: custom_domains table]
Content[User's images/profile]
User -->|1. Request| DNS
DNS -->|2. Routes to| Worker
Worker -->|3. Check cache| KV
KV -->|4. Cache miss| DB
DB -->|5. Return owner| Worker
Worker -->|6. Serve content| Content
Content -->|7. Response| User
style KV fill:#e1f5ff99
style DB fill:#fff4e199
style Worker fill:#f0e1ff88The flow:
- User configures custom domain via API (
POST /v1/custom-domain) or UI - API creates custom hostname in Cloudflare and stores config in D1
- User adds DNS records (CNAME + TXT) to their domain config
- Background queue polls Cloudflare every 30 seconds to check verification
- Once verified, hostname-to-owner mapping gets cached in KV
- Requests to custom domain are intercepted and served from cache
Then, Cloudflare handles SSL certificates, DNS verification, and CDN routing so no extra infrastructure needed.
Database Schema
Custom domains are stored in custom_domains with a one-to-one relationship to users.
Migration: packages/database/migrations/016_custom_domains.sql
Status values:
pending- DNS verification in progressactive- Verified and workingblocked- Cloudflare blocked the domainerror- Verification failed
API Endpoints
All endpoints live under /v1/custom-domain and require authentication.
Implementation: packages/api/src/routes/api/custom-domain.ts
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| GET | /v1/custom-domain | read | Get your domain config |
| POST | /v1/custom-domain | write | Create or update domain |
| DELETE | /v1/custom-domain | delete | Remove domain |
| POST | /v1/custom-domain/verify | write | Trigger verification check |
Example response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"hostname": "cdn.example.com",
"status": "pending",
"cname_target": "fallback.pixelflare.cc",
"txt_name": "_acme-challenge.cdn.example.com",
"txt_value": "abc123def456"
}Hostname validation:
- 3-253 characters
- Must be a subdomain (no apex domains like
example.com) - Max 5 levels deep
- No forbidden subdomains (www, mail, api, admin, etc)
Verification Flow
Domain verification uses a background queue with retry logic. Takes 1-20 minutes depending on DNS propagation.
sequenceDiagram
participant User
participant API
participant Cloudflare
participant Queue
participant KV
User->>API: POST /v1/custom-domain
API->>Cloudflare: Create custom hostname
Cloudflare-->>API: Return verification records
API->>Queue: Send verification message
API-->>User: Return DNS records to configure
Note over User: User adds CNAME + TXT records
loop Every 30 seconds (max 40 retries)
Queue->>Cloudflare: Check hostname status
alt Status is active
Queue->>KV: Cache hostname -> owner
else Status is pending
Queue->>Queue: Re-queue for next check
end
endKey files:
- Queue consumer:
packages/api/src/queue/custom-domain-consumer.ts - Cloudflare API:
packages/api/src/services/custom-domains/cloudflare-api.ts - Verification:
packages/api/src/services/custom-domains/verification.ts
Request Routing
Custom domain requests get intercepted early in the Worker lifecycle, before any routers run.
Middleware: packages/api/src/middleware/custom-domain.ts
API routes (/v1/*) and auth routes (/auth/*) return 403 on custom domains. This prevents credential leakage.
Supported URL Patterns
| Pattern | Example | Description |
|---|---|---|
/ | cdn.example.com/ | Profile landing page |
/:id | cdn.example.com/abc123 | Image by short ID |
/:id/:variant | cdn.example.com/abc123/w512 | Image with variant |
/:album/:filename | cdn.example.com/vacation/sunset.jpg | Image by album/filename |
Custom domains skip the /i/ prefix. Short IDs are accessed directly as /abc123.
Path parsing: packages/api/src/lib/custom-domain-handler.ts
Profile Page Proxying
When someone visits cdn.example.com/, the Worker fetches their profile from the frontend and rewrites the HTML.
CDN routing: packages/api/src/routes/cdn/custom-domains.ts
The rewriter does three things:
- Rewrites profile links (
/u/alice/becomes/) - Points nav links to main domain
- Injects
window.__CUSTOM_DOMAIN_USERNAME__for the frontend
If profile_visibility is not public, the request redirects to the main site instead.
HTML handlers: packages/api/src/lib/html-rewriter-handlers.ts
Security
Plan enforcement: Only Starter and Pro users can create custom domains. Checked at custom-domain.ts:66-73.
SSRF protection: Profile fetches validate FRONTEND_HOST against private IP ranges. Blocks localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, and IPv6 equivalents. See custom-domains.ts:42-69.
HTML injection: Uses Cloudflare's HTMLRewriter for proper parsing instead of regex. Usernames are JSON-encoded to prevent XSS.
DoS protection: Hostname validation checks length and depth before running regex. Max 5 subdomain levels. See validation.ts.
Configuration
Environment Variables
ENABLE_CUSTOM_DOMAINS='true' # Enable custom domains
CLOUDFLARE_ZONE_ID='your-zone-id' # Cloudflare zone ID (with SaaS enabled)
CLOUDFLARE_API_TOKEN='your-api-token' # API token with required permissions
CUSTOM_DOMAIN_FALLBACK_CNAME='fallback.pixelflare.cc' # Fallback origin
FRONTEND_HOST='app.pixelflare.cc' # frontend host for proxyingAPI token permissions:
- Zone > SSL and Certificates > Edit
- Zone > Custom Hostnames > Edit
Terraform
The fallback DNS record uses 100:: (discard prefix, RFC 6666). Cloudflare proxies it through Workers so the address is never actually used.
Variables: terraform/variables.tf:301-324
Wrangler
Local development needs queue bindings. See packages/api/wrangler.dev.toml:49-57.
Edge Cases
KV cache TTL is 1 hour. Domain changes can take up to an hour to propagate unless the cache is explicitly invalidated. Delete and update operations do invalidate the cache. But if the Worker crashes between database update and cache invalidation, stale data could persist.
Instance limit is 99 domains. Cloudflare for SaaS includes 100 free custom hostnames on Enterprise plans. The code caps at 99 to stay under.
Private profiles redirect. If a user sets their profile to private, custom domain requests redirect to the main site. Images still work.
Troubleshooting
Domain stuck in pending
Check DNS propagation:
dig cdn.example.com
dig _acme-challenge.cdn.example.com TXTCommon causes: DNS not propagated yet, wrong CNAME target, wrong TXT value. Wait 5-10 minutes and click "Check Status" in the UI.
Domain shows error status
Read the last_error field. Common errors:
- "CAA record prevents issuance" - Add CAA record allowing letsencrypt.org
- "CNAME not found" - DNS not propagated or wrong value
- "Hostname already exists" - Domain claimed by another Cloudflare account
Images work but profile redirects
Profile visibility is set to private. Change it to public in account settings.
API routes return 403
API routes are blocked on custom domains by design. Use the default API host for API calls.
File Reference
| Purpose | File |
|---|---|
| Database migration | packages/database/migrations/016_custom_domains.sql |
| API routes | packages/api/src/routes/api/custom-domain.ts |
| Middleware | packages/api/src/middleware/custom-domain.ts |
| CDN routing | packages/api/src/routes/cdn/custom-domains.ts |
| Path parsing | packages/api/src/lib/custom-domain-handler.ts |
| Cloudflare API | packages/api/src/services/custom-domains/cloudflare-api.ts |
| Validation | packages/api/src/services/custom-domains/validation.ts |
| Queue consumer | packages/api/src/queue/custom-domain-consumer.ts |
| HTML rewriting | packages/api/src/lib/html-rewriter-handlers.ts |
| Config constants | packages/config/src/custom-domains.ts |
| Frontend manager | packages/frontend/src/lib/components/settings/CustomDomainManager.svelte |