Skip to content

Digging Deeper

Storage

This page shows you how to read and write files through the wheels.storage abstraction instead of reaching for FileWrite / cfhttp directly. You configure one or more named disks — a local directory, an S3 bucket, or both — and resolve them through StorageManager for a single put / get / exists / delete / url / signedUrl surface that swaps backends with a config change.

You’ll learn:

  • How to declare disks in config/settings.cfm and resolve them by name
  • How LocalDisk stores objects on the filesystem and serves them through a URL prefix
  • How S3Disk talks to S3 over plain cfhttp with from-scratch SigV4 signing — no AWS SDK, no JARs
  • How signed (presigned) URLs work on both drivers
  • What the failure contract is (Wheels.Storage.* errors)

Storage mirrors the disk abstraction you already know from Laravel’s Filesystem or Rails’ ActiveStorage. Configuration names each disk and assigns it a driver — "local" or "s3". Application code asks StorageManager for a disk by name and receives a wheels.interfaces.StorageDiskInterface back, regardless of which driver is behind it.

config/settings.cfm
<cfscript>
set(storage = {
default = "local",
disks = {
local = {
driver = "local",
root = ExpandPath("../storage/uploads"),
urlPrefix = "/uploads"
},
s3 = {
driver = "s3",
bucket = "my-bucket",
region = "us-east-1",
accessKeyId = env("S3_ACCESS_KEY_ID"),
secretAccessKey = env("S3_SECRET_ACCESS_KEY"),
visibility = "private"
}
}
});
</cfscript>

Register the manager as a singleton in config/services.cfm. Because its init() takes the storage config struct — which isn’t a registered service — wrap construction in toFactory(), and end with .asSingleton() (a factory mapping is transient otherwise, so every service("storage") call would build a new manager):

config/services.cfm
<cfscript>
local.di = injector();
local.di.map("storage").toFactory(function() {
return new wheels.storage.StorageManager(config = get("storage"));
}).asSingleton();
</cfscript>

StorageManager.disk() returns the default disk when you pass no name, and throws Wheels.Storage.UnknownDisk for a name you didn’t configure:

resolving a disk
local.uploads = service("storage").disk(); // "local" (the default)
local.assets = service("storage").disk("s3"); // "s3"

If you aren’t using the container, instantiate the manager directly — new wheels.storage.StorageManager(config = get("storage")) — and keep it in a scope you control.

Every disk exposes the same six methods. Keys are forward-slash-delimited path strings; put() creates intermediate directories for you.

basic put/get/exists/delete
local.disk = service("storage").disk();
local.disk.put("avatars/42.png", local.binary, contentType = "image/png", visibility = "public");
// -> "avatars/42.png"
local.content = local.disk.get("avatars/42.png"); // binary
local.has = local.disk.exists("avatars/42.png"); // true
local.disk.delete("avatars/42.png"); // true
MethodReturnsNotes
put(key, content, contentType, visibility)the keyBinary or string content; visibility is "public" or "private".
get(key)binaryThrows Wheels.Storage.NotFound when absent.
exists(key)booleanLocalDisk checks the filesystem; S3Disk issues a HEAD.
delete(key)booleanfalse when the object was already gone.
url(key)stringPublic URL (local prefix, or the S3 object URL).
signedUrl(key, expiresIn, contentDisposition)stringExpiring URL; see below.

get() and put() round-trip bytes exactly — LocalDisk.put() writes binary, never a string, so a PNG comes back identical to what went in.

The local driver stores objects under a configured root and exposes them through urlPrefix, which is whatever path your app already serves statically. Its config:

KeyRequiredDefaultMeaning
rootyes—Directory objects are written under.
urlPrefixno""Public URL path the app serves the files from.
signingKeyno""HMAC secret for signedUrl(); required only if you call it.
resolveSymlinksnofalseOpt into a stricter containment check that resolves symlinks too (see Symlinks under root).
config/settings.cfm — local disk
<cfscript>
set(storage = {
default = "local",
disks = {
local = {
driver = "local",
root = ExpandPath("../storage/uploads"),
urlPrefix = "/uploads",
signingKey = env("STORAGE_SIGNING_KEY")
}
}
});
</cfscript>

url("avatars/42.png") produces /uploads/avatars/42.png. Because the filesystem has no native presigning, signedUrl() appends an HMAC expires / signature query string over the key and expiry — your download route is expected to verify it with verifySignature() before streaming. This is the same tradeoff Rails’ DiskController and Laravel’s serve make.

