Skip to content

Tutorial: Configuring Your Application

In this tutorial, we'll see how to use convoke to configure a simple Starlette web application.

We'll start with a very simple Starlette web app:

# app.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

templates = Jinja2Templates(directory='templates')


async def homepage(request):
    return templates.TemplateResponse(
        "index.html",
        {"request": request, "DEBUG": request.app.debug}
    )


def create_app():
    return Starlette(debug=True, routes=[
        Route('/', homepage),
    ])

Built-in configuration settings

Now, of course, we don't want to always be in debug mode. Let's add a configuration by subclassing convoke.configs.BaseConfig:

# app.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

from convoke.configs import BaseConfig, env_field

# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""


def create_app():
    config = WebAppConfig()

    return Starlette(debug=config.DEBUG, routes=[
        Route('/', homepage),
    ])

Every instance of BaseConfig has two built-in settings: DEBUG and TESTING, both booleans with a default value of False. So, if we run our application, it will default to production mode:

$ uvicorn --factory app.create_app

But if we set the environment variable DEBUG=True, it will run in development mode:

$ DEBUG=True uvicorn --factory app.create_app

DEBUG=true and DEBUG=TRUE would also work, but tRuE would not. Likewise, with equivalent cases of DEBUG=False.

Custom configuration settings

Now, to improve our application, we'll want our users to be able to login, and for that, we'll need sessions:

# app.py
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.sessions import SessionMiddleware
# ...

def create_app():
    config = WebAppConfig()

    return Starlette(
        debug=config.DEBUG,
        routes=[
            Route('/', homepage),
        ],
        middleware=[
            Middleware(
                SessionMiddleware,
                secret_key="supers3kr1t",
                https_only=not config.DEBUG)
        ],
    )

We see how the configuration is already making our lives easier: in production we want secure sessions, which requires HTTPS, but in development we don't want to bother with HTTPS, so we use https_only=not config.DEBUG to only require HTTPS in production.

Protecting secrets

However, we have another problem: our secret key is stored in plain text in our Python source. Anyone who can read this file can discover our secret and hack our users! What to do?

Let's move our secret key to the environment:

# app.py
# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""

    SECRET_KEY: str = env_field()


def create_app():
    config = WebAppConfig()

    return Starlette(
        # ...
        middleware=[
            Middleware(
                SessionMiddleware,
                secret_key=config.SECRET_KEY,
                https_only=not config.DEBUG)
        ],
    )

We use all caps SECRET_KEY by convention, and the corresponding environment variable:

$ DEBUG=True SECRET_KEY="supers3kr1t"  uvicorn --factory app.create_app

Now, one problem is that if we ever print our configuration to the console, we get our secret in plain text again:

$ DEBUG=True SECRET_KEY="supers3kr1t"  python
>>> from app import WebAppConfig

>>> print(WebAppConfig())
WebAppConfig(DEBUG=True, TESTING=False, SECRET_KEY='supers3kr1t')

Let's fix that by using the Secret type:

# app.py
# ...
from convoke.configs import BaseConfig, env_field, Secret
# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""

    SECRET_KEY: Secret = env_field()

Now, when we print to the console, our secret is safe:

$ DEBUG=True SECRET_KEY="supers3kr1t"  python
>>> from app import WebAppConfig

>>> print(WebAppConfig())
WebAppConfig(DEBUG=True, TESTING=False, SECRET_KEY=Secret('**********'))

But when we use the secret as a string, we get the plain-text value:

>>> str(WebAppConfig().SECRET_KEY)
'supers3kr1t'

Other configuration field types

Since we're considering security, let's consider our session age. Starlette's default session age is 2 weeks (1,209,600 seconds), but say that for our application, we require that a user session must be refreshed by authentication every 24 hours (86,400 seconds). As a business policy, this may change in the future, so let's make it a configurable value:

# app.py
# ...
from convoke.configs import BaseConfig, env_field, Secret
# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""

    SECRET_KEY: Secret = env_field()
    SESSION_COOKIE_MAX_AGE: int = env_field(default=86_400)


def create_app():
    config = WebAppConfig()

    return Starlette(
        # ...
        middleware=[
            Middleware(
                SessionMiddleware,
                secret_key=config.SECRET_KEY,
                https_only=not config.DEBUG,
                max_age=config.SESSION_COOKIE_MAX_AGE,
            ),
        ],
    )

By annotating the type of the configuration field, we tell convoke how to parse the environment string. This works for floats and booleans too:

class MyConfig(BaseConfig):
    VALUE_OF_PI: float = env_field(default=3.14)
    USE_ANTIGRAVITY: bool = env_field(default=False)

Configuration fields with multiple values

Now, to really make sure our application is secure, let's configure Starlette's TrustedHostMiddleware:

