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:
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:
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.
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.
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())
}
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.
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(())
}
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.
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
}
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.
Rust support in Encore is in beta, and you can sign up for access to try it with your Actix Web app.
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.
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(...)).
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.
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.