Deploying a FastAPI app with Docker, Traefik, and Let's Encrypt

Deploying a FastAPI app with Docker, Traefik, and Let's Encrypt

While fitting all the pieces together for configuring Traefik with Docker and Let's Encrypt I ended up with fifteen browser's tab open.

This guide is an attempt to spare you an hour or so. Enjoy!

Requirements

To follow along with this guide you should have:

  • a basic understanding of Docker and Docker Compose.
  • Docker and Docker Compose installed on your local machine.

For deploying in production:

  • Docker and Docker Compose installed on a remote machine (a Digital Ocean or Hetzner VPS will do).
  • a registered domain for requesting SSL certificates in production.
// toc

Scenario

We want to run a Python application built with FastAPI, in Docker. The application is behind a reverse proxy, and uses Let's Encrypt for SSL certificates. Certificate configuration must be automatic.

What is Traefik

Traefik is a cloud-native, modern reverse proxy.

Cloud-native means that Traefik integrates easily, out of the box, with cloud technologies like Docker and Kubernetes.

What's particularly appealing of Traefik is the automatic support for Let's Encrypt certificates, which makes it preferable over other solutions based on Nginx.

What is FastAPI

FastAPI is an asynchronous Python framework for building APIs. It's rising in popularity as a modern alternative to Flask and Django REST framework, especially for performance-intensive, I/O bound applications.

Setting up the development environment

Setting up the project

To start off create a new project folder and initialize a Python virtual environment into it:

mkdir dataset-service && cd $_
python3 -m venv venv
source venv/bin/activate

Once done create two Docker Compose files for later use:

touch docker-compose.yml docker-compose.prod.yml

Make also sure to initialize a Git repo in this folder:

git init

Don't forget to use a .gitignore for Python.

To deploy in production later you should have a remote repo somewhere to pull the code from.

With the skeleton's project in place let's now prepare the FastAPI app.

Preparing the FastAPI app for development

Let's start off with a minimal configuration for FastAPI to run our API in development.

To run our project we need:

  • FastAPI, the API framework
  • Uvicorn, an ASGI server for running FastAPI
  • Gunicorn for running Uvicorn

In dataset-service with the Python virtual environment active, create a new folder for our API code and move into it:

mkdir -p services/api && cd $_

Now install Python dependencies with:

pip install fastapi uvicorn gunicorn

When the dependencies are ready populate a requirements.txt with:

pip freeze > requirements.txt

In this file leave only the following dependencies:

fastapi==0.57.0
gunicorn==20.0.4
uvicorn==0.11.5

(Your version numbers may vary).

Now create a new Python file named main.py with the following code:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
return {"Hello": "World"}

Here we import FastAPI, and we also create a root route for sending back a Hello World to our users.

This example is intentionally minimal to keep things easy to understand. With the code in place let's now move to the Docker section.

FastAPI, Docker, and Docker Compose for development

Let's prepare the Docker environment for running FastAPI in development.

Make sure you're still in services/api and create a new Dockerfile:

touch Dockerfile

In this Dockerfile we prepare a Docker image with code and dependencies for running our FastAPI app:

FROM python:3.8.1-slim-buster

ENV WORKDIR=/usr/src/app
ENV USER=app
ENV APP_HOME=/home/app/web
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

WORKDIR $WORKDIR

RUN pip install --upgrade pip
COPY ./requirements.txt $WORKDIR/requirements.txt
RUN pip install -r requirements.txt

RUN adduser --system --group $USER
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

COPY . $APP_HOME
RUN chown -R $USER:$USER $APP_HOME
USER $USER

An explanation for this file is out of the scope for this tutorial. Make sure to refer to the official documentation if you need guidance.

(Use multi-stage Docker builds as a potential optimization for bigger projects.)

Now let's go to the Docker Compose file.

Open up the docker-compose.yml you created earlier (should be in the root project folder) and configure it as follows:

version: '3.8'

services:
web:
build:
context: ./services/api
command: gunicorn main:app --bind 0.0.0.0:5000 -k uvicorn.workers.UvicornWorker
expose:
- 5000
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)"

Here we create a "web" service, basically our FastAPI app, we expose it on port 5000, and we launch it with Gunicorn and Uvicorn. (In development, you could also use just Uvicorn alone).

Let's also take a closer look at labels:

