Handling avatar uploads in Flask with Encore, boto3 and S3, Google Cloud Storage or files on local disk, with size limits, content type checks and short-lived signed URLs.
Flask handles uploads through request.files, but where the bytes go next is up to you. Local disk breaks once there are two containers, and a bucket brings credentials, CORS, signed URLs and a separate setup for development.
This guide stores user avatars from a Flask app with Encore, boto3 and S3, Google Cloud Storage and local disk. Each version limits uploads to 5 MB, accepts only images and keeps the files private.
What you need to decide:
Encore combines buckets declared in your Flask 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 added to an existing app, and the routes and forms stay as they are.

The bucket is one line, and MAX_CONTENT_LENGTH makes Flask reject bodies over 5 MB before the view runs:
import uuid
from encore.storage import objects
from flask import Flask, abort, request
# S3 on AWS, Cloud Storage on GCP, a local emulator under `encore run`.
avatars = objects.Bucket("avatars")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # Reject uploads over 5 MB.
ALLOWED = {"image/png", "image/jpeg", "image/webp"}
The upload route checks the type, generates the key on the server and stores the content type with the object:
@app.post("/avatars")
async def upload_avatar():
file = request.files.get("file")
if file is None or file.mimetype not in ALLOWED:
abort(400)
key = f"{uuid.uuid4()}"
await avatars.upload(key, file.read(), content_type=file.mimetype)
return {"key": key}, 201
Downloads return a link that expires after five minutes, so the bucket stays private. For large files, the third route hands the browser a signed upload URL so the bytes skip Flask entirely:
@app.get("/avatars/<key>/url")
async def avatar_url(key: str):
if not await avatars.exists(key):
abort(404)
# A short-lived link, so the bucket itself stays private.
return {"url": await avatars.signed_download_url(key, ttl=300)}
@app.post("/avatars/upload-url")
async def direct_upload_url():
key = f"{uuid.uuid4()}"
# The browser PUTs the file straight to the bucket with this URL.
return {"key": key, "url": await avatars.signed_upload_url(key, ttl=600)}
Under encore run, an upload returns a key, the key returns a signed URL, and an unknown key returns 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 are stored in the app. Each upload and signing call appears in the request trace.
For an agent, a new bucket is one line it can test against the local emulator, and the deploy creates the real bucket and provisions an IAM role for the service.
The browser-reported mimetype can be spoofed, so check the file's first bytes or process images with a library before serving them to other users. Generate keys on the server, as here, rather than trusting filename; if you keep the original name for display, pass it through Werkzeug's secure_filename.
On AWS without Encore, you create the bucket and an IAM policy, then use boto3:
import uuid
import boto3
from flask import Flask, abort, request
app = Flask(__name__)
s3 = boto3.client("s3")
BUCKET = "my-app-avatars"
@app.post("/avatars")
def upload_avatar():
file = request.files.get("file")
if file is None or file.mimetype not in {"image/png", "image/jpeg"}:
abort(400)
key = str(uuid.uuid4())
s3.upload_fileobj(file, BUCKET, key, ExtraArgs={"ContentType": file.mimetype})
url = s3.generate_presigned_url(
"get_object", Params={"Bucket": BUCKET, "Key": key}, ExpiresIn=300
)
return {"key": key, "url": url}, 201
The bucket, its public access block, CORS for browser uploads and the role that lets the app call s3:PutObject and s3:GetObject are all set up outside the app. Locally you use a real development bucket or an S3 emulator with its own endpoint setting.
The google-cloud-storage client follows the same shape on GCP:
from datetime import timedelta
from google.cloud import storage
bucket = storage.Client().bucket("my-app-avatars")
def save_avatar(key: str, data: bytes, content_type: str) -> str:
blob = bucket.blob(key)
blob.upload_from_string(data, content_type=content_type)
return blob.generate_signed_url(version="v4", expiration=timedelta(minutes=5))
V4 signed URLs need credentials that can sign. On Cloud Run that means the service account needs the Service Account Token Creator role on itself, or a key file, which many teams avoid.
Flask's file upload pattern saves to a directory:
import os
import uuid
from flask import Flask, abort, request, send_from_directory
app = Flask(__name__)
UPLOADS = "/var/app/uploads"
@app.post("/avatars")
def upload_avatar():
file = request.files.get("file")
if file is None:
abort(400)
key = f"{uuid.uuid4()}.png"
file.save(os.path.join(UPLOADS, key))
return {"key": key}, 201
@app.get("/avatars/<key>")
def get_avatar(key: str):
return send_from_directory(UPLOADS, key)
This works on one server with a persistent disk and backups. On containers the directory disappears on restart, and with two instances each one sees different files.
Python support in Encore is in beta, and you can sign up for access to try it with your Flask app.
Read the file from request.files, check its type and size, and upload it under a generated key. With Encore, declare avatars = objects.Bucket("avatars") and call await avatars.upload(key, file.read(), content_type=file.mimetype). encore run provides a local bucket, and on deploy the bucket becomes S3 on AWS or Cloud Storage on GCP.
Keep the bucket private and return a short-lived signed URL. With Encore, check await avatars.exists(key) and return await avatars.signed_download_url(key, ttl=300), which gives a link that works for five minutes.
Set app.config["MAX_CONTENT_LENGTH"]. Flask then rejects larger requests with 413 before your view reads the body.
Only for a single server with a persistent disk. Containers lose local files on restart, and several instances each see a different disk, so production apps use a 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.