Running Flask with Postgres and S3 on AWS with Encore, Elastic Beanstalk, ECS on Fargate or Zappa on Lambda, with a production WSGI server, a non-root container and the networking each one needs.
Flask runs anywhere Python does, and on AWS that means choosing between Beanstalk, ECS, Lambda and EC2 before adding RDS, S3, IAM roles and a WSGI server. A small app with a database and file uploads touches most of them before its first production request.
This guide deploys a books API with Postgres and S3 cover uploads to AWS. It compares Encore, Elastic Beanstalk, ECS on Fargate and Zappa.
What you need to decide:
Encore combines the database and bucket your Flask app declares with a local environment and a deployment to your own AWS account, so RDS, S3 and the Fargate service all come from the code you run locally, and Encore provisions an IAM role for the service. It is an infrastructure SDK you add to an existing app, and the views stay plain Flask.

The books table is a SQL migration in ./migrations:
-- migrations/1_create_books.up.sql
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL
);
The app declares a Postgres database and a covers bucket at module level. Uploads go to the bucket with upload, and downloads use a presigned URL that expires after five minutes, so the bucket never has to be public:
import os
from encore.storage import objects, postgres
from flask import Flask, abort, request
from waitress import serve
# On AWS: an RDS Postgres database and an S3 bucket in your account.
# Under `encore run`: a local Postgres database and a local bucket.
db = postgres.Database("books", migrations="./migrations")
covers = objects.Bucket("covers")
app = Flask(__name__)
@app.post("/books")
async def create_book():
data = request.get_json()
row = await db.query(
"INSERT INTO books (title, author) VALUES ($1, $2) RETURNING id",
data["title"],
data["author"],
).one()
return {"id": row["id"]}, 201
@app.get("/books/<int:book_id>")
async def get_book(book_id: int):
row = await db.query(
"SELECT id, title, author FROM books WHERE id = $1", book_id
).first()
if row is None:
abort(404)
return {"id": row["id"], "title": row["title"], "author": row["author"]}
@app.put("/books/<int:book_id>/cover")
async def upload_cover(book_id: int):
attrs = await covers.upload(
f"{book_id}.jpg", request.get_data(), content_type="image/jpeg"
)
return {"key": f"{book_id}.jpg", "size": attrs.size}
@app.get("/books/<int:book_id>/cover")
async def cover_url(book_id: int):
if not await covers.exists(f"{book_id}.jpg"):
abort(404)
url = await covers.signed_download_url(f"{book_id}.jpg", ttl=300)
return {"url": url}
@app.get("/healthz")
def healthz():
return {"ok": True}
if __name__ == "__main__":
# Encore's local run sets ENCORE_LISTEN_ADDR to the "host:port" to bind.
addr = os.environ.get("ENCORE_LISTEN_ADDR")
if addr:
host, _, port = addr.rpartition(":")
else:
host, port = "0.0.0.0", os.environ.get("PORT", "8000")
serve(app, host=host or "0.0.0.0", port=int(port))
encore run starts Postgres locally with the migration applied and a local bucket that issues working signed URLs, so the upload flow can be tested end to end before anything exists on AWS:
On deploy, Encore provisions an RDS Postgres instance in private subnets, an S3 bucket and a Fargate service with a load balancer in your AWS account, and provisions an IAM role for the service. The app needs no long-lived keys, since permissions are granted through a role in your account, and cloud access sits with Encore's IAM role, not the agent. Preview environments for pull requests run in Encore's hosting with their own database and bucket, while staging and production live in your account.
The upload route accepts any body, so add authentication and a size limit, for example Flask's MAX_CONTENT_LENGTH, before exposing it. Waitress is used here because it runs on every platform, and Gunicorn works just as well.
Elastic Beanstalk's Python platform looks for a WSGI callable named application in application.py, and the Flask walkthrough deploys it with the EB CLI:
Creating RDS from inside Beanstalk ties the database's lifetime to the environment, so most teams create it separately and pass the URL as an environment property. The instance profile needs an S3 policy for the bucket, and Beanstalk runs Gunicorn for you, configurable through a Procfile.
For containers, the app gets a Dockerfile that runs Gunicorn as a non-root user, and the image is pushed to ECR and run as a Fargate service:
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
RUN useradd --create-home --uid 10001 app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-", "main:app"]
Around the task definition you need a VPC with private subnets, an Application Load Balancer, a target group with a health check on /healthz, a task role for S3, a task execution role for pulling the image and reading secrets, and RDS with a security group that admits the tasks. That is usually written in Terraform or the CDK. The Gunicorn documentation suggests starting at (2 x cores) + 1 workers, which on a 1 vCPU task is three. ECS Express Mode, which AWS launched in 2025 as the successor to App Runner's simple setup, removes some of this for basic web services.
Zappa packages a WSGI app for Lambda behind API Gateway, configured in zappa_settings.json:
{
"production": {
"app_function": "main.app",
"aws_region": "eu-central-1",
"runtime": "python3.13",
"s3_bucket": "books-zappa-deployments",
"environment_variables": { "COVERS_BUCKET": "books-covers-prod" }
}
}
Ordinary Flask views work unchanged, but Lambda limits request payloads to a few megabytes, so large covers should go straight to S3 with presigned upload URLs. Each concurrent request opens its own database connection, and RDS Proxy is the usual fix. Check that the Zappa release you install supports the Python runtime you plan to use.
The Flask documentation's deployment page is clear that the development server is not for production, and on AWS the WSGI server sits behind a load balancer, so enable ProxyFix from Werkzeug to read the client's real IP and scheme. Keep uploads in S3 rather than on the container's disk, which does not survive a deploy. With Encore, the RDS instance, S3 bucket, roles and network are created in your account from the code.
Python support in Encore is in beta, and you can sign up for access to try it with your Flask app.
Elastic Beanstalk takes a zip with application.py and requirements.txt and runs it behind a load balancer, which is quick for a single app. With Encore, declare postgres.Database("books", migrations="./migrations") and objects.Bucket("covers") in the Flask code and connect your AWS account, and Encore creates the Fargate service, RDS database and S3 bucket in that account on each push.
Containers on Fargate suit steady traffic, long requests and apps with connection pools. Lambda through Zappa or a WSGI adapter suits spiky, low traffic, but each concurrent request runs in its own environment and opens its own database connection, which usually calls for RDS Proxy.
Flask's built-in server is for development only. Use a production WSGI server such as Gunicorn or Waitress, and size the worker count to the CPU and memory of the container.
Write them to S3 rather than the container's disk, which is lost on every deploy. Serve them with short-lived presigned URLs so the bucket can stay private.
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.