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.