# omitted for brevity
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)"

These two labels will be read later by Traefik. In particular:

traefik.enable=true ensures that Traefik sees our container and routes traffic to it. You can omit this directive if in the Traefik configuration you use exposedByDefault = true.

traefik.http.routers.fastapi defines a so called router for Traefik. The complete configuration is:

traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)

This line means: create a router called fastapi, and route traffic to it when the host header matches fastapi.localhost.

In theory these configurations can live in the Traefik configuration file, but here we take advantage of Traefik autodiscovering capabilities to attach our container to Traefik from the Docker Compose file.

Preparing the Traefik configuration for development

After setting up the Python app it's now time to meet Traefik, the proxy.

Move to the root project folder dataset-service and create a new folder for Traefik:

mkdir -p services/traefik && cd $_

Once inside the new folder create a configuration file for Traefik:

touch traefik.dev.toml

Now configure traefik.dev.toml as follows:

[entryPoints]
[entryPoints.web]
address = ":80"

[api]
insecure = true

[log]
level = "DEBUG"

[accessLog]

[providers]
[providers.docker]
exposedByDefault = false

What are we doing here?

First, we define an entrypoint for Traefik to listen on port 80:

[entryPoints]
[entryPoints.web]
address = ":80"

Then we enable the Traefik dashboard over HTTP (only for development!):

[entryPoints]
[entryPoints.web]
address = ":80"

[api]
insecure = true

Next up we enable the debug, and the access log:

[entryPoints]
[entryPoints.web]
address = ":80"

[api]
insecure = true

[log]
level = "DEBUG"

[accessLog]

Finally, we enable a so called provider:

[entryPoints]
[entryPoints.web]
address = ":80"

[api]
insecure = true

[log]
level = "DEBUG"

[accessLog]

[providers]
[providers.docker]
exposedByDefault = false

With the Docker provider enabled Traefik is able to see our containers. exposedByDefault = false means that only container that are explicitly labeled with traefik.enable=true in the labels section of Docker Compose are seen by Traefik.

Setting up Traefik with Docker Compose for development

After configuring Traefik for development let's go to the Docker Compose file again.

Open up the docker-compose.yml (should be in the root project folder) and configure it as follows:

version: '3.8'

services:
web:
build:
context: ./services/api
command: gunicorn main:app --bind 0.0.0.0:5000 -w 4 -k uvicorn.workers.UvicornWorker
expose:
- 5000
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)"

traefik:
image: traefik:v2.2
ports:
- "80:80"
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "$PWD/services/traefik/traefik.dev.toml:/etc/traefik/traefik.toml"

Here we come full circle: we wire up Traefik and FastAPI together.

In Docker Compose we declare two volumes for Traefik:

version: '3.8'

services:
web:
# omitted for brevity
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)"

traefik:
image: traefik:v2.2
ports:
- "80:80"
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "$PWD/services/traefik/traefik.dev.toml:/etc/traefik/traefik.toml"

The first volume makes Traefik aware of Docker containers, while the second volume mounts our traefik.dev.toml , the configuration file, inside the Traefik container.

With the configuration in place we're now ready to test things out.

Smoke testing FastAPI and Traefik in development

To recap, for running the development environment you should have:

  • a configuration file for Traefik at services/traefik/traefik.dev.toml.
  • a configuration file for Docker Compose in the root project folder docker-compose.yml.

Once everything is in place, to test our development environment launch Docker Compose in the main project folder:

docker-compose up --build

Then from another terminal run the following curl call:

curl -H Host:fastapi.localhost http://0.0.0.0

You should see the following output:

{"Hello":"World"}

That's it for development! Feel free to commit the changes to the repo.

Let's now turn our attention to the production environment.

Setting up the production environment

FastAPI, Docker, and Docker Compose for production

For our FastAPI app there's nothing special to do at this stage. A "Hello world" is enough to keep things going.

Instead, we'll complete docker-compose.prod.yml.

Open up docker-compose.prod.yml, one of the file we created earlier for Docker Compose, and give it the following configuration:

version: '3.8'

services:
web:
build:
context: ./services/api
command: gunicorn main:app --bind 0.0.0.0:5000 -w 4 -k uvicorn.workers.UvicornWorker
expose:
- 5000
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`subdomain.example.com`)"

