Oct 27, 20266 min read

How to Add Object Storage to an Actix Web App

Storing user uploads from an Actix Web 4 app in S3 or Cloud Storage with Encore, the AWS SDK for Rust, the object_store crate or local disk, with size limits and signed URLs.

Actix Web handles request bodies well, but uploads need somewhere to live after the handler returns. A bucket means credentials, a bucket policy, presigned URLs and something that stands in for S3 on a developer laptop.

This guide stores avatar uploads from an Actix Web 4 app with Encore, the AWS SDK for Rust, the object_store crate and local disk. Each version caps uploads at 5 MB, accepts only images and serves them through short-lived links.

What you need to decide:

  • Whether bytes pass through the app or go straight to the bucket.
  • How keys are generated so users cannot overwrite each other.
  • How private files are served.
  • What replaces the bucket during development.

Object storage options for Actix Web at a glance

CapabilityEncoreAWS SDK for Rustobject_storeLocal disk
Bucket defined inRust staticConsole or IaCConsole or IaCA directory
CloudsS3 or Cloud StorageS3S3, GCS, Azure, localOne server
Signed URLssigned_download_url_withpresigned(...)signed_url on supported storesServed by the app
CredentialsIAM role provisioned for the serviceYour IAM setupYour IAM setupNone
Local developmentencore run provides a bucketReal bucket or an emulatorLocal filesystem storeWorks

Encore: object storage for your existing Actix Web app

Encore

Encore combines buckets declared in your Actix Web code with the storage behind them, providing a local bucket under encore run and provisioning S3 on AWS or Cloud Storage on GCP in your own account on deploy. It is an infrastructure SDK, the encore-dev crate, added to an existing app, so routes and extractors stay plain Actix Web.

The bucket is a static. The upload handler checks the content type, generates a key and stores the type with the object:

use actix_web::{post, web, HttpRequest, HttpResponse, Responder}; use encore::objects::{Bucket, UploadOptions}; // S3 on AWS, Cloud Storage on GCP, a local emulator under `encore run`. static AVATARS: Bucket = Bucket::new("avatars"); const ALLOWED: [&str; 3] = ["image/png", "image/jpeg", "image/webp"]; #[post("/avatars")] async fn upload(req: HttpRequest, body: web::Bytes) -> impl Responder { let content_type = req .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_string(); if !ALLOWED.contains(&content_type.as_str()) { return HttpResponse::BadRequest().body("unsupported image type\n"); } let key = uuid::Uuid::new_v4().to_string(); let options = UploadOptions { content_type: Some(content_type), ..Default::default() }; match AVATARS.upload_with(&key, body.to_vec(), options).await { Ok(_) => HttpResponse::Created().json(serde_json::json!({ "key": key })), Err(_) => HttpResponse::InternalServerError().finish(), } }

The download handler returns 404 for unknown keys and otherwise a link that works for five minutes:

use std::time::Duration; use actix_web::get; use encore::objects::SignedUrlOptions; #[get("/avatars/{key}/url")] async fn download_url(path: web::Path<String>) -> impl Responder { let key = path.into_inner(); match AVATARS.exists(&key).await { Ok(true) => {} Ok(false) => return HttpResponse::NotFound().finish(), Err(_) => return HttpResponse::InternalServerError().finish(), } // A five-minute link, so the bucket itself stays private. let options = SignedUrlOptions { ttl: Some(Duration::from_secs(300)) }; match AVATARS.signed_download_url_with(&key, options).await { Ok(signed) => HttpResponse::Ok().json(serde_json::json!({ "url": signed.url })), Err(_) => HttpResponse::InternalServerError().finish(), } }

PayloadConfig rejects bodies over 5 MB before the handler runs:

use actix_web::{App, HttpServer}; #[actix_web::main] async fn main() -> std::io::Result<()> { encore::initialize(); let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into()); HttpServer::new(|| { App::new() // Reject request bodies over 5 MB before they reach the handler. .app_data(web::PayloadConfig::new(5 * 1024 * 1024)) .service(upload) .service(download_url) }) .bind(addr)? .run() .await }

Under encore run, an image upload returns a key, a text file gets a 400 and an unknown key gets a 404:

