Skip to content

File / Object Storage

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 = "17", 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 = "17", 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:

VariableDefault
RWS_S3_BUCKET(required)
RWS_S3_REGIONus-east-1
RWS_S3_ACCESS_KEYoptional — falls back to workload identity (below) when unset
RWS_S3_SECRET_KEYoptional — falls back to workload identity (below) when unset
RWS_S3_ENDPOINThttps://s3.{region}.amazonaws.com

Point RWS_S3_ENDPOINT at a custom host to use a non-AWS provider:

Terminal window
# Cloudflare R2
RWS_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# MinIO (local development)
RWS_S3_ENDPOINT=http://localhost:9000

Or 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:

SourceTriggered byNotes
EKS IRSAAWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILEInjected automatically by the EKS pod-identity webhook. Calls STS AssumeRoleWithWebIdentity.
ECS task roleAWS_CONTAINER_CREDENTIALS_FULL_URI or _RELATIVE_URIInjected 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.

Terminal window
# On EKS/ECS/EC2, just omit the static keys — no code change needed:
RWS_S3_BUCKET=my-bucket
RWS_S3_REGION=us-east-1

Addressing 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.

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 = "17", 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:

VariableDefault
RWS_AZURE_ACCOUNT(required)
RWS_AZURE_CONTAINER(required)
RWS_AZURE_ACCOUNT_KEYoptional — falls back to Managed Identity (below) when unset
RWS_AZURE_ENDPOINThttps://{account}.blob.core.windows.net

Point RWS_AZURE_ENDPOINT at a custom host to use the Azurite local emulator or a private endpoint:

Terminal window
RWS_AZURE_ENDPOINT=http://127.0.0.1:10000/devstoreaccount1

Or 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:

SourceTriggered byNotes
App Service / Container AppsIDENTITY_ENDPOINT + IDENTITY_HEADERInjected 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.

Terminal window
# 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-container

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.