Serving a FastAPI app with Poetry and Docker
June 27, 2024
Creating the server
The FastAPI-Poetry-Docker stack is my go-to for deploying backend applications. It's great because FastAPI gives you a nice OpenAPI framework out of the box, Poetry makes dependencies easy, and Docker standardizes the deployment process. But deploying to Docker can be surprisingly tricky, especially when you're using Poetry.
First, let's create a FastAPI app using Poetry. The first step is to create our Poetry project:
poetry new server
Next, let's install FastAPI and Uvicorn:
poetry add fastapi 'uvicorn[standard]'
poetry install
Then, let's create a simple FastAPI app in server/server/main.py
:
import fastapi
app = fastapi.FastAPI()
@app.get("/")
def index():
return {"message": "Hello, World!"}
Now, let's run our FastAPI app using Uvicorn:
poetry run uvicorn server.main:app --reload
If you open your browser and navigate to http://localhost:8000
, you should see the message {"message":"Hello, World!"}
.
Creating the Dockerfile
Now we get to the tricky part: creating a Dockerfile that will build and run our app. Let's start by creating a Dockerfile
in the root of our project and selecting an image to build off of:
FROM python:3.11.8-bullseye AS python-base
Next, we'll set a few environment variables and create a working directory:
# Set environment variables for Python, Pip, Poetry and project directories
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1 \
PROJECT_DIR="/code"
# Add Poetry to the PATH
ENV PATH="$POETRY_HOME/bin:$PROJECT_DIR/.venv/bin:$PATH"
With that set up, we can start installing a few dependencies for Python:
FROM python-base AS production
# Install system dependencies
RUN buildDeps="build-essential" \
&& apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
vim \
netcat \
&& apt-get install -y --no-install-recommends $buildDeps \
&& rm -rf /var/lib/apt/lists/*
Now, let's install Poetry. To install our packages, we won't use Poetry directly—instead, we'll export our Poetry dependencies to a requirements.txt
file and install them using uv
. We use uv
because it's much faster than Poetry:
# Set Poetry and uv versions
ENV POETRY_VERSION=1.8.3
ENV UV_VERSION=0.2.17
# Install Poetry - respects $POETRY_VERSION & $POETRY_HOME
RUN curl -sSL https://install.python-poetry.org | python3 - && chmod a+x /opt/poetry/bin/poetry
RUN poetry self add poetry-plugin-export
RUN pip install uv==$UV_VERSION
Now, let's export the dependencies and install them.
# Install package dependencies with uv
COPY poetry.lock pyproject.toml ./
RUN poetry export -f requirements.txt --output requirements.txt
RUN poetry run uv pip install -r requirements.txt
# Set working directory and copy package files
WORKDIR $PROJECT_DIR
COPY server ./server
EXPOSE 8000
We'll leave the entrypoint blank so we can specify it in our docker-compose file instead.
# Set default command to run the application
CMD [""]
Putting it all together, our Dockerfile
should look like this:
FROM python:3.11.8-bullseye AS python-base
# Set environment variables for Python, Pip, Poetry and project directories
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1 \
PROJECT_DIR="/code"
# Add Poetry to the PATH
ENV PATH="$POETRY_HOME/bin:$PROJECT_DIR/.venv/bin:$PATH"
FROM python-base AS production
# Install system dependencies
RUN buildDeps="build-essential" \
&& apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
vim \
netcat \
&& apt-get install -y --no-install-recommends $buildDeps \
&& rm -rf /var/lib/apt/lists/*
# Set Poetry and uv versions
ENV POETRY_VERSION=1.8.3
ENV UV_VERSION=0.2.17
# Install Poetry - respects $POETRY_VERSION & $POETRY_HOME
RUN curl -sSL https://install.python-poetry.org | python3 - && chmod a+x /opt/poetry/bin/poetry
RUN poetry self add poetry-plugin-export
RUN pip install uv==$UV_VERSION
# Install package dependencies with uv
COPY poetry.lock pyproject.toml ./
RUN poetry export -f requirements.txt --output requirements.txt
RUN poetry run uv pip install -r requirements.txt
# Set working directory and copy package files
WORKDIR $PROJECT_DIR
COPY server ./server
EXPOSE 8000
# Set default command to run the application
CMD [""]
Building the image
To build our Docker image, we can run:
docker build . \
--network host \
-t server
The --network host
flag is important because it allows the Docker container to access the host's network and download dependencies. The -t server
flag tags our image with the name server
.
Running the image
Now let's create a docker-compose file to run our app:
version: "3.8"
services:
server:
network_mode: host
container_name: server
command: poetry run uvicorn server.main:app --port 8000 --host 0.0.0.0
build:
context: .
dockerfile: Dockerfile
network: host
image: server
restart: always
init: true
To run our app, we can use:
docker compose up
Or, if you prefer to run it in the background:
docker compose up -d
If you open your browser again and navigate to http://localhost:8000
, you should see the message {"message":"Hello, World!"}
.
Bonus: Setting up SSL with Cloudflare
To set up SSL, we can use Cloudflare's origin certificates. First, we need to generate a certificate. We can do this by navigating to the SSL/TLS tab in Cloudflare under a domain and clicking on "Origin Server." Clicking the "Create Certificate" button will generate a certificate that we can use to secure our server.
That will give us a certificate and a private key. We can download these into cert.pem
and key.pem
, respectively, under the /server
directory.
We'll want to rebuild our Dockerfile to include these certificates. We'll run the build command again:
docker build . \
--network host \
-t server
Next, we'll modify our entrypoint to serve our app on port 443 (HTTPS) and use the certificates:
version: "3.8"
services:
server:
network_mode: host
container_name: server
command: poetry run uvicorn server.main:app --port 443 --host 0.0.0.0 --ssl-keyfile /code/server/key.pem --ssl-certfile /code/server/cert.pem
build:
context: .
dockerfile: Dockerfile
network: host
image: server
restart: always
init: true
Now, if you navigate to https://localhost
, you should get an authentication error. You'll want to deploy your app to a server with a domain name and set up Cloudflare to proxy your server. Typically, you'll want to add an A record to your domain pointing to your server's IP address and set up Cloudflare to proxy your server. Once that's done, you should be able to access your server over HTTPS.