Static Files

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07; every Python example was executed with those versions. Tornado 6.6 is only available as the alpha 6.6a1 at that date, so anything about 6.6 is upcoming, and no change to static file handling is announced for it in the sources consulted. The page follows a file from the disk to the browser in the Bookshelf Live scenario: how Application wires up the static handler, how static_url() fingerprints URLs so that browsers can cache them for years, how the handler decides on status codes and headers, how to customise it by subclassing, the symlink and traversal rules that changed in 6.5.9 and 6.5.10, and what changes in production, where nginx or a CDN should answer instead of Python. The Flask counterpart is Static Files & Assets with Flask; FastAPI’s is in Static Files, Templates & Frontend and Django’s in Static Files & Front-End Assets.

How Tornado Serves Static Files

Static files are served by one class, tornado.web.StaticFileHandler, an ordinary RequestHandler. There is no separate static server, no build step and no separate collect command (unlike Django’s collectstatic): the handler reads files from a directory you name and answers GET and HEAD requests. It supports ETag and Last-Modified conditional requests (304 Not Modified), HTTP Range requests (206 Partial Content, which some browsers need to seek inside audio and video), Content-Type guessing from the file name, and the long-lived caching of versioned URLs. Compression is not part of the handler: it comes from the application-wide compress_response setting.

There are two ways to get the handler into the application.

  • The static_path setting. Application inserts three routes at the front of the routing table for you: the static_url_prefix route (default /static/(.*)), /favicon.ico and /robots.txt. All three use the same handler class and arguments.

  • Explicit routes. Add (pattern, StaticFileHandler, {"path": directory}) to the handler list yourself for any additional directory, such as downloadable book covers. The pattern needs exactly one capture group, which is passed to get() as the path relative to the directory.

flowchart LR B["Browser"] -- "GET /static/css/site.css?v=ab12..." --> R{"Application routing"} R -- "static_url_prefix + (.*)" --> H["StaticFileHandler"] R -- "everything else" --> D["Your handlers"] H --> V["validate_absolute_path
root check, symlink check, 404/403"] V --> C["set_headers
ETag, Last-Modified, Content-Type,
Cache-Control only if v is present"] C --> S["304, 206 or 200
+ file bytes"] D -- "static_url('css/site.css')" --> U["make_static_url
prefix + path + ?v=hash"] U -. "written into the HTML" .-> B

The handler is documented as intended for development and light-duty serving; for heavy traffic the official guidance is to put a dedicated static file server such as nginx or Apache in front (see production). Everything in this page keeps working in that arrangement, because the URLs and the version query string are the contract between the template, Tornado and the proxy.

Every example below builds a small Bookshelf Live front end in a temporary directory so that the page is self-contained. The helper at the end of the block starts the application on an unused loopback port, makes one HTTP request with AsyncHTTPClient and stops the server again, so that the examples can show real status codes and headers.

import asyncio
import gzip
import os
import secrets
import tempfile
from pathlib import Path

import tornado.web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpserver import HTTPServer
from tornado.testing import bind_unused_port

# In a real project this is Path(__file__).resolve().parent
BASE = Path(tempfile.mkdtemp(prefix="bookshelf-live-"))
STATIC = BASE / "static"
(STATIC / "css").mkdir(parents=True)
(STATIC / "js").mkdir()
(STATIC / "docs").mkdir()
(STATIC / "css" / "site.css").write_text("body { font-family: sans-serif; }\n" * 40)
(STATIC / "js" / "live.js").write_text("console.log('live');\n")
(STATIC / "docs" / "index.html").write_text("<h1>Bookshelf Live docs</h1>\n")
(STATIC / "favicon.ico").write_bytes(b"\x00\x00\x01\x00")
(STATIC / "robots.txt").write_text("User-agent: *\nDisallow: /admin/\n")
(STATIC / "audio.bin").write_bytes(bytes(range(256)) * 4)  # stands in for an audio file


async def get(app, path, *, method="GET", **kwargs):
    """Start app on a free loopback port, send one request, stop the server."""
    sock, port = bind_unused_port()
    server = HTTPServer(app)
    server.add_sockets([sock])
    client = AsyncHTTPClient()
    kwargs.setdefault("follow_redirects", False)
    try:
        return await client.fetch(
            f"http://127.0.0.1:{port}{path}", method=method, raise_error=False, **kwargs
        )
    finally:
        server.stop()
        client.close()


def run(coro):
    return asyncio.run(coro)


print(sorted(p.relative_to(STATIC).as_posix() for p in STATIC.rglob("*") if p.is_file()))

The Static File Settings

All the settings are keyword arguments of Application (so they end up in Application.settings).

Setting Default Meaning

static_path

none

Directory served by the static handler. Without it Application registers no static routes and static_url() raises an exception. Use an absolute path (built from __file__); a relative one depends on the working directory of the process.

static_url_prefix

/static/

URL prefix of the static route, and the prefix static_url() puts in front of the file path. Keep the trailing slash: the prefix and the path are concatenated.

static_handler_class

StaticFileHandler

The class used for the static routes and for static_url() (make_static_url is called on it). Use a subclass to customise either side (see subclassing).

static_handler_args

{}

A dictionary of keyword arguments for the handler’s initialize(): default_filename and allowed_symlink_directory for the stock class, plus any argument your own subclass accepts. Application adds the path key to this dictionary itself, so do not reuse one dictionary object across applications.

static_hash_cache

True

Whether version hashes are cached in memory. debug=True sets it to False (together with autoreload, compiled_template_cache and serve_traceback), and then the hash cache is cleared before every request.

compress_response

False

Application-wide gzip of responses with a compressible content type, static files included (see compression). The older name of the setting is gzip.

The next example creates the application for the front end, with the defaults, and requests the three routes that static_path registers. Because /favicon.ico and /robots.txt are served from the same directory, a browser or a crawler finds them without any extra route.

app = tornado.web.Application(
    [],  # no handlers of our own yet
    static_path=str(STATIC),
)


async def demo():
    for path in ("/static/css/site.css", "/favicon.ico", "/robots.txt", "/static/missing.css"):
        r = await get(app, path)
        print(r.code, path, r.headers.get("Content-Type"), len(r.body))


run(demo())

The prefix is configurable, and static_handler_args configures the handler itself. With default_filename="index.html" a request for a directory serves the index file inside it. If the request path does not end with a slash, the handler first answers with a permanent redirect to the same path plus a trailing slash, so that relative links in the page resolve correctly.

docs_app = tornado.web.Application(
    [],
    static_path=str(STATIC),
    static_url_prefix="/assets/",
    static_handler_args={"default_filename": "index.html"},
)


async def demo():
    for path in ("/assets/docs", "/assets/docs/"):
        r = await get(docs_app, path)
        print(r.code, path, r.headers.get("Location"), r.body[:30])


run(demo())

static_url(): Versioned URLs and Long-Term Caching

A static file that never changes can be cached by the browser forever, and one that does change has to be refetched at once. These requirements conflict unless the URL changes whenever the content does. RequestHandler.static_url(path) solves that: it returns static_url_prefix + path followed by ?v= and a hash of the file’s contents, for example /static/css/site.css?v=<hash>. Edit the file and the hash, hence the URL, changes; the browser treats it as a new resource and fetches it, while every unchanged file keeps hitting its cache.

In a template the call is {{ static_url("css/site.css") }} (the template language is covered in Templates & UI Modules). In Python code the method works the same. The example renders a small page through a template string loaded from a file and prints the generated URLs.

TEMPLATES = BASE / "templates"
TEMPLATES.mkdir()
(TEMPLATES / "index.html").write_text(
    '<link rel="stylesheet" href="{{ static_url("css/site.css") }}">\n'
    '<script src="{{ static_url("js/live.js") }}"></script>\n'
    '<img src="{{ static_url("css/site.css", include_version=False) }}">\n'
)


class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        self.render("index.html")


page_app = tornado.web.Application(
    [(r"/", IndexHandler)],
    static_path=str(STATIC),
    template_path=str(TEMPLATES),
)


async def demo():
    r = await get(page_app, "/")
    for line in r.body.decode().splitlines():
        # the hash is a 128-character SHA-512 hex digest: shorten it for display
        print(line.replace(line[line.find("?v=") + 7 : line.find('"', line.find("?v="))], "...")
              if "?v=" in line else line)


run(demo())

The details of the mechanism are worth knowing because they explain the pitfalls.

  • The hash is a SHA-512 of the file contents (StaticFileHandler.get_content_version). It is long, 128 hexadecimal characters, which is harmless but visible in the HTML. A subclass can return something shorter (see subclassing).

  • static_url() returns a path relative to the current host by default. Pass include_host=True, or set an include_host attribute on the handler class, to get an absolute URL built from the request’s protocol and host.

  • The hash is computed once per file and process. It is stored in a class-level dictionary, protected by a lock, the first time static_url() (or an ETag calculation) needs it. Changing a file while the process runs does not change its URL until the cache is cleared. That is why debug=True turns the cache off, and why a production deployment replaces files by restarting or rolling the processes, not by editing them in place.

  • A file that cannot be read yields a URL without a version and an error in the tornado.general log, so a typo in a template does not break the page, but it silently loses caching.

from tornado.web import StaticFileHandler

CSS = STATIC / "css" / "site.css"
before = StaticFileHandler.make_static_url({"static_path": str(STATIC)}, "css/site.css")
CSS.write_text(CSS.read_text() + "h1 { color: teal; }\n")
stale = StaticFileHandler.make_static_url({"static_path": str(STATIC)}, "css/site.css")
StaticFileHandler.reset()  # what static_hash_cache=False does before each request
fresh = StaticFileHandler.make_static_url({"static_path": str(STATIC)}, "css/site.css")

print("stale until reset:", before == stale)
print("new url after reset:", before != fresh)

What the Handler Sends

Now the server side. The caching headers depend on the request. A response always carries Accept-Ranges: bytes, an ETag (derived from the same content hash, so it is also the correct tag for partial responses), Last-Modified (truncated to whole seconds, an aware UTC datetime since 6.4) and the guessed Content-Type. Only when the request has a v query argument does the handler also send Cache-Control: max-age=315360000 and a matching Expires, that is CACHE_MAX_AGE, ten years. Without v it sends neither, and the browser revalidates with a conditional request.

async def demo():
    plain = await get(app, "/static/css/site.css")
    print("plain:", plain.code, plain.headers.get("Cache-Control"), "| ETag set:", bool(plain.headers.get("Etag")))

    versioned = await get(app, "/static/css/site.css?v=anything")
    print("versioned:", versioned.code, versioned.headers.get("Cache-Control"))

    etag = plain.headers["Etag"]
    again = await get(app, "/static/css/site.css", headers={"If-None-Match": etag})
    print("If-None-Match:", again.code, len(again.body))

    since = await get(
        app,
        "/static/css/site.css",
        headers={"If-Modified-Since": plain.headers["Last-Modified"]},
    )
    print("If-Modified-Since:", since.code)


run(demo())

The example also shows the first pitfall: the value of v is never checked. Any request with a v argument, even ?v=anything, is treated as immutable and cached for ten years. That is correct for URLs generated by static_url(), but it means a hand-written <link href="/static/app.css?v=2"> is cached for a decade and cannot be fixed without changing the URL. Always generate URLs with static_url() (or another fingerprinting scheme) and never hard-code a v argument.

If a request carries If-None-Match, the handler uses only the ETag comparison and ignores If-Modified-Since, as HTTP prescribes; otherwise it compares the modification time. A match produces 304 with no body.

Range Requests and HEAD

The handler honours the Range header, which is how <audio> and <video> elements seek, and answers HEAD requests with the headers of the GET response and no body. A satisfiable partial range yields 206 and a Content-Range header, a range starting beyond the end of the file yields 416, and a malformed range header is ignored as if it were absent. A Range: bytes=0- request for the whole file returns a plain 200 (some browsers refuse to play audio if they get 206 in that case).

async def demo():
    r = await get(app, "/static/audio.bin", headers={"Range": "bytes=10-19"})
    print(r.code, r.headers["Content-Range"], len(r.body), list(r.body[:3]))

    r = await get(app, "/static/audio.bin", headers={"Range": "bytes=-4"})
    print(r.code, r.headers["Content-Range"], list(r.body))

    r = await get(app, "/static/audio.bin", headers={"Range": "bytes=5000-"})
    print(r.code, r.headers["Content-Range"])

    r = await get(app, "/static/audio.bin", headers={"Range": "bytes=0-"})
    print(r.code, r.headers.get("Content-Range"), len(r.body))

    r = await get(app, "/static/audio.bin", method="HEAD")
    print(r.code, r.headers["Content-Length"], len(r.body))


run(demo())

Compression

StaticFileHandler does not compress anything itself. With compress_response=True on the Application, the GZipContentEncoding output transform compresses a response when the client sends Accept-Encoding: gzip and the content type begins with text/ or is one of a small list (JavaScript, JSON, XML, Atom, XHTML and SVG). Images, fonts and the like are not touched. The transform adds Vary: Accept-Encoding, and does not compress responses that are already encoded or smaller than 1024 bytes. It compresses on the fly for each response; Tornado has no equivalent of nginx’s gzip_static, which serves a precompressed .gz file next to the original, so a high-traffic site compresses once in the proxy or at build time.

zapp = tornado.web.Application([], static_path=str(STATIC), compress_response=True)


async def demo():
    r = await get(zapp, "/static/css/site.css", headers={"Accept-Encoding": "gzip"}, decompress_response=False)
    print(r.code, r.headers.get("Content-Encoding"), r.headers.get("Vary"))
    print(len(r.body), "bytes on the wire for", len(gzip.decompress(r.body)), "bytes of CSS")

    r = await get(zapp, "/static/audio.bin", headers={"Accept-Encoding": "gzip"}, decompress_response=False)
    print("binary file:", r.headers.get("Content-Encoding"))


run(demo())

Serving Other Directories and Index Files

static_path serves one directory. A second one needs an explicit route, as in this example, where the original uploaded cover images live outside the code tree. The pattern must contain exactly one capture group (the file path), and the path argument names the directory. Every argument of initialize() can be given the same way, so the index-file behaviour and the symlink allow-list are per route.

COVERS = BASE / "covers"
COVERS.mkdir()
(COVERS / "dune.jpg").write_bytes(b"\xff\xd8\xff\xe0 fake jpeg")

covers_app = tornado.web.Application(
    [
        (r"/covers/(.*)", StaticFileHandler, {"path": str(COVERS)}),
        (r"/manual/(.*)", StaticFileHandler, {"path": str(STATIC / "docs"), "default_filename": "index.html"}),
    ],
    static_path=str(STATIC),
)


async def demo():
    for path in ("/covers/dune.jpg", "/manual/", "/static/js/live.js"):
        r = await get(covers_app, path)
        print(r.code, path, r.headers.get("Content-Type"))


run(demo())

Routes registered by static_path are inserted before yours, so they win on a conflict. A pattern such as /(.*) for a single-page application (SPA) fallback must therefore not shadow API routes; give API routes an explicit prefix and register the catch-all last, or serve the SPA shell from a dedicated handler (a StaticFileHandler subclass that falls back to index.html when validate_absolute_path raises a 404 is a common pattern and is shown in the next section).

Customising the Handler by Subclassing

The class is designed for subclassing, with a specific set of override points. The documentation warns that overriding other methods, get() in particular, is error-prone, because get() is tightly coupled with the ETag and header logic. Several of the hooks are class methods, because static_url() has to call them without a request: decorate them with @classmethod when overriding. Instance methods can use self.path (the path relative to the root), self.absolute_path and self.modified.

Hook Kind Use it to

set_extra_headers(path)

instance

Add headers to every static response (X-Content-Type-Options, Content-Security-Policy, CORS).

get_cache_time(path, modified, mime_type)

instance

Choose the cache lifetime in seconds. The default is ten years if the request has v, otherwise 0 (no Cache-Control).

validate_absolute_path(root, absolute_path)

instance

Reject paths (raise HTTPError), redirect (then return None) or rewrite the path. This is where 404s and 403s are produced.

get_content_type()

instance

Override the guessed Content-Type.

make_static_url(settings, path, include_version=True)

class

Change the URLs static_url() generates (CDN host, a different version scheme).

get_version(settings, path), get_content_version(abspath)

class

Change how the version string is computed. Override get_content_version (the hash of the content) rather than get_version, because the base class then still caches the result.

parse_url_path(url_path)

instance

Inverse of make_static_url: convert the URL path into a path relative to the root.

get_absolute_path(root, path), get_content(abspath, start, end), get_content_size(), get_modified_time()

class and instance

Replace the file system entirely, for instance to serve from a database or an object store. get_content is a generator-friendly class method returning bytes or an iterator of bytes.

The Bookshelf Live handler below adds X-Content-Type-Options: nosniff to every response, refuses dotfiles and source maps as not found, never caches HTML (so a new deployment is seen at once), and falls back to the SPA shell when a client-side route such as /shelves/42 is requested. It passes the real work to the base class with super().

from tornado.web import HTTPError


class BookshelfStaticHandler(StaticFileHandler):
    def validate_absolute_path(self, root, absolute_path):
        parts = Path(self.path).parts
        if any(part.startswith(".") for part in parts) or self.path.endswith(".map"):
            raise HTTPError(404)  # the same answer as for a missing file
        try:
            return super().validate_absolute_path(root, absolute_path)
        except HTTPError as exc:
            # SPA fallback: unknown paths without a file extension serve the shell
            if exc.status_code == 404 and "." not in Path(self.path).name:
                return super().validate_absolute_path(root, str(Path(root) / "docs" / "index.html"))
            raise

    def set_extra_headers(self, path):
        self.set_header("X-Content-Type-Options", "nosniff")

    def get_cache_time(self, path, modified, mime_type):
        if mime_type == "text/html":
            self.set_header("Cache-Control", "no-cache")
            return 0
        return super().get_cache_time(path, modified, mime_type)


(STATIC / ".env").write_text("not for the browser\n")
(STATIC / "js" / "live.js.map").write_text("{}")
custom_app = tornado.web.Application(
    [],
    static_path=str(STATIC),
    static_handler_class=BookshelfStaticHandler,
)


async def demo():
    for path in ("/static/js/live.js?v=1", "/static/.env", "/static/js/live.js.map", "/static/shelves/42", "/static/docs/index.html"):
        r = await get(custom_app, path)
        print(r.code, path, r.headers.get("Cache-Control"), r.headers.get("X-Content-Type-Options"))


run(demo())

The same class mechanism changes the URLs. When the assets are published to a CDN under a release identifier, a subclass can build the URL from the configured CDN host and the build identifier, instead of hashing files that the application server may not even hold. Custom keys in the Application settings are available to the class method through its settings argument.

class CdnStaticHandler(StaticFileHandler):
    @classmethod
    def make_static_url(cls, settings, path, include_version=True):
        url = settings["static_cdn"].rstrip("/") + "/" + path
        return f"{url}?v={settings['release']}" if include_version else url


class CdnIndexHandler(tornado.web.RequestHandler):
    def get(self):
        self.write(self.static_url("js/live.js"))


cdn_app = tornado.web.Application(
    [(r"/", CdnIndexHandler)],
    static_path=str(STATIC),
    static_handler_class=CdnStaticHandler,
    static_cdn="https://cdn.example.test/bookshelf-live",
    release=os.environ.get("RELEASE", "2026-10-07-1"),
)


async def demo():
    print((await get(cdn_app, "/")).body.decode())


run(demo())

The handler has to make sure that a URL cannot name a file outside the directory it serves. It does so in two steps. First, the requested path is joined to the root, normalised and compared with the root: a path such as /static/../private/secret.txt that leads outside it is rejected with 403. Second, since Tornado 6.5.9 the path is also resolved through any symbolic links, and the result must stay inside the directory being served, or the request is rejected with the same 403. The error is deliberately the same for both cases, so that a client cannot tell which one occurred (or whether an out-of-bounds directory exists).

The second step is a security fix. Before 6.5.9 the handler followed symlinks wherever they pointed, so a link created in the static directory (by a deployment tool, an unpacked archive or a user-supplied upload) could expose any file readable by the process. Older tutorials that say "symlinks in the static folder just work" describe that behaviour; with 6.5.9 and later a link to a target outside the root is a 403 unless you opt in.

Version Behaviour

before 6.5.9

Symlinks are followed to any location.

6.5.9

Links whose target is outside the served directory give 403. The allowed_symlink_directory argument of the handler’s initialize() names a directory that contains every legitimate target.

6.5.10

allowed_symlink_directory may also be a list of directories.

Deployments that publish releases as releases/<id>/ directories and point a current link at the newest one stay inside the rule when the served directory is current itself: the root and the allowed directories are resolved first, so a root that is reached through a symlink works. A layout where the files inside the root are links to a shared asset store needs the opt-in, as in this example.

SHARED = BASE / "shared-assets"
SHARED.mkdir()
(SHARED / "logo.svg").write_text("<svg xmlns='http://www.w3.org/2000/svg'/>")
PRIVATE = BASE / "private"
PRIVATE.mkdir()
(PRIVATE / "secret.txt").write_text("do not serve")

os.symlink(SHARED / "logo.svg", STATIC / "logo.svg")      # a legitimate link into the asset store
os.symlink(PRIVATE / "secret.txt", STATIC / "leak.txt")   # a link that points somewhere it should not

strict_app = tornado.web.Application([], static_path=str(STATIC))
shared_app = tornado.web.Application(
    [],
    static_path=str(STATIC),
    static_handler_args={"allowed_symlink_directory": [str(SHARED)]},  # a list since 6.5.10
)


async def demo():
    for name, application in (("default", strict_app), ("allow-list", shared_app)):
        for path in ("/static/logo.svg", "/static/leak.txt", "/static/../private/secret.txt"):
            r = await get(application, path)
            print(f"{name:10} {r.code} {path}")


run(demo())

Two limits are stated in the source documentation and are worth repeating. The symlink check is a check of the file system at one moment: a path replaced by a symlink after the check but before the file is opened is not caught, so it is no substitute for file system permissions on a directory that untrusted users can write to. And an allow-list wider than the static root widens what the handler can read, so name the narrowest directory (or directories) possible, never the home directory or /.

Hardening Checklist

  • Keep the Tornado version at or above 6.5.9 (currently 6.5.10) and treat static handling as security-relevant: the recent 6.5.x releases are mostly security fixes.

  • Point static_path at a directory that contains only public files. Never serve the project root, a virtualenv, or a directory with .env files, .git, or database dumps; the stock handler serves anything it can read, dotfiles included (the subclass above shows how to refuse them).

  • Do not let users write into the static directory, and do not serve user uploads from it. Store uploads outside it and serve them through a dedicated route with a restrictive handler: a Content-Disposition: attachment header, X-Content-Type-Options: nosniff, and never HTML or SVG from your own origin, which would run scripts with your cookies.

  • Do not hand-write ?v= arguments (see above), and do not use static_url() as a substitute for authorization: a static URL is public for anyone who has it.

  • Content-Type comes from the file extension (mimetypes), so a file named .html is served as HTML. Restrict which extensions can ever land in the directory.

Protected Files: Authorization Before Static Delivery

Files that only some users may fetch (paid book PDFs, private covers) should not live under the public static route. The usual arrangement is that a normal handler authorizes the request and then either streams the file itself or tells the front proxy to send it. With nginx, a handler that answers with an X-Accel-Redirect header naming an internal location makes nginx replace the response with the file, so Python never reads the bytes. The handler is an ordinary RequestHandler; the cookie secret comes from the environment or is generated at start-up, never written in the source.

class LoginHandler(tornado.web.RequestHandler):
    def post(self):
        self.set_signed_cookie("reader", self.get_argument("name"), httponly=True, samesite="Lax")


class DownloadHandler(tornado.web.RequestHandler):
    def get_current_user(self):
        value = self.get_signed_cookie("reader")
        return value.decode() if value else None

    @tornado.web.authenticated
    def get(self, book_id):
        self.set_header("X-Accel-Redirect", f"/protected-files/{int(book_id)}.pdf")
        self.set_header("Content-Disposition", f'attachment; filename="book-{int(book_id)}.pdf"')


protected_app = tornado.web.Application(
    [(r"/login", LoginHandler), (r"/download/([0-9]+)", DownloadHandler)],
    cookie_secret=os.environ.get("COOKIE_SECRET") or secrets.token_hex(32),
    login_url="/login",
    xsrf_cookies=False,  # demo only: a real login form uses xsrf_cookies=True
)


async def demo():
    anonymous = await get(protected_app, "/download/7")
    print("anonymous:", anonymous.code, anonymous.headers.get("Location"))

    login = await get(protected_app, "/login", method="POST", body="name=ana")
    cookie = login.headers["Set-Cookie"].split(";")[0]
    ok = await get(protected_app, "/download/7", headers={"Cookie": cookie})
    print("signed in:", ok.code, ok.headers["X-Accel-Redirect"])


run(demo())

The same split applies without nginx: the handler can call self.write() with chunks of the file and await self.flush() for large files, so the loop is not blocked while it streams, but the cost of reading the file is yours. The built-in StaticFileHandler already does that chunked, flush-per-chunk reading and range handling, so a subclass whose validate_absolute_path performs the authorization check (reading self.current_user) is often the least code for authenticated static files.

Static Files in Production

Tornado documents the production recommendation itself: serve static files from a faster static server such as nginx, and let that server recognise the v argument written by static_url() to set long-lived caching headers. Because the version is derived from the content, replacing a file produces a new URL, so the proxy can safely mark every versioned response as cacheable "forever". The following configuration sends /static/ and the two well-known files from disk, adds the far-future expires only when a query string is present, defines the internal location used by X-Accel-Redirect above, and proxies everything else to one Tornado process. It is configuration, not Python, so it is not executed on this page.

upstream bookshelf_live {
    server 127.0.0.1:8888;
}

server {
    listen 80;
    server_name books.example.com;

    location /static/ {
        alias /srv/bookshelf-live/static/;
        access_log off;
        if ($query_string) {
            expires max;
        }
    }

    location = /favicon.ico { alias /srv/bookshelf-live/static/favicon.ico; }
    location = /robots.txt  { alias /srv/bookshelf-live/static/robots.txt; }

    location /protected-files/ {
        internal;
        alias /srv/bookshelf-live/private/books/;
    }

    location / {
        proxy_pass http://bookshelf_live;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

A few points make this arrangement work well.

  • Keep the application’s static_path anyway. static_url() needs it in order to compute the content hash, so the Tornado hosts must have the same files as nginx has (they usually come from the same image or release directory). The routes it registers then act as a fallback for development.

  • nginx must be told the layout. The alias path ends in a slash exactly like the location it belongs to. The files are served relative to it, so the static_url_prefix on the Tornado side has to equal the location prefix.

  • A CDN builds on the same idea. Either put a CDN in front of nginx (it caches by URL, so the version arguments keep working), or publish the build to a bucket and use a make_static_url override as shown above, so that templates write absolute CDN URLs. With a CDN, remember to configure the CDN to include the query string in its cache key.

  • Containers and Kubernetes. Bake the static directory into the image used by the Tornado pods and into the image of the nginx (or ingress) sidecar, built from the same commit, so that the hashes agree; see Docker and Kubernetes.

  • Do not expect the handler to be free. Flask under Gunicorn serves static files with the same proxy-first strategy; the Tornado handler writes and flushes the file in chunks, but it still runs on the single event loop of the process. Reading large files from a slow network file system inside get_content is synchronous I/O and can stall the loop, which is another reason to let nginx do it.

Testing Static Serving

AsyncHTTPTestCase starts the application on an unused port for each test and offers self.fetch(). A test for static serving checks the things that matter in production: that the versioned URL is immutable, that traversal and symlink escapes are refused, and that private files are not exposed. The test below reuses the application and files created above and runs the cases with the standard test runner.

import unittest

from tornado.testing import AsyncHTTPTestCase


class StaticServingTest(AsyncHTTPTestCase):
    def get_app(self):
        return tornado.web.Application(
            [(r"/", IndexHandler)],
            static_path=str(STATIC),
            template_path=str(TEMPLATES),
        )

    def test_versioned_url_is_immutable(self):
        html = self.fetch("/").body.decode()
        href = html.split('href="')[1].split('"')[0]
        self.assertIn("?v=", href)
        response = self.fetch(href)
        self.assertEqual(response.code, 200)
        self.assertEqual(response.headers["Cache-Control"], "max-age=315360000")

    def test_unversioned_url_revalidates(self):
        response = self.fetch("/static/js/live.js")
        self.assertNotIn("Cache-Control", response.headers)
        again = self.fetch("/static/js/live.js", headers={"If-None-Match": response.headers["Etag"]})
        self.assertEqual(again.code, 304)

    def test_traversal_and_symlink_escapes_are_forbidden(self):
        self.assertEqual(self.fetch("/static/../private/secret.txt").code, 403)
        self.assertEqual(self.fetch("/static/leak.txt").code, 403)

    def test_missing_file_is_404(self):
        self.assertEqual(self.fetch("/static/nope.css").code, 404)


suite = unittest.defaultTestLoader.loadTestsFromTestCase(StaticServingTest)
result = unittest.TextTestRunner().run(suite)
assert result.wasSuccessful()

Pitfalls and Where Older Material Is Outdated

Problem What to do

Immutable cache on a hand-written ?v= URL

The value of v is not checked. Use static_url() everywhere, or serve with another cache policy through get_cache_time.

Changed file, unchanged URL

The version hash is cached for the life of the process. Restart or roll the processes on deployment; use debug=True only in development (it disables the hash cache and costs one hash computation per request).

static_url() raises "You must define the static_path setting"

The setting is missing, or the handler runs in an application without it. Sub-applications and tests need it too.

404 for a file that exists

The route needs the capture group; static_url_prefix must match the URL the browser requests; a relative static_path depends on the working directory.

403 for a file that exists

The file resolves outside the root through a symlink (6.5.9 and later), the request contains .., or the path is a directory and no default_filename is configured.

Static files slow all requests down

The handler runs on the event loop. Serve large or busy assets from nginx or a CDN; never read big files with blocking calls in a subclass (use an executor, see Asynchronous I/O & the Event Loop).

static_handler_args shared between applications

Application adds path to the dictionary you pass. Build one dictionary per application.

Older tutorials: "symlinks work", get_version as the hook, tornado.web.asynchronous

Symlinks no longer escape the root as of 6.5.9. get_content_version is the preferred hook since 3.1, and the @asynchronous decorator was removed in 6.0.

Comparison With Other Frameworks

Tornado Flask FastAPI

Setup

static_path setting, or an explicit StaticFileHandler route

static_folder and static_url_path of Flask

app.mount("/static", StaticFiles(directory="static"))

URL building and cache busting

static_url() appends a content hash v and the handler caches it for ten years

url_for("static", filename="site.css"); cache busting is left to you or an extension

url_path_for("static", path="site.css"); no built-in versioning

Default caching

Cache-Control only with v; ETag and Last-Modified always

SEND_FILE_MAX_AGE_DEFAULT (none by default) with conditional requests

ETag and Last-Modified from Starlette

Production

nginx or a CDN in front, same URLs

the same, through Gunicorn plus nginx

the same, through Uvicorn plus nginx

See Flask: Static Files & Assets and FastAPI: Static Files, Templates & Frontend for the details of the other columns. Django separates development serving from production (collectstatic, WhiteNoise or a web server) in Django: Static Files & Front-End Assets.

Where to Go Next

Templates & UI Modules uses static_url() inside {{ }} expressions and in UI module embeds (javascript_files() and css_files() generate versioned links), XSRF & Cross-Origin Protection covers xsrf_cookies and cross-origin protection around the same handlers, and Running & Deploying shows the full process layout behind nginx.