~/profiles
$ encore run$ curl -X POST -H 'Content-Type: image/png' --data-binary @me.png localhost:4000/avatars{"key":"9a7c..."}$ curl -X POST -H 'Content-Type: text/plain' -d hi localhost:4000/avatarsunsupported image type

On deploy, Encore creates the bucket on AWS or GCP in your own account and provisions an IAM role for the service, so no access keys live in the app. Uploads and signing calls appear in each request's trace.

An agent adding an export feature can declare another bucket, test uploads against the local emulator and leave the S3 bucket and permissions to the deploy.

Good to know

The Content-Type header comes from the client, so sniff the first bytes or decode the image before showing it to other users. web::Bytes buffers the whole body, which is fine at 5 MB; for larger files, hand the client a signed upload URL so the bytes go straight to the bucket.

Try Encore →

AWS SDK for Rust

On AWS without Encore, aws-sdk-s3 uploads the bytes and presigns a GET:

use std::time::Duration; use aws_sdk_s3::{presigning::PresigningConfig, primitives::ByteStream, Client}; async fn store(client: &Client, key: &str, body: Vec<u8>, ct: &str) -> anyhow::Result<String> { client .put_object() .bucket("my-app-avatars") .key(key) .content_type(ct) .body(ByteStream::from(body)) .send() .await?; let presigned = client .get_object() .bucket("my-app-avatars") .key(key) .presigned(PresigningConfig::expires_in(Duration::from_secs(300))?) .await?; Ok(presigned.uri().to_string()) }

Good to know

The bucket, its public access block and the task role with s3:PutObject and s3:GetObject are set up outside the app. Local development needs a real bucket or an emulator with a custom endpoint.

object_store

The object_store crate from the Apache Arrow project wraps S3, Cloud Storage, Azure and the local filesystem behind one trait:

use object_store::{aws::AmazonS3Builder, path::Path, ObjectStore, PutPayload}; async fn store(key: &str, body: Vec<u8>) -> object_store::Result<()> { let s3 = AmazonS3Builder::from_env().with_bucket_name("my-app-avatars").build()?; s3.put(&Path::from(key), PutPayload::from(body)).await?; Ok(()) }

Good to know

Swapping in LocalFileSystem gives a store for development. Credentials and buckets are still yours to create, and signed URLs come from a separate Signer trait on the cloud stores.

Local disk

Writing to a directory with tokio::fs is the simplest option:

async fn store(key: &str, body: &[u8]) -> std::io::Result<()> { tokio::fs::write(format!("/var/app/uploads/{key}"), body).await }

Good to know

Files disappear when a container restarts, and two instances each see their own disk. Serving them needs actix-files or another handler, with access checks you write yourself.

How to choose

  • Encore if you want one bucket declaration that runs locally and becomes S3 or Cloud Storage in your own account, with signed URLs and the service's IAM role created on deploy.
  • The AWS SDK for Rust when the bucket and IAM already exist and you only target S3.
  • object_store for code that must run against several clouds or a local filesystem.
  • Local disk for one server you back up yourself.

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

Frequently asked questions

How do I upload files to S3 from Actix Web?

Limit the body size, check the content type, and upload the bytes under a key generated on the server. With Encore, declare static AVATARS: Bucket = Bucket::new("avatars") and call AVATARS.upload_with(&key, body.to_vec(), UploadOptions { content_type: Some(content_type), ..Default::default() }). encore run provides a local bucket, and on deploy it becomes S3 on AWS or Cloud Storage on GCP.

How do I generate a presigned download URL in Rust?

Sign a GET request for the object with an expiry. With Encore, call AVATARS.signed_download_url_with(&key, SignedUrlOptions { ttl: Some(Duration::from_secs(300)) }) and return signed.url. With the AWS SDK, use get_object().presigned(PresigningConfig::expires_in(...)).

How do I limit upload size in Actix Web?

Register web::PayloadConfig::new(limit) with app_data. Requests whose body is larger are rejected before the handler runs. For multipart forms, actix-multipart has its own limits.

Should an Actix Web app keep uploads on local disk?

Only on a single server with a persistent, backed up disk. Containers lose files on restart, and several instances each see a different disk.

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