This guide will go over how to deploy a FastAPI app, add a postgres
database, and attach a SSL certificate (assuming you have purchased a custom domain).
This tutorial will be using pipx to install the Elastic Beanstalk CLI. Follow the instructions here to install AWS's CLI if you do not already have that installed. Then configure it following this guide.
Assuming that you are using poetry
to manage your dependencies, run the following commands in your terminal:
$ poetry new deployment-example
$ cd deployment-example
$ poetry add fastapi "uvicorn[standard]" tortoise-orm pydantic
Our folder structure will look something like this:
.
├── README.rst
├── deployment_example
│ └── __init__.py
├── poetry.lock
├── pyproject.toml
└── tests
├── __init__.py
└── test_deployment_example.py
Note that we are using tortoise-orm
as our ORM here, but feel free to use anything else (e.g., sqlmodel
, sqlalchemy
, etc...). Notice that we installed the standard version of uvicorn
that will help us later on during deployment. Create a main.py file inside of deployment_example
to start coding our API:
from typing import Any, Optional
from fastapi import Body, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import (
AnyHttpUrl,
BaseSettings as PydanticBaseSettings,
Field,
PostgresDsn,
ValidationError,
)
from tortoise import fields, models
from tortoise.contrib.fastapi import register_tortoise
from tortoise.contrib.pydantic import pydantic_model_creator
from tortoise.exceptions import DoesNotExist
class User(models.Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=30)
UserIn = pydantic_model_creator(User, exclude_readonly=True)
UserOut = pydantic_model_creator(User)
class BaseSettings(PydanticBaseSettings):
class Config:
case_sensitive = True
env_file = ".env"
class RdsSettings(BaseSettings):
# These environment variables will be available when deploying our
# elastic beanstalk
username: str = Field(..., env="RDS_USERNAME")
password: str = Field(..., env="RDS_PASSWORD")
host: str = Field(..., env="RDS_HOSTNAME")
port: str = Field(..., env="RDS_PORT")
name: str = Field(..., env="RDS_DB_NAME")
@property
def database_uri(self) -> str:
username, password = self.username, self.password
host, port = self.host, self.port
name = self.name
return f"postgres://{username}:{password}@{host}:{port}/{name}"
class Settings(BaseSettings):
allow_origins: tuple[AnyHttpUrl, ...] = Field((), env="ALLOW_CORS_ORIGINS")
debug: bool = Field(False, env=["FASTAPI_DEBUG", "DEBUG"])
database_uri: Optional[PostgresDsn] = Field(..., env="DATABASE_URI")
@property
def orm_config(self) -> dict[str, Any]:
return dict(
connections=dict(default=self.database_uri),
apps=dict(models=dict(models=self.models)),
)
try:
rds_settings = RdsSettings()
except ValidationError:
settings = Settings()
else:
settings = Settings(database_uri=rds_settings.database_uri)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allow_origins,
allow_credentials=True,
allow_methods="*",
allow_headers="*",
)
register_tortoise(
app,
config=settings.orm_config,
generate_schemas=settings.debug,
add_exception_handlers=settings.debug,
)
@app.post("/", response_model=UserOut)
async def create_user(user: UserIn) -> UserOut:
user_db = await User.create(**user.dict())
return await UserOut.from_tortoise_orm(user_db)
@app.get("/", response_model=list[UserOut])
async def get_users() -> list[UserOut]:
return await UserOut.from_queryset(User.all())
@app.put("/{user_id}", response_model=UserOut)
async def update_user(
user_id: int, name: str = Body(..., embed=True)
) -> UserOut:
try:
user_db = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(detail="Could not find user", status=404)
user_db.name = name
await user_db.save()
return await UserOut.from_tortoise_orm(user_db)
@app.get("/{user_id}", response_model=UserOut)
async def get_user(user_id: int) -> UserOut:
try:
return await UserOut.from_queryset_single(User.get(id=user_id))
except DoesNotExist:
raise HTTPException(detail="Could not find user", status=404)
Our Dockerfile would look something like this:
FROM python:3.9
WORKDIR /tmp
RUN pip install poetry
COPY ./pyproject.toml ./poetry.lock* /tmp/
RUN poetry export --without-hashes -f requirements.txt -o requirements.txt
FROM python:3.9
WORKDIR /code
COPY --from=0 /tmp/requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY . /code
EXPOSE 80
CMD ["uvicorn", "deployment_example.main:app", "--host", "0.0.0.0", "--port", "80"]
Note that with the platform
we will be using below named stages are not supported at the time of writing, which is why we have --from=0
and no named stages. We will also need a Procfile
to tell AWS where our ASGI is located, which will contain the following line of code: web: uvicorn --host 0.0.0.0 --port 80 deployment_example.main:app
. After everything is done, our app structure will look something like this:
.
├── Dockerfile
├── Procfile
├── README.rst
├── deployment_example
│ ├── __init__.py
│ └── main.py
├── poetry.lock
├── pyproject.toml
└── tests
├── __init__.py
└── test_deployment_example.py
To install the command line, it is pretty straightforward using pipx
, just run the following command and pipx
will take care of the rest:
$ pipx install awsebcli
Installing...
pipx
is not an option and you are in a hurry you could use plain pip
and run the following command:
$ pip install -U --user awsebcli
Installing...
Read the issue with installing it that way here: https://github.com/aws/aws-elastic-beanstalk-cli-setup.
Set up some variables here in your bash shell to ease subsequent commands:
$ profile='default'
$ platform='Docker running on 64bit Amazon Linux 2'
$ region='ap-south-1'
$ secret_key=$(eval openssl rand -hex 32)
$ db_password=$(eval openssl rand -hex 12)
$ echo "DATABASE PASSWORD: $db_password"
DATABASE PASSWORD: XXXXXXXXXXXXXXXXXXXXXXXX
Make sure to copy the database password and keep it safe. Also, note again that we are using a Docker environment, this will make sure our environment runs locally and on production in a reproducible manner. Initialize the beanstalk by running the following command:
$ eb init --profile $profile -r $region -p "$platform" <Application-Name>
Initializing...
Make sure to replace <Application-Name>
with your actual application name. You could re-run the command above safely later on by adding an -i
flag. To create the beanstalk environment run the following command:
$ eb create \
--profile $profile \
-r $region \
-c <CNAME> \
-ix 1 \
--database.engine postgres \
--database.username postgres \
--database.password $db_password \
--database.instance db.t3.micro \
<environment-name>
Creating...
Let us breakdown the command above. The -r
and --profile
options are for using the correct region and profile, set initially. The -c
flag is for using a custom subdomain when creating this environment, so you will end up with a beanstalk URL that looks something like this: http://<CNAME>.<REGION>.elasticbeanstalk.com
, instead of random characters in the <CNAME>
section. The -ix 1
option will limit the load balancer to a single instance. We will need a load balancer to simplify setting up SSL with our custom domain. Then we have the database options to customize our database setup. Make sure to replace <environment-name>
with your actual environment name. If you have a lot of environment variables it might be easier to set them up in the command line than using the console. For example, that would look something like this:
$ eb setenv \
foo=bar \
spam=egg \
secret=secure
Setting environment variables...
Make sure to set up your custom domain in Amazon Route 53. If the domain was purchased in Amazon Route 53 then skip the following section.
Go to Amazon Route 53's console in your region, and click on Create hosted zone:
Then proceed to add your domain name (e.g., example.com), and create the hosted zone. Afterwards, you should two records already created. One of them will be SOA
and the other will be NS
(a.k.a. nameserver). Copy the values from the nameserver record, and go to your registrar (e.g., GoDaddy, BlueHost, etc...), and paste those nameserver values one-by-one. You might receive a warning that changing your nameserver will cause some down time. If this domain is being used already on production, you want to be very careful about migrating. Otherwise if that is not the case and this is a newly purchased domain then there is nothing to worry about.
Follow the steps below to have your custom domain serve your beanstalk app:
- Inside the hosted zone, create a record.
- Add a subdomain if you would like.
- Select Simple Routing Policy as the routing policy.
- Select Routes traffic to an IPv4 address... as the type.
- Toggle on Alias.
- Choose the endpoint as an Alias to Elastic Environment.
- Select your beanstalk's region.
- Select your elastic beanstalk environment.
Go to AWS Certificate Manager and Request
a certificate:
- After creating the request, go to the certificate.
- Under the domains table click on Create records in Route 53, and wait for the validation to complete.
- Go to your Elastic Beanstalk Environment.
- Go to the Configurations page.
- Click on Edit on the Load Balancer section.
- Add a Listener.
- Make sure the Listener Port is 443, the Listener Protocol is HTTPS, the Instance Port is 80, and the Instance Protocol is 80 (notice that the instance and listener protocols are different).
- Add the certificate you just created.
- Click on Apply on the bottom of the page once done.