LocalDisk treats every key as relative to root and rejects anything that could escape it with Wheels.Storage.InvalidKey: a path-traversal segment (../, ..\), a segment made only of dots and/or whitespace (., .., ..., . ., and — because Windows strips trailing dots and spaces — .. ), and a drive-letter prefix (C:/…, C:foo). Because every key is relative to root, slash runs carry no meaning: a leading /, a trailing /, doubled // and a UNC/network prefix (//server/share, \\server\share) are normalised to a relative key under root, and keys are never URL-decoded, so %2e%2e%2f stays a literal segment. A name that merely contains two dots inside a segment, such as reports/q3..final.pdf or a..b.txt, is a normal name and is allowed. The containment check is lexical (it resolves ./, ../ and // as text), so it behaves identically on every engine, including the JVM-free RustCFML.

Section titled “Opt in to symlink-resolving containment (resolveSymlinks)”

For a hardened deployment — for example, root on a shared volume where a symlink escape is part of your threat model — set resolveSymlinks = true on the disk. On top of the lexical check, $resolve() then canonicalises each path through the filesystem and compares the resolved paths exactly, so a symlink under root that targets outside it is rejected with Wheels.Storage.InvalidKey instead of followed. The lexical check from the default mode stays on underneath; this only adds a stronger layer.

config/settings.cfm — strict containment
<cfscript>
set(storage = {
default = "local",
disks = {
local = {
driver = "local",
root = ExpandPath("../storage/uploads"),
urlPrefix = "/uploads",
resolveSymlinks = true
}
}
});
</cfscript>

The S3 driver speaks to S3 (and S3-compatible endpoints) over plain cfhttp, signing each request with a from-scratch SigV4 implementation in wheels.storage.S3Signer — no AWS SDK, no bundled JARs. Its config:

KeyRequiredDefaultMeaning
bucketyes—Bucket name.
regionyes—AWS region, e.g. us-east-1.
accessKeyIdyes—AWS access key.
secretAccessKeyyes—AWS secret key.
visibilityno"private"Default ACL for put() (public → public-read).
endpointno""S3-compatible endpoint (MinIO, R2, etc.).
usePathStylenofalseForce path-style addressing for non-AWS endpoints.
timeoutno60HTTP timeout in seconds.
putting an object to S3
service("storage").disk("s3").put(
key = "reports/q3.pdf",
content = local.pdfBytes,
contentType = "application/pdf",
visibility = "private"
);

url() returns the object’s public URL; signedUrl() returns a presigned, expiring GET URL computed locally by S3Signer — no round-trip to AWS required:

presigned download URL
local.url = service("storage").disk("s3").signedUrl(
key = "reports/q3.pdf",
expiresIn = 900,
contentDisposition = "attachment; filename=q3.pdf"
);

Both signedUrl() signatures match: signedUrl(key, expiresIn = 300, contentDisposition = ""). expiresIn must be within 1..604800 (one second to seven days) or the call throws Wheels.Storage.InvalidExpiresIn. On S3 the URL is a real AWS presigned GET; on local it’s a URL with an HMAC token your app verifies. Object keys are RFC3986-encoded on both drivers, so spaces and reserved characters behave identically.

Storage errors are typed so you can rescue them precisely:

ErrorRaised when
Wheels.Storage.UnknownDiskdisk("nope") names a disk that isn’t configured.
Wheels.Storage.UnknownDriverA disk’s driver is neither local nor s3.
Wheels.Storage.InvalidConfigurationA disk omits a required config key (e.g. root, bucket).
Wheels.Storage.NotFoundget() on a missing key.
Wheels.Storage.InvalidKeyA LocalDisk key that is empty/slash-only, uses path traversal (../), has a dot/space-only segment, or is absolute or drive-letter-prefixed (see Key validation and path safety).
Wheels.Storage.InvalidExpiresInexpiresIn outside 1..604800.
Wheels.Storage.MissingSigningKeysignedUrl() on a local disk with no signingKey.
Wheels.Storage.RequestFailedAn S3 request fails in a way that isn’t “the object is absent”.

exists() and delete() deliberately distinguish “object absent” (false) from “the backend didn’t answer” (a thrown error) — a connection failure must not read as “no file here.”