File / Object Storage
A single Storage trait for local disk and S3-compatible object storage (AWS S3, Cloudflare R2, MinIO), with no AWS SDK dependency.
FormMultipartData::parse() (see Forms & File Uploads) hands back raw bytes with no place to put them. The storage module gives handlers a single Storage trait so the same upload code works against local disk in development and an S3-compatible bucket in production.
pub trait Storage: Send + Sync {
fn put(&self, key: &str, data: &[u8], content_type: &str) -> Result<String, StorageError>;
fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
fn delete(&self, key: &str) -> Result<(), StorageError>;
fn url(&self, key: &str) -> String;
}url() performs no I/O and never fails — it just formats a string.
Local disk (storage-local feature)
No new dependencies.
[dependencies]
rust-web-server = { version = "18", features = ["storage-local"] }
use rust_web_server::storage::{LocalStorage, Storage};
let store = LocalStorage::new("/var/data/uploads");
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;Keys are relative paths under the root directory; parent directories (e.g. avatars/) are created automatically. A key containing a .. segment is rejected — the same defense used by the config-driven proxy's static-file action.
Content type is not persisted — plain files on disk have no metadata slot for it.
Serving uploaded files back over HTTP
LocalStorage::url() returns the object's filesystem path by default, which isn't directly useful to a browser. If the storage root is also served as a static directory, call .with_base_url() so url() returns an HTTP path instead:
let store = LocalStorage::new("/var/data/uploads").with_base_url("/uploads");
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
assert_eq!("/uploads/avatars/42.png", store.url(&key));Pair this with a Router route (or the config-driven proxy's type = "static" action — see Config-Driven Proxy) that serves /var/data/uploads at the /uploads prefix.
S3-compatible object storage (storage-s3 feature)
Works with AWS S3, Cloudflare R2, MinIO, or any other S3-compatible provider. Signs every request with AWS Signature Version 4 using the existing outbound HTTP client (hmac + sha2) — no AWS SDK dependency.
[dependencies]
rust-web-server = { version = "18", features = ["storage-s3"] }
use rust_web_server::storage::{S3Storage, Storage};
let store = S3Storage::from_env()?;
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;
let public_url = store.url(&key);
# Ok::<(), rust_web_server::storage::StorageError>(())Configuration
S3Storage::from_env() reads:
| Variable | Default |
|---|---|
RWS_S3_BUCKET |
(required) |
RWS_S3_REGION |
us-east-1 |
RWS_S3_ACCESS_KEY |
optional — falls back to workload identity (below) when unset |
RWS_S3_SECRET_KEY |
optional — falls back to workload identity (below) when unset |
RWS_S3_ENDPOINT |
https://s3.{region}.amazonaws.com |
Point RWS_S3_ENDPOINT at a custom host to use a non-AWS provider:
# Cloudflare R2
RWS_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# MinIO (local development)
RWS_S3_ENDPOINT=http://localhost:9000Or construct S3Config directly instead of reading the environment:
use rust_web_server::storage::{S3Config, S3Storage};
let store = S3Storage::new(S3Config {
bucket: "my-bucket".to_string(),
region: "us-east-1".to_string(),
access_key: "...".to_string(),
secret_key: "...".to_string(),
endpoint: "https://s3.us-east-1.amazonaws.com".to_string(),
});Workload identity (no static keys)
When RWS_S3_ACCESS_KEY/RWS_S3_SECRET_KEY are unset, S3Storage auto-detects short-lived credentials from the environment instead — no AWS SDK, no static keys to rotate or leak. Detection follows the same precedence AWS's own SDKs use:
| Source | Triggered by | Notes |
|---|---|---|
| EKS IRSA | AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE |
Injected automatically by the EKS pod-identity webhook. Calls STS AssumeRoleWithWebIdentity. |
| ECS task role | AWS_CONTAINER_CREDENTIALS_FULL_URI or _RELATIVE_URI |
Injected automatically by the ECS agent. |
| EC2 IMDSv2 | — (last resort) | Each request uses a short timeout so a non-EC2 host (local dev, CI, GCP, Azure) fails fast instead of hanging. |
Set RWS_S3_CREDENTIAL_SOURCE=static|irsa|ecs|imds to force a specific source and skip detection entirely — useful to guarantee a non-cloud host never even probes the EC2 metadata endpoint.
Credentials are cached in memory and refreshed automatically shortly before they expire — no per-request network overhead once a request has been signed at least once.
# On EKS/ECS/EC2, just omit the static keys — no code change needed:
RWS_S3_BUCKET=my-bucket
RWS_S3_REGION=us-east-1Addressing style
S3Storage always uses path-style addressing — {endpoint}/{bucket}/{key} — rather than virtual-hosted-style ({bucket}.{host}/{key}). Path-style works against every S3-compatible provider, including custom endpoints (R2, MinIO) where a wildcard DNS entry for virtual-hosted-style isn't set up.
Every request signs host, x-amz-content-sha256, and x-amz-date — plus x-amz-security-token when using temporary/workload-identity credentials. This covers standard single-object PUT/GET/DELETE — there's no support for presigned URLs, multipart (chunked) uploads, or query-string signing.
Azure Blob Storage (storage-azure feature)
Signs every request with the Shared Key HMAC-SHA256 scheme using the existing outbound HTTP client (hmac + sha2) — no Azure SDK dependency.
[dependencies]
rust-web-server = { version = "18", features = ["storage-azure"] }
use rust_web_server::storage::{AzureBlobStorage, Storage};
let store = AzureBlobStorage::from_env()?;
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;
let public_url = store.url(&key);
# Ok::<(), rust_web_server::storage::StorageError>(())Configuration
AzureBlobStorage::from_env() reads:
| Variable | Default |
|---|---|
RWS_AZURE_ACCOUNT |
(required) |
RWS_AZURE_CONTAINER |
(required) |
RWS_AZURE_ACCOUNT_KEY |
optional — falls back to Managed Identity (below) when unset |
RWS_AZURE_ENDPOINT |
https://{account}.blob.core.windows.net |
Point RWS_AZURE_ENDPOINT at a custom host to use the Azurite local emulator or a private endpoint:
RWS_AZURE_ENDPOINT=http://127.0.0.1:10000/devstoreaccount1Or construct AzureBlobConfig directly instead of reading the environment:
use rust_web_server::storage::{AzureBlobConfig, AzureBlobStorage};
let store = AzureBlobStorage::new(AzureBlobConfig {
account: "myaccount".to_string(),
container: "my-container".to_string(),
account_key: "...".to_string(),
endpoint: "https://myaccount.blob.core.windows.net".to_string(),
});Workload identity (no static keys)
When RWS_AZURE_ACCOUNT_KEY is unset, AzureBlobStorage auto-detects a Managed Identity OAuth token instead — no Azure SDK, no static account key to rotate or leak:
| Source | Triggered by | Notes |
|---|---|---|
| App Service / Container Apps | IDENTITY_ENDPOINT + IDENTITY_HEADER |
Injected automatically by the platform. |
| VM / AKS IMDS | — (last resort) | Each request uses a short timeout so a non-Azure host (local dev, CI, AWS, GCP) fails fast instead of hanging. |
Set RWS_AZURE_CREDENTIAL_SOURCE=key|managed-identity to force a specific source and skip detection entirely. Tokens are cached in memory and refreshed automatically shortly before they expire.
# On AKS, a VM, App Service, or Container Apps, just omit the account key —
# no code change needed:
RWS_AZURE_ACCOUNT=myaccount
RWS_AZURE_CONTAINER=my-containerShared Key requests sign every x-ms-* header actually sent — x-ms-date, x-ms-version, and x-ms-blob-type on PUT — plus the standard Content-Type/Content-Length slots. Managed Identity requests skip signing entirely and use Authorization: Bearer {token} instead. This covers standard single-blob PUT/GET/DELETE — there's no support for SAS tokens, blob listing, or multipart (block list) uploads.
Large objects: put_stream
put_stream(key, reader, content_type) stores whatever a std::io::Read
yields, without holding it all in memory — pair it with a
streamed upload,
whose &request.body is a reader (since 18.0.0):
use rust_web_server::storage::{S3Storage, Storage};
let store = S3Storage::from_env()?;
// In a route registered with .stream_body("PUT", "/upload/:name"):
store.put_stream("uploads/video.mp4", &mut &request.body, "video/mp4")?;S3Storageuses an S3 multipart upload: 8 MiB parts (S3_PART_SIZE) read and sent one at a time, so memory stays at one part; objects up to ~78 GiB (S3's 10 000-part limit). Anything that fits in one part is a plainPUT. A failure aborts the upload, so no orphaned parts are left behind. Tested end to end with a 2 GiB HTTP upload against an S3-compatible server.LocalStoragewrites to a temporary file beside the target as it reads and renames it into place, so a failed upload never replaces the old file.AzureBlobStorageand custom backends use the default: read everything, thenput.
Writing handler code against Storage
Depend on the trait, not a concrete type, so the same function works with any backend:
use rust_web_server::storage::{Storage, StorageError};
fn save_avatar(store: &dyn Storage, user_id: u64, bytes: &[u8]) -> Result<String, StorageError> {
let key = format!("avatars/{user_id}.png");
store.put(&key, bytes, "image/png")
}Wire store in via dependency injection or app state so handlers don't construct a new LocalStorage/S3Storage/AzureBlobStorage per request.