Configuration

Configuration lives in config/ as plain Python modules. Every value is readable through dot notation, and secrets come from the environment.

The .env file

Application.register_config() loads .env before any config module runs, so env() sees your values. Real environment variables always win over the file — in production you can set them directly and ship no .env at all.

APP_NAME=Craft
APP_ENV=local
APP_DEBUG=true
APP_KEY=base64:...

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1

MAIL_FROM_NAME="${APP_NAME}"

${VAR} interpolates values already loaded. null, none and empty strings resolve to the default you pass to env().

.env is gitignored. .env.example is committed and documents every key.

Config modules

Each file in config/ becomes a namespace:

# config/app.py
from craft.config import env

APP_NAME = env("APP_NAME", "Craft")
# Debug is opt-in: it defaults to False so a missing .env never leaks stack
# traces in production. Set APP_DEBUG=true locally.
APP_DEBUG = env("APP_DEBUG", False)
APP_LOCALE = env("APP_LOCALE", "en")
APP_FALLBACK_LOCALE = env("APP_FALLBACK_LOCALE", "en")

Read them by file name and key:

from craft.facades import Config

Config.get("app.APP_NAME")
Config.get("database.connections.pgsql.host")
Config.get("nothing.here", "fallback")

Keys are registered in both their original and lowercased form, so Config.get("app.app_name") also works.

Set values at runtime — useful in tests:

Config.set("cache.default", "array")

Configuration is shared for the process. A test that changes a value must put it back, or every later test sees the change.

env() type coercion

| In .env | Python value | |---|---| | true / false | True / False | | 9000 | 9000 (int) | | null / none / empty | the default you passed | | anything else | str |

What ships in config/

| File | Purpose | |---|---| | app.py | Name, environment, debug (off by default), key, locale, timezone | | framework.py | Framework name/version/release, feature flags (MULTI_TENANCY_ENABLED, PQC_SECURITY_ENABLED, CAPTCHA_ENABLED), health probes, metrics, thread pool, migration lock, default locale and supported locales | | database.py | Connections for sqlite, pgsql, mysql | | session.py | Driver, lifetime, cookie name, SameSite, CSRF switch | | auth.py | Guards and the user provider model | | cache.py | Default store | | queue.py | Default connection | | logging.py | Log channel setup and the text / json format |

Database connections

# config/database.py
# sqlite by default, so a fresh checkout runs with no database service;
# Docker and production set DB_CONNECTION explicitly.
default = env("DB_CONNECTION", "sqlite")

connections = {
    "sqlite": {"driver": "sqlite", "database": env("DB_DATABASE", "storage/database.sqlite")},
    "pgsql": {
        "driver": "postgresql",
        "host": env("DB_HOST", "127.0.0.1"),
        "port": env("DB_PORT", 5432),
        "database": env("DB_DATABASE", "forge"),
        "username": env("DB_USERNAME", "forge"),
        "password": env("DB_PASSWORD", ""),
    },
}

Split reads from writes by nesting read and write:

"pgsql": {
    "driver": "postgresql",
    "host": "127.0.0.1",
    "username": "craft",
    "password": "secret",
    "write": {"host": "primary.db.internal"},
    "read": {"host": "replica.db.internal"},
}

Keys outside read/write apply to both.

Connection pool

Requests are served on a thread pool, and each thread borrows a connection for the duration of the request and gives it back afterwards. Two keys size that pool, per connection:

"pgsql": {
    "driver": "postgresql",
    # ...
    "pool_size": 4,        # physical connections (default 4)
    "pool_timeout": 10,    # seconds to wait for a free one (default 10)
    "pool_recycle": 900,   # reopen a connection idle longer than this
    "application_name": env("APP_NAME", "craft"),
}

pool_size is a ceiling on connections to that database per worker process and per connection (a configured read replica gets its own), so the total your deployment opens is the sum across web workers, queue workers and listeners — keep that under the database's own max_connections, minus the slots it reserves for the superuser. When every connection is checked out, a request waits up to pool_timeout and then fails with an error naming the setting, rather than hanging forever.

pool_recycle reopens a connection that has sat idle longer than the given seconds instead of reusing it, ahead of the idle timeout a managed database enforces on its own. A connection idle for more than 30 seconds is also pinged before reuse, so a failover costs one discarded connection rather than a wave of errors. application_name is what makes pg_stat_activity able to say which process is holding connections open.

The web thread pool is sized from pool_size — see Deployment.

SQLite :memory: is the one exception: an in-memory database lives inside the connection that created it, so all threads share a single connection there.

Sessions

# config/session.py
driver = env("SESSION_DRIVER", "cookie")     # cookie | file
lifetime = env("SESSION_LIFETIME", 7200)
cookie = env("SESSION_COOKIE", "craft_session")
secure = env("SESSION_SECURE_COOKIE", False)
same_site = env("SESSION_SAME_SITE", "lax")
csrf = env("SESSION_CSRF", True)

See Sessions for the difference between the drivers.

Adding your own

Drop a file in config/:

# config/services.py
from craft.config import env

stripe = {
    "key": env("STRIPE_KEY", ""),
    "secret": env("STRIPE_SECRET", ""),
}
Config.get("services.stripe.key")

Inspecting configuration

python dev.py about     # environment, debug, database, cache, queue
python dev.py db show   # the active connection