Storing files from a Rust Axum service in S3 or Google Cloud Storage with Encore, the AWS SDK for Rust or the object_store crate, including presigned URLs, local buckets, tests and production.
Axum gives you the Bytes and Multipart extractors for reading uploads, and storing the files is up to you. Files belong in object storage rather than on the container's disk, so adding uploads means choosing a client crate, passing it to handlers, creating buckets per environment with the right permissions and having something to test against locally. This guide covers Encore, the AWS SDK for Rust and object_store.
The 2024 State of Rust Survey found Rust especially popular for server backends, web and networking services and cloud technologies.
What you need to decide:
Encore combines the bucket your handlers use with the storage behind it, locally and in your cloud, through a static declaration in the Axum crate you already have. Handlers call the bucket directly, so there is no client to build at startup and nothing to thread through State, and routing stays in Axum and the infrastructure SDK covers the bucket.
use axum::{body::Bytes, extract::Path, http::StatusCode, routing::{post, put}, Router};
use encore::objects::Bucket;
// Provisioned locally by encore run and in your cloud on deploy.
static AVATARS: Bucket = Bucket::new("avatars");
async fn upload(Path(name): Path<String>, body: Bytes) -> (StatusCode, String) {
match AVATARS.upload(&name, body.to_vec()).await {
Ok(attrs) => (StatusCode::CREATED, format!("stored {name} ({} bytes)\n", attrs.size)),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("upload failed: {e}\n")),
}
}
async fn download(Path(name): Path<String>) -> Result<Vec<u8>, StatusCode> {
AVATARS.download(&name).await.map_err(|_| StatusCode::NOT_FOUND)
}
async fn remove(Path(name): Path<String>) -> StatusCode {
match AVATARS.remove(&name).await {
Ok(()) => StatusCode::NO_CONTENT,
Err(_) => StatusCode::NOT_FOUND,
}
}
// The browser uploads large files straight to the bucket with this URL.
async fn upload_url(Path(name): Path<String>) -> Result<String, StatusCode> {
AVATARS
.signed_upload_url(&name)
.await
.map(|signed| signed.url)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new()
.route("/avatars/:name", put(upload).get(download).delete(remove))
.route("/avatars/:name/upload-url", post(upload_url));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
std::process::exit(encore::shutdown::drained().await.code());
}
/avatars/{name}.Bucket::new("assets").public() declares a bucket for public files, with public_url returning an object's URL, and .versioned() keeps earlier versions. For large files the bucket also has upload_stream and download_stream, so a handler does not have to hold the whole file in memory.
encore run serves the declared buckets locally, and under encore test the Encore Axum example gives each test an empty bucket of its own. On deploy, Encore provisions the infrastructure, including the bucket, in the account you connect to Encore. An agent adding an upload feature declares the bucket and writes the handler together and runs the tests locally, and cloud access sits with Encore's IAM role, not the agent.
Without Encore you load AWS config at startup, build a client, store it in State and create the bucket, its IAM policy and its CORS rules for each environment, which the next section shows.
The AWS SDK for Rust is the official client, which is cheap to clone and lives in Axum State:
use std::time::Duration;
use aws_sdk_s3::{presigning::PresigningConfig, primitives::ByteStream, Client};
use axum::{body::Bytes, extract::{Path, State}, http::StatusCode, routing::{post, put}, Router};
#[derive(Clone)]
struct AppState {
s3: Client,
bucket: String,
}
async fn upload(
State(st): State<AppState>,
Path(name): Path<String>,
body: Bytes,
) -> StatusCode {
let res = st.s3.put_object()
.bucket(&st.bucket)
.key(&name)
.body(ByteStream::from(body))
.send()
.await;
if res.is_ok() { StatusCode::CREATED } else { StatusCode::INTERNAL_SERVER_ERROR }
}
async fn upload_url(State(st): State<AppState>, Path(name): Path<String>) -> Result<String, StatusCode> {
let cfg = PresigningConfig::expires_in(Duration::from_secs(600))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let req = st.s3.put_object().bucket(&st.bucket).key(&name)
.presigned(cfg).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(req.uri().to_string())
}
#[tokio::main]
async fn main() {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let state = AppState {
s3: Client::new(&config),
bucket: std::env::var("AVATAR_BUCKET").unwrap(),
};
let app = Router::new()
.route("/avatars/:name", put(upload))
.route("/avatars/:name/upload-url", post(upload_url))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Axum limits request bodies to 2 MB by default, so larger uploads need a raised limit or, more often, a presigned URL that sends the bytes straight to S3. A single PUT to S3 can carry up to 5 GB, and larger objects use multipart uploads.
The object_store crate, from the Apache Arrow project, puts one async trait over S3, Google Cloud Storage, Azure, the local filesystem and memory:
use std::sync::Arc;
use object_store::{aws::AmazonS3Builder, path::Path, ObjectStore, PutPayload};
async fn store() -> Result<(), object_store::Error> {
let s3: Arc<dyn ObjectStore> = Arc::new(
AmazonS3Builder::from_env().with_bucket_name("avatars").build()?,
);
s3.put(&Path::from("42.png"), PutPayload::from_static(b"...")).await?;
Ok(())
}
Tests can swap in InMemory behind the same trait, which makes it easy to test without a server, although buckets and permissions in production are still created separately.
New S3 buckets block public access by default, which keeps private files private until you add a policy. S3 also provides strong read-after-write consistency, so a download right after an upload returns the new object.
Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.
Read the body with the Bytes extractor or axum's Multipart extractor, then write it with a client held in Axum State. With aws-sdk-s3 that is client.put_object().bucket(...).key(...).body(...).send(). With Encore you declare a static Bucket and call BUCKET.upload(&key, data).await in the handler, with no client to construct.
With aws-sdk-s3, build the put_object or get_object request and call presigned with a PresigningConfig that sets the expiry. With Encore, call signed_upload_url or signed_download_url on the bucket.
aws-sdk-s3 exposes every S3 feature and is the official SDK. object_store gives one async API over S3, Google Cloud Storage, Azure and the local filesystem, which helps when the same code must run against several backends.
Run an S3-compatible server locally and point the client at it, or use object_store's in-memory or local filesystem backend in tests. With Encore, encore run and encore test give each declared bucket a local backend, and each test can start with an empty bucket.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.