Deliver files through the CDN
Cache private signed downloads or explicitly published assets at cdn.fortly.com, configure folder access, purge safely and understand delivery budgets.
On this page
- Choose how files are shared
- Permissions at a glance
- Set up private delivery
- Generate private links in your app
- Publish an asset folder
- Understand the cache
- Inspect cache activity
- Why a request did not hit cache
- Find a request without exposing its URL
- Run a bounded cache check
- Know the coverage boundary
- Revoke or pause access
- Track requests and delivery bytes
- Troubleshooting
Use Storage → your bucket → Delivery to serve files through cdn.fortly.com with edge body caching. You can keep files private behind signed links or deliberately publish a folder such as public/. The underlying S3 bucket stays private in both cases. Uploading a file alone never publishes it.
CDN delivery is available on hosted Fortly. Every bucket starts with delivery disabled: choose Signed links or deliberately confirm a Public folder to begin. The current account-wide operational limits are 100,000 admitted requests and 10 GiB of delivered response-body budget per UTC month, shared across buckets. Delivery shows usage and remaining headroom. These are safety limits, not extra automatic charges. Self-hosted operators configure their own Worker, origin and budgets using the operator guide.
Choose how files are shared
| Mode | Who can read | Suitable for |
|---|---|---|
| Disabled | No CDN requests; authenticated S3 access remains available | A newly created bucket, or a paused distribution |
| Signed links | Anyone holding a valid, unexpired link for the exact file | Documents, exports and user uploads after your app authorizes access |
| Public folder | Anyone with a file URL inside the confirmed prefix | Website images, scripts, stylesheets and downloadable public assets |
A folder is an exact S3 prefix ending in /. Publishing public/ includes public/logo.png and future objects inside that folder, but excludes publicity/private.png, private/invoice.pdf and every other prefix. Publishing the entire bucket by leaving the public prefix empty is refused. To publish an asset, upload or copy it into the folder you deliberately choose.
Only the current account owner or an admin can configure delivery, purge, generate console links or browse private files. Existing S3 keys are read-only or read/write in exactly one bucket. Neither a delivery URL nor public delivery grants upload, delete, key-management or account access.
Permissions at a glance
| Caller | Can do | Cannot do |
|---|---|---|
| Current owner or admin | Configure access, preview public scope, create console links, purge and pause | Recover files already downloaded by a recipient |
| Project member | See safe bucket metadata and aggregate usage | Create console delivery links, retrieve private file paths, change publication or manage keys |
| App with a read-only bucket key | List/read that bucket through S3; sign CDN links on the server within the saved delivery scope | Upload, delete, publish folders or administer credentials |
| App with a read/write bucket key | Read, upload, replace and delete throughout that bucket; sign delivery links | Change CDN settings or administer other keys |
| Signed-link recipient | GET/HEAD the exact file while the link and signing key remain valid | List the bucket, upload, delete or change settings |
| Public-folder visitor | GET/HEAD a known file URL inside the published folder | List the bucket, read outside the folder, upload or manage access |
The CDN folder is not an S3 key restriction. A key keeps its read-only or read/write permission across its entire bucket, including folders outside the CDN scope. Use a separate bucket and dedicated key when an integration needs a narrower storage boundary. Object names and users/alex/ prefixes do not implement app-user permissions.
Your server must authorize each app user before it issues a signed link. The recipient does not need a Fortly login, and signing out does not revoke an existing bearer link. Keep the bucket secret on the server. A read/write key can publish new content by uploading into an already-public folder, even though it cannot change the publication settings.
Set up private delivery
- Open the bucket's Delivery section.
- Select Signed links. Leave Folder to deliver empty for any file in this bucket, or choose a narrower folder.
- Choose an Edge cache lifetime and select Enable delivery. Refreshing usage preserves your unsaved settings; if another admin changes the saved configuration, Reload settings is required before you can save. Purging the cache preserves your draft access settings.
- Enter an exact object key under Use a delivery URL, or use Files → file actions → Create delivery link.
- Copy the URL or select Test delivery. A console-generated signed CDN link defaults to 15 minutes.
The URL is a bearer credential: anyone holding it can read that object until expiry, subject to current signing-key access and delivery settings. It is reusable, not a one-time download. Keep it out of logs, analytics, public issue reports and screenshots. Files at the same key can change when overwritten; use separate keys if you need durable content snapshots.
A valid URL starts with:
https://cdn.fortly.com/<stable-s3-bucket-name>/<encoded-object-key>The signed query contains an access-key ID, issue time, expiry and signature. It never contains the secret, provider bucket or internal account prefix. Do not change the key, endpoint or token fields after signing. Other query parameters and duplicate signing parameters are rejected. S3 presigned URLs still belong on s3.fortly.com; their SigV4 query is not a CDN delivery token.
Generate private links in your app
Your app already has bucket-scoped credentials after connecting the bucket. Reuse them in server-side code. Authorize your end user before generating a CDN link. Do not place the secret in browser bundles, a public environment variable or a client-side signing helper.
This helper uses Node's built-in HMAC implementation and needs no new package. It uses default connection variables; the console's Generate links in your app example supports a saved connection's variable prefix. Both the exact published helper and generated console example are exercised against the local CDN Worker and production origin code in integration tests.
import { createHmac } from "node:crypto";
// Server only. Authorize the app user before generating a link.
export function createDeliveryLink(key, lifetime = 900) {
if (!Number.isInteger(lifetime) || lifetime < 60 || lifetime > 86400) {
throw new Error("Use a lifetime of 1 minute to 24 hours");
}
const endpoint = "https://cdn.fortly.com";
const bucket = process.env.S3_BUCKET;
const issued = Math.floor(Date.now() / 1000);
const expires = issued + lifetime;
const message = ["fortly-cdn-v1", endpoint, bucket, key, issued, expires].join("\n");
const signature = createHmac("sha256", process.env.AWS_SECRET_ACCESS_KEY)
.update(message).digest("hex");
const path = key.split("/").map(part => encodeURIComponent(part)
.replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase())).join("/");
const url = new URL(endpoint + "/" + bucket + "/" + path);
url.search = new URLSearchParams({
access_key_id: process.env.AWS_ACCESS_KEY_ID,
issued: String(issued), expires: String(expires), signature,
}).toString();
return url.toString();
}Call createDeliveryLink("exports/report.pdf") only after your application has checked who may read the report. You can choose a lifetime from 60 to 86,400 seconds. The gateway rejects expired links, future issue times beyond its bounded clock allowance, altered signatures and lifetimes beyond 24 hours. It also checks the current key's own expiry/revocation and the bucket's current ownership/state.
The token format is Fortly CDN v1, distinct from AWS Signature V4. Its HMAC-SHA256 message is six newline-separated values: fortly-cdn-v1, the exact configured CDN origin, stable S3 bucket name, exact decoded object key, Unix issue seconds and Unix expiry seconds. The signature is lowercase hexadecimal. The query field names are access_key_id, issued, expires, and signature. GET links also permit HEAD; no write methods are supported. Encode each path segment separately, preserving actual / separators. The helper handles spaces, Unicode, repeated slashes and literal percent signs without normalizing customer keys.
Publish an asset folder
- Select Public folder and enter a prefix such as
public/. - Review the file count, total size and sample object names. Empty folders can be published; future uploads into that prefix also become public.
- Type the bucket's display name to confirm the exposure, then save.
- Generate a URL for a file within that folder. Public URLs have no signing query. Switching back to signed links stops anonymous delivery; changing folders removes public access to files outside the new scope.
A public image can be used directly:
<img src="https://cdn.fortly.com/your-stable-bucket/public/logo.png" alt="Your logo" />Replace your-stable-bucket with the actual name copied from the console. Do not derive it from the display name. Renaming the display name does not change URLs. This is folder-scoped delivery, not an anonymous S3 bucket policy: unsigned S3 reads, uploads and listings remain refused.
Understand the cache
Fortly checks current delivery scope, signing-key state, bucket/account state, object version and budget before every response, including cache hits. The edge caches the file body; it does not cache an authorization decision. An unavailable authorization/accounting origin fails closed rather than serving stale private data.
- A first read in a Cloudflare location normally reports
X-Fortly-Cache: MISS; a repeated eligible read can reportHIT. - Cache lifetime is 1 minute–24 hours. It limits reuse of unchanged bytes; it does not delay access checks or make a signed URL valid longer.
- Cache identity includes the immutable bucket, cache generation and opaque object version. An overwrite selects a new version automatically. Deletion blocks new requests, including old cache hits.
- Purge cache advances generation for new requests across all locations. Old copies expire in the background and are not addressable through delivery URLs. This is logical invalidation, not a promise of immediate physical erasure from every cache.
- Cloudflare's Cache API is per-location and best effort. A hit is not guaranteed, and the implementation does not claim tiered caching.
Only full GET bodies up to 16 MiB enter the cache. Larger files stream from the private origin. A single byte range bypasses the body cache and reports BYPASS; unsatisfiable or multiple ranges are refused. HEAD sends metadata with no file body. If-None-Match can return 304 with no body. Range, HEAD and conditional requests still count as delivery requests.
Browsers must revalidate public responses. Signed responses use Cache-Control: private, no-store; the Worker stores an internal body copy separately and checks permission again before using it. No cookies, credentials or signed query values enter a cache identity. Untrusted HTML/SVG and unknown document types are attachments with sandbox CSP and nosniff on the separate CDN origin. Common image, font, CSS, JavaScript and JSON assets have their actual content type. CORS permits reading a known delivery URL without cookies; it is not authorization.
Inspect cache activity
Choose Explore cache activity in the bucket’s Delivery section. Select the last hour, 24 hours, seven days or 30 days. The request and data-transfer views share the same time window; charts use UTC. Hover, touch or focus a chart and use the arrow keys to inspect points. Switch between bars and lines, toggle series, open the data table or download the visible series as CSV.
- Admitted requests includes requests still awaiting a receipt. This is not a count of every request reaching Cloudflare.
- Cache hit ratio is completed HIT receipts divided by HIT plus MISS receipts. BYPASS and pending receipts are excluded.
- Processed body bytes counts reported response-body bytes. Body bytes from cache counts those bytes from HIT receipts, including a partial response if it was interrupted. These are not measured upstream egress, TCP/TLS bytes or financial savings.
- Response duration includes streaming inside the Worker. Authorization round trip measures its origin authorization call, which still happens on hits. First body byte measures the first body chunk inside the Worker; it excludes client network time and bodyless responses. Older unmeasured values display a dash, not zero.
- Edge locations are top Cloudflare location codes supplied by the platform. An unrecorded location displays Unmeasured; it is not inferred from a visitor IP.
Auto refresh runs every 30 seconds while the section is visible. Pause it when investigating a snapshot. Loaded history remains in place while charts update; Latest requests returns the inspector to current results. Aggregate charts use a consistent read snapshot, so their totals and time buckets agree while receipts are arriving. Expensive queries are bounded; choose a shorter window if the service asks you to retry.
Why a request did not hit cache
The breakdown distinguishes a normal MISS from an intentional bypass: HEAD metadata, an ETag/304 response, a byte range, an empty file or a body above 16 MiB. It also identifies an unavailable cache, failed cache lookup or cached metadata that failed version/length checks. A MISS does not prove a successful fill. The Cache API does not reliably distinguish expiry from an entry that was never present, so Fortly does not invent EXPIRED, STALE or REVALIDATED classifications.
Select a reason, HTTP status or location to inspect matching requests. The filter applies to the request list; the overview charts remain scoped to the selected time window. Requests load in pages of 50; the browser displays at most 500 matching records. Narrow the filters/window, find an exact ID or use the paginated API for older records. CSV export includes the displayed records only.
Find a request without exposing its URL
Copy X-Fortly-Request-ID from the delivery response and paste it into Find request ID. The inspector shows outcome, HTTP status, reason, expected/processed body bytes, measured timing and edge location. Refresh an awaiting receipt to see whether it completed. The request ID is an opaque correlation identifier; it is not a credential and does not bypass owner/admin access checks.
Use X-Fortly-Cache for Fortly’s internal body cache; the outer CF-Cache-Status is not a substitute for this result. The Worker exposes X-Fortly-Cache, X-Fortly-Cache-Reason, X-Fortly-Request-ID, X-Fortly-Telemetry-Version, ETag and content length to browsers. Store the opaque request ID in your application diagnostics when useful. Never log the signed URL, its query or the signing secret. Fortly’s request inspector does not retain object paths, signed queries, IP addresses, credentials or file contents.
Run a bounded cache check
Enter an existing object key in Use a delivery URL, then choose Test caching. The check makes one HEAD and two GETs for a file up to 1 MiB, using the bucket’s current signed/public mode without browser cookies or S3 credentials. The body reads are bound to the HEAD version with If-Match; an intervening overwrite returns 412 instead of downloading a newly enlarged object. Successful checks deliver at most 2 MiB of file bodies. They count toward delivery budgets, and browser preflight requests are outside the admission ledger.
The check displays each actual HTTP/cache outcome and its request ID. A repeated HIT is not guaranteed: fills are asynchronous and location-local. For larger files, use a smaller test object; this check limit does not change the normal 16 MiB cache limit. The checker refuses a URL outside the configured bucket endpoint and does not retain response contents. Older deployments without the required diagnostic/version headers stop after metadata instead of performing unbounded test reads.
Know the coverage boundary
Analytics are derived from the durable admitted-request ledger, not Cloudflare zone analytics. Rejections before admission—invalid/expired links, denied folders, budget refusals, edge burst limits and Worker/origin failures before authorization completes—do not create records here. An error count of zero therefore does not prove there were no rejected or failed edge requests. The bounded check can surface such HTTP errors directly, but there may be no recorded request ID to inspect.
Timing/reason/location fields are nullable for compatibility with older Workers. Thirty-day querying is a time-window option, not a retention guarantee; operators must establish retention and missing-receipt handling before activation. Only current owners/admins can retrieve analytics or request records. MCP discovery remains aggregate-only and excludes request IDs, object paths and private URLs.
Revoke or pause access
- Revoke an app key in Access keys to block its subsequent S3 and signed CDN requests, including hits. Rotate safely by verifying the replacement before retiring the old key.
- Settings → Revoke console download links retires the hidden signer used by console-generated S3 and signed CDN links. Separately scoped app keys and public folder URLs are unaffected. Individual console-link revocation is unavailable.
- Pause delivery blocks new CDN requests, including hits, without deleting files or disabling authenticated S3 access. If you resume, previously issued, unexpired links may become usable again. Revoke their signing key if they must remain blocked.
- Change a public prefix to remove that publication scope, or delete an object to stop new delivery of it. A response already in progress may finish. Files already downloaded or copied cannot be recalled.
Removing a teammate's Fortly membership does not retire delegated application keys. Follow the offboarding guide.
Track requests and delivery bytes
Delivery activity is separate from S3/console traffic and from stored-file capacity. The Delivery section reports bucket-attributed requests, processed bytes, hit/miss/bypass counts, failures and in-progress reservations in the current UTC month. The shared account budget spans every bucket.
Admission atomically reserves a request and its expected response bytes before serving a hit or miss. Concurrent edges cannot all escape the remaining budget. Completion/cancellation receipts reconcile the reservation to actual processed response body bytes. HEAD and 304 reserve zero body bytes. Ranges reserve only their selected bytes. Lost receipts remain conservatively reserved; the UI shows in-progress reservations rather than claiming they were free or delivered successfully.
Reaching the configured monthly byte or request budget refuses new CDN requests. Stored files and authenticated S3 reads/deletion remain available. Budget resets use the next UTC calendar month. These are explicit operational budgets, not a new published pricing tier or automatic overage charge. The current integration does not collect payment, settle invoices or enforce a prepaid credit balance.
Cached copies do not count as a second stored-object charge. The original object, public Project files, incomplete multipart uploads, reservations and pending physical cleanup remain in the existing shared storage allowance. Metrics count bytes processed by Fortly/its Worker, not confirmed receipt by a remote browser or total TCP/TLS wire bytes.
Troubleshooting
| Result | Meaning | Next action |
|---|---|---|
| Not configured | Origin-side delivery configuration is absent | Ask the operator to complete the gated Worker/origin rollout |
| Cloudflare 1010 | The edge browser-integrity check rejected a non-browser client before Fortly admission | Contact support with the status and CF-Ray, without the signed URL; self-hosted operators need the scoped protocol-client exception |
| 400 | Invalid/ambiguous URL, unsupported query or use of S3 authorization headers | Generate a delivery URL using the documented helper |
| 403 | Delivery paused, wrong folder, invalid/expired token or revoked key | Check saved scope and current key, then create a fresh link |
| 404 | Bucket/object is absent or no longer available | Check the exact key and refresh Files |
| 412 | File changed after the metadata/version check | Refresh metadata and retry against the current version |
| 416 | Unsupported or unsatisfiable byte range | Request one valid range or a full GET |
| 429 | Account delivery budget reached, or source burst limit | Review Delivery activity; retry after backoff for a burst limit, or wait for the budget reset |
| 503 / interrupted body | Authorization origin, provider or transfer is unavailable | Retry with backoff; check operator diagnostics without logging private URLs |
A setting being saved does not prove public DNS, TLS or Cloudflare deployment. Operators must validate those separately with disposable staging objects before advertising the hostname as active. Custom delivery domains, image resizing, tiered cache, automatic overage billing and object recovery/version retention are not provided by this delivery feature.