Oct 27, 20266 min read

How to Add Object Storage to a Flask App

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:

  • Whether uploads pass through Flask or go straight to the bucket.
  • How files are named, so users cannot overwrite each other.
  • How private files are served.
  • What stands in for the bucket during development.

Object storage options for Flask at a glance

CapabilityEncoreboto3 and S3Google Cloud StorageLocal disk
Bucket defined inPython codeAWS console or IaCGCP console or IaCA directory
Private downloadssigned_download_url(key, ttl=)generate_presigned_urlgenerate_signed_urlServed by Flask
Direct browser uploadsigned_upload_urlPresigned PUTSigned PUTNo
Local developmentencore run provides a bucketReal bucket or an emulatorReal bucket or an emulatorWorks
Several instancesShared bucketShared bucketShared bucketBreaks

Encore: object storage for your existing Flask app

Encore

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.

Encore declares infrastructure in Python code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in Python, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

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:

~/profiles
$ encore run$ curl -F file=@me.png localhost:4000/avatars{"key": "5b0f3c1e-..."}$ curl localhost:4000/avatars/5b0f3c1e-.../url{"url": "http://localhost:..."}

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.

Good to know

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.

Try Encore →

boto3 and S3

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

Good to know

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.

Google Cloud Storage

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

Good to know

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.

Local disk

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)

Good to know

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.

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.
  • boto3 and S3 if the bucket and IAM already exist and are managed elsewhere.
  • Google Cloud Storage for an app already on GCP with signing credentials sorted out.
  • Local disk for a single server you back up yourself.

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

Frequently asked questions

How do I upload files to S3 from Flask?

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.

How do I serve private files from Flask?

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.

How do I limit upload size in Flask?

Set app.config["MAX_CONTENT_LENGTH"]. Flask then rejects larger requests with 413 before your view reads the body.

Should Flask store uploads on local disk?

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.

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