Again, traefik.enable=true ensures that Traefik sees our container and routes traffic to it.

In the routers configuration instead we say: create a router called fastapi, and route traffic to it when the Host header matches subdomain.example.com.

The subdomain, or the domain you'll choose must appear in your configuration. Here subdomain.example.com is just an example.

Note that to make this work with your domain, you must point the appropriate A records to the ip address of your production host.

Let's now configure Let's Encrypt with Traefik.

Preparing the Traefik configuration for production with Let's Encrypt

What really shines in Traefik is the ability to automatically request Let's Encrypt certificates.

To activate this capability we must follow these steps:

  • configuring a certificate resolver in Traefik's static configuration.
  • associating the router with the certificate resolver.

To do so, create a production configuration file in services/traefik:

touch traefik.prod.toml

Now configure traefik.prod.toml as follows:

[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web.http]
[entryPoints.web.http.redirections]
[entryPoints.web.http.redirections.entryPoint]
to = "websecure"
scheme = "https"

[entryPoints.websecure]
address = ":443"

[accessLog]

[providers]
[providers.docker]
exposedByDefault = false

[certificatesResolvers.letsencrypt.acme]
email = "your-email@example.com"
storage= "acme.json"
[certificatesResolvers.letsencrypt.acme.httpChallenge]
entryPoint = "web"

In this configuration file for Traefik we:

  • redirect HTTP connections to HTTPS.
  • configure a resolver for requesting SSL certificates with Let's Encrypt.

Optionally you can enable the Traefik dashboard. Refer to the documentation for more.

Once this file is in place we can finally create a Docker Compose configuration for production.

Setting up Traefik with Docker Compose for production

As a last step we need to complete docker-compose.prod.yml. For the web service we add two more configuration directives needed by Traefik:

version: '3.8'

services:
web:
build:
context: ./services/api
command: gunicorn main:app --bind 0.0.0.0:5000 -w 4 -k uvicorn.workers.UvicornWorker
expose:
- 5000
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`subdomain.example.com`)"
- "traefik.http.routers.fastapi.tls=true"
- "traefik.http.routers.fastapi.tls.certresolver=letsencrypt"

Here we have still the FastAPI app enabled for Traefik, the same host subdomain.example.com, and in addition we:

  • enable TLS for the Docker Compose web service
  • enable the Let's Encrypt resolver

Finally, we configure the Traefik service:

version: '3.8'

services:
web:
build:
context: ./services/api
command: gunicorn main:app --bind 0.0.0.0:5000 -w 4 -k uvicorn.workers.UvicornWorker
expose:
- 5000
labels:
- "traefik.enable=true"
- "traefik.http.routers.fastapi.rule=Host(`subdomain.example.com`)"
- "traefik.http.routers.fastapi.tls=true"
- "traefik.http.routers.fastapi.tls.certresolver=letsencrypt"

traefik:
image: traefik:v2.2
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "$PWD/services/traefik/traefik.prod.toml:/etc/traefik/traefik.toml"

Notice how we use 80 and 443 for production, so that Traefik can listen for connections. Notice also that we use the production configuration file traefik.prod.toml mounted in the container:

    volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "$PWD/services/traefik/traefik.prod.toml:/etc/traefik/traefik.toml"

With everything in place, commit the changes and push to the remote repo. We're going to try things out in production.

Smoke testing FastAPI and Traefik in production

To recap, for running the production environment you should:

  • have a configuration file for Traefik at services/traefik/traefik.prod.toml.
  • have a configuration file for Docker Compose in the root project folder docker-compose.prod.yml.
  • Clone the remote repo on the machine where Docker and Docker Compose are installed.

Once everything is in place, to test the production environment launch Docker Compose in the main project folder:

docker-compose -f docker-compose.prod.yml up --build

You should now be able to access subdomain.example.com in your browser, or with curl:

curl https://subdomain.example.com

You should see again the following output:

{"Hello":"World"}

The only difference now being the SSL certificate configured almost magically for us by Traefik!

Wrapping up

In this tutorial you learned how to deploy a FastAPI app with Docker, Traefik, and Let's Encrypt.

By following the same steps you can virtually secure any application with Traefik as an SSL termination.

For learning more about Traefik, refer to the official documentation.

Thanks for reading and stay tuned!

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!