Oct 23, 20265 min read

How to Add S3 Object Storage to an Axum API

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:

  • Whether uploads pass through the API or go straight to the bucket with a presigned URL.
  • Which crate talks to the bucket and how handlers reach it.
  • Which buckets are private and which are public.
  • Where buckets come from locally and in tests.

Object storage options for Axum at a glance

CapabilityEncoreaws-sdk-s3object_store
Client setupNone, a static BucketLoad config, build a client, put it in StateBuild a store, put it in State
Presigned URLssigned_upload_url, signed_download_urlpresigned with PresigningConfigsigned_url on stores that support it
BackendsLocal in development, cloud storage on deployS3 and S3-compatibleS3, GCS, Azure, local, in-memory
Local bucketencore run provides oneYou run an S3-compatible serverLocal filesystem or in-memory store
Production bucketCreated in your AWS or GCP accountYou create it and its IAM policyYou create it and its IAM policy

Encore: add buckets to your existing Axum app

Encore

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()); }
The route syntax is for Axum 0.7, and Axum 0.8 uses /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

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.

Try Encore →

aws-sdk-s3

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(); }

Good to know

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.

object_store

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(()) }

Good to know

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.

Buckets in production

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.

How to choose

  • Encore if you want buckets working locally and created in your AWS or GCP account from the Axum code, with no client setup.
  • aws-sdk-s3 for the full S3 feature set when you already manage the buckets.
  • object_store for one API across several storage backends.

Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.

Frequently asked questions

How do I upload files to S3 from an Axum handler?

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.

How do I generate a presigned S3 URL in Rust?

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.

Should I use aws-sdk-s3 or object_store?

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.

How do I test S3 code in Rust without AWS?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders