How to Serve a Directory of Static Files with FastAPI

by Christoph Schiessl on Python and FastAPI

Let's say you are working on a website and you are using a static site generator. Your generator ships with a build script that "renders" your website to static files located in some directory (e.g., dist). For deployment, you simply copy this directory to a server of your choice — you don't have to worry about anything else. Your website is just a bunch of static files — there is no database or other server-side components of any kind.

There are many static site generators available, but for this article, it doesn't matter which one you are using. The only important thing is that your site generator produces an output directory full of static HTML, CSS, and JavaScript files. Think of the following shell script as a placeholder for the real build script of your generator:

$ rm -rf dist && mkdir -p dist dist/foo
$ echo '<!doctype html><meta charset=utf-8><title>/index.html</title>' > dist/index.html
$ echo '<!doctype html><meta charset=utf-8><title>/foo/index.html</title>' > dist/foo/index.html
$ echo '<!doctype html><meta charset=utf-8><title>/404.html</title>' > dist/404.html

After running this script, you have a dist directory that looks like this:

$ tree --noreport dist
dist
├── 404.html
├── foo
│   └── index.html
└── index.html

Now, let's say you want to serve your site for development on localhost:8000. You can use FastAPI for that, even though it's not its primary use case. That said, FastAPI is powerful and gives you great flexibility. Anyway, to get this working, we can start with an empty FastAPI application like the following.

import uvicorn
from fastapi import FastAPI

app = FastAPI()

if __name__ == '__main__':
    uvicorn.run(app=app, port=8000)

That's pretty much what you get when you follow the official FastAPI tutorial. Needless to say, if you are doing this for the first time, you also have to install the dependencies: pip install fastapi uvicorn. Once you have that done, you can start your server ...

$ python app.py
INFO:     Started server process [1708000]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Now, if you send HTTP requests to localhost:8000, you consistently get 404 responses because your FastAPI application has not yet defined any routes.

$ http --format-options=json.format:false GET localhost:8000
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:19 GMT
server: uvicorn

{"detail":"Not Found"}

$ http --format-options=json.format:false GET localhost:8000/
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:23 GMT
server: uvicorn

{"detail":"Not Found"}

$ http --format-options=json.format:false GET localhost:8000/index.html
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:37 GMT
server: uvicorn

{"detail":"Not Found"}

$ http --format-options=json.format:false GET localhost:8000/foo
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:41 GMT
server: uvicorn

{"detail":"Not Found"}

$ http --format-options=json.format:false GET localhost:8000/foo/
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:43 GMT
server: uvicorn

{"detail":"Not Found"}

$ http --format-options=json.format:false GET localhost:8000/foo/index.html
HTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Tue, 06 Feb 2024 15:20:52 GMT
server: uvicorn

{"detail":"Not Found"}

By the way, I'm using httpie to make these requests from my command line. This is a cool little Python tool that's a modern alternative to the good old curl. If you want to try this too, you can install it with pip install httpie.

Mounting StaticFiles

Basically, all we need is two additional lines of Python code (and a couple of import statements):

app.mount("/", StaticFiles(directory=Path("dist"), html=True))

This code creates an instance of StaticFiles and initializes it with two keyword-only parameters:

  1. directory=Path("dist") — which tells StaticFiles which directory to serve (relative to the current working directory).
  2. html=True — which makes StaticFiles behave as you would expect for a website. Namely, serve index.html for requests that would be a directory.

Finally, we take the newly created StaticFiles object and use app.mount(...) to mount it to our application's / path. Here is the full code with highlighted changes:

import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path

app = FastAPI()
app.mount("/", StaticFiles(directory=Path("dist"), html=True))

if __name__ == '__main__':
    uvicorn.run(app=app, port=8000)

Now, if you restart your server, things look quite different:

$ http GET localhost:8000
HTTP/1.1 200 OK
content-length: 62
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:20 GMT
etag: c737767be418a2d3bf90a92c437a6e49
last-modified: Tue, 06 Feb 2024 15:40:38 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/index.html</title>

$ http GET localhost:8000/
HTTP/1.1 200 OK
content-length: 62
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:25 GMT
etag: c737767be418a2d3bf90a92c437a6e49
last-modified: Tue, 06 Feb 2024 15:40:38 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/index.html</title>

$ http GET localhost:8000/index.html
HTTP/1.1 200 OK
content-length: 62
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:31 GMT
etag: c737767be418a2d3bf90a92c437a6e49
last-modified: Tue, 06 Feb 2024 15:40:38 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/index.html</title>

$ http GET localhost:8000/foo
HTTP/1.1 307 Temporary Redirect
content-length: 0
date: Tue, 06 Feb 2024 15:41:36 GMT
location: http://localhost:8000/foo/
server: uvicorn

$ http GET localhost:8000/foo/
HTTP/1.1 200 OK
content-length: 66
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:37 GMT
etag: 5fe148dbf4e1e1a51e48936f39af8247
last-modified: Tue, 06 Feb 2024 15:40:44 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/foo/index.html</title>

$ http GET localhost:8000/foo/index.html
HTTP/1.1 200 OK
content-length: 66
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:39 GMT
etag: 5fe148dbf4e1e1a51e48936f39af8247
last-modified: Tue, 06 Feb 2024 15:40:44 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/foo/index.html</title>

As you can see, it's behaving like a proper web server. Requests to / and /foo/, resolve to /index.html and /foo/index.html respectively. Requests to /foo (no trailing slashing) are redirected to /foo/ (with trailing slash).

Bonus: 404 Error Pages

It is not even documented, but StaticFiles can also serve custom 404 pages for you. This assumes that your static site generator outputs a file named 404.html in the root directory of your website — this file name is hard-coded and cannot be configured. Anyway, our placeholder build script (see above) already generates a 404.html file, so we can quickly see this behavior in action:

$ http GET localhost:8000/does-not-exist
HTTP/1.1 404 Not Found
content-length: 60
content-type: text/html; charset=utf-8
date: Tue, 06 Feb 2024 15:41:52 GMT
etag: 14937226972b3692a637ee66dc7e65fa
last-modified: Tue, 06 Feb 2024 15:40:51 GMT
server: uvicorn

<!doctype html><meta charset=utf-8><title>/404.html</title>

Voilà! That's everything for today. Thank you for reading, and see you next time!

Christoph Schiessl

Hi, I'm Christoph Schiessl.

I help you build robust and fast Web Applications.


I'm available for hire as a freelance web developer, so you can take advantage of my more than a decade of experience working on many projects across several industries. Most of my clients are building web-based SaaS applications in a B2B context and depend on my expertise in various capacities.

More often than not, my involvement includes hands-on development work using technologies like Python, JavaScript, and PostgreSQL. Furthermore, if you already have an established team, I can support you as a technical product manager with a passion for simplifying complex processes. Lastly, I'm an avid writer and educator who takes pride in breaking technical concepts down into the simplest possible terms.

Continue Reading?

Here are a few more Articles for you ...


HTTP Caching with ETag and If-None-Match Headers

Learn how to use ETag and If-None-Match headers to limit your web application's resource consumption by preventing data retransfers.

By Christoph Schiessl on Python and FastAPI

HTTP Caching with Last-Modified and If-Modified-Since Headers

Learn about timestamp-based caching in HTTP using the Last-Modified and If-Modified-Since headers, with Python's FastAPI as an example.

By Christoph Schiessl on Python and FastAPI

Disabling 304 Not Modified in FastAPI's StaticFiles

Dealing with caching issues in FastAPI's StaticFiles sub-application and a monkey patching workaround to disable caching.

By Christoph Schiessl on Python and FastAPI

Web App Reverse Checklist

Ready to Build Your Next Web App?

Get my Web App Reverse Checklist first ...


Software Engineering is often driven by fashion, but swimming with the current is rarely the best choice. In addition to knowing what to do, it's equally important to know what not to do. And this is precisely what my free Web App Reverse Checklist will help you with.

Subscribe below to get your free copy of my Reverse Checklist delivered to your inbox. Afterward, you can expect one weekly email on building resilient Web Applications using Python, JavaScript, and PostgreSQL.

By the way, it goes without saying that I'm not sharing your email address with anyone, and you're free to unsubscribe at any time. No spam. No commitments. No questions asked.