# app.py
# ...
from starlette.middleware.trustedhost import TrustedHostMiddleware
# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""

    SECRET_KEY: Secret = env_field()
    SESSION_COOKIE_MAX_AGE: int = env_field(default=86_400)
    ALLOWED_HOSTS: tuple[str] = env_field(default=('127.0.0.1', 'localhost'))


def create_app():
    config = WebAppConfig()

    return Starlette(
        # ...
        middleware=[
            # ...
            Middleware(
                TrustedHostMiddleware,
                allowed_hosts=config.ALLOWED_HOSTS,
            ),
        ],
    )

By using the type annotation tuple[str], we tell convoke to parse the value as a comma-separated list of strings, resulting in a tuple of strings:

$ DEBUG=True SECRET_KEY="supers3kr1t" ALLOWED_HOSTS="example.com,*.example.com"  uvicorn --factory app.create_app

We could also use tuple[int] for a setting that uses a sequence of integers, etc.

Prefer immutability

For that matter, we could also use ALLOWED_HOSTS: list[str] give us a list of strings instead of a tuple, but Config instances are immutable, and it's best to use immutable types for configuration values as well.

Generating a .env file

As we've added more configuration values, our uvicorn invocation has been getting longer and longer. Web applications can have tens if not hundreds of configuration values, and we don't want to type these out every time we start up the dev server! Let's fix that:

# app.py
# ...
from convoke.configs import BaseConfig, env_field, generate_dot_env, Secret
# ...

if __name__ == "__main__":
    print(generate_dot_env(BaseConfig.gather_settings()))

Then, in the shell:

$ python app.py > .env
$ cat .env
################################
## convoke.configs.BaseConfig ##
################################
##
## Base settings common to all configurations

# ------------------
# -- DEBUG (bool) --
# ------------------
#
# Development mode?

DEBUG="False"

# --------------------
# -- TESTING (bool) --
# --------------------
#
# Testing mode?

TESTING="False"


###########################
## __main__.WebAppConfig ##
###########################
##
## Configuration for our web application.

# ---------------------------------------
# -- SECRET_KEY (Secret) **Required!** --
# ---------------------------------------

SECRET_KEY="6BIW_mb496YHiptQ1E4WVm-7b_YBW1zQFqZnBKmcsDpTlb1Qb8uZ8w"

# ---------------------------
# -- SESSION_COOKIE_MAX_AGE (int) --
# ---------------------------

SESSION_COOKIE_MAX_AGE="86400"

# ---------------------------
# -- ALLOWED_HOSTS (tuple) --
# ---------------------------

ALLOWED_HOSTS="127.0.0.1,localhost"

A few things to note here:

  • Default values are included
  • Secrets are assigned a securely-generated value
  • Configuration values have documentation!

Documenting configuration values

Let's add some documentation to our config values:

# app.py
# ...

class WebAppConfig(BaseConfig):
    """Configuration for our web application."""

    SECRET_KEY: Secret = env_field(
        doc="""
            Secret used to cryptographically sign session cookies.

            This should be set to a unique, unpredictable value and kept safe
            from prying eyes.
        """,
    )
    SESSION_COOKIE_MAX_AGE: int = env_field(
        default=86_400,
        doc="""
            The maximum age of session cookies, in seconds.

            Defaults to 24 hours.
        """,
    )
    ALLOWED_HOSTS: tuple[str] = env_field(
        default=('127.0.0.1', 'localhost'),
        doc="""
            A list of strings representing the host/domain names that this site can serve.
        """
    )

And now, if we generate a .env file again, we see our new documentation:

$ python app.py
# ...
###########################
## __main__.WebAppConfig ##
###########################
##
## Configuration for our web application.

# ---------------------------------------
# -- SECRET_KEY (Secret) **Required!** --
# ---------------------------------------
#
# Secret used to cryptographically sign session cookies.
#
# This should be set to a unique, unpredictable value and kept safe from
# prying eyes.

SECRET_KEY="6rlnXtlxeggkgaG1Y4EsEitfRTGjUxu8zbdtZ8GpwfbwCmi0J2Tb3w"

# ----------------------------------
# -- SESSION_COOKIE_MAX_AGE (int) --
# ----------------------------------
#
# The maximum age of session cookies, in seconds.
#
# Defaults to 24 hours.

SESSION_COOKIE_MAX_AGE="86400"

# ---------------------------
# -- ALLOWED_HOSTS (tuple) --
# ---------------------------
#
# A list of strings representing the host/domain names that this site can serve.

ALLOWED_HOSTS="127.0.0.1,localhost"

Conclusion

With that, you should now understand the basics of configuring a Starlette application with convoke, including how to keep secrets safe, and how to document your settings.