Skip to content

Configuration

Fullfinity is configured via config.yaml in the project root. When running with Docker Compose, a config is auto-created with working defaults on first startup.

# config.yaml — Docker defaults (works out of the box)
WORKERS: 2
SERVER_PORT: 8000
DB_USERNAME: "fullfinity"
DB_PASSWORD: "fullfinity"
DB_HOST: "db" # "localhost" for native install
DB_PORT: "5432"
DB_POOL_MIN_CONNECTIONS: 5
DB_POOL_MAX_CONNECTIONS: 10
VALKEY_HOST: "cache" # "localhost" for native install
VALKEY_PORT: 6379
FILESTORE_PATH: "/app/filestore" # Local path for native install
SECRET_KEY: "change-me"
REFRESH_SECRET_KEY: "change-me"
MODULE_PATHS:
- "fullfinity/modules"
- "fullfinity/enterprise"
- "/app/modules" # a folder you mounted into the container
DB_USERNAME: "fullfinity"
DB_PASSWORD: "fullfinity"
DB_HOST: "db" # Docker service name, or "localhost" for native
DB_PORT: "5432"
DB_POOL_MIN_CONNECTIONS: 5 # Minimum connections kept in pool (per db, per worker)
DB_POOL_MAX_CONNECTIONS: 10 # Maximum concurrent connections (per db, per worker)
# DB_MAX_INACTIVE_LIFETIME: 300 # Seconds an idle pooled connection lingers before closing
# DB_STATEMENT_CACHE_SIZE: 1000 # Per-connection prepared-statement cache; 0 for a
# transaction-mode pooler (see "Running behind PgBouncer")

Fullfinity maintains its own connection pool per database (one pool per worker process), so you do not need an external pooler for a single-node deployment. Tune it with DB_POOL_MIN_CONNECTIONS / DB_POOL_MAX_CONNECTIONS above.

Because the pool is per worker process and per database, the connections a Postgres server sees is roughly:

workers × databases-in-use × DB_POOL_MAX_CONNECTIONS

On a single node with a handful of tenant databases this stays well under Postgres’s default max_connections (~100) and no external pooler is warranted. Reach for PgBouncer only when that product grows large enough to threaten the Postgres connection ceiling — many worker processes multiplied by many tenant databases.

Fullfinity uses prepared statements (asyncpg’s statement cache) for performance, so if you put PgBouncer in front of Postgres, pick one of the two supported modes below.

  • Session pooling (pool_mode = session) — always compatible, but gives up most of PgBouncer’s connection multiplexing (each client holds a server connection for its lifetime), so it does little to reduce the connection count. In a multi-tenant deployment, set DB_POOL_MIN_CONNECTIONS: 0 (and a short DB_MAX_INACTIVE_LIFETIME, e.g. 120) — otherwise every tenant’s pool keeps min connections open forever, and in session mode each one pins a Postgres backend for as long as it stays open, so idle tenants would hold backends they aren’t using.
  • Transaction pooling (pool_mode = transaction) — the mode worth using for connection relief. Two ways to make asyncpg’s prepared statements safe under it:
    • Let PgBouncer track themPgBouncer ≥ 1.21 with max_prepared_statements set to a non-zero value in pgbouncer.ini (it defaults to 0 = disabled). The version alone is not enough — you must set both:

      ; pgbouncer.ini
      pool_mode = transaction
      max_prepared_statements = 200 ; must exceed DB_POOL_MAX_CONNECTIONS-worth of distinct
      ; statements; 0 (the default) breaks prepared statements
    • Turn the client-side cache off — set DB_STATEMENT_CACHE_SIZE: 0. asyncpg then stops caching named prepared statements, so a backend reused across clients can’t collide on them. This is the escape hatch for a pooler that is older than 1.21 or doesn’t expose max_prepared_statements (some hosted PgBouncer / RDS-Proxy-style products) — it trades a little per-query planning overhead for transaction-mode compatibility. Leave DB_STATEMENT_CACHE_SIZE unset (default 1000) for session mode or a direct connection.

The default is deliberately much larger than asyncpg’s own 100. One record form issues around fifty distinct statement shapes and browsing a handful of models passes a hundred, so a cache of 100 evicts statements faster than they are reused and every query re-pays its planning cost — measurably slower than disabling the cache outright. The size is chosen against the whole application rather than one session, because a pooled connection serves every model rather than a slice of them.

The cache is per connection, not per database, so its memory cost multiplies by the same figure as the connection count above:

workers × databases-in-use × DB_POOL_MAX_CONNECTIONS × DB_STATEMENT_CACHE_SIZE × ~9KB

A single-database node (2 workers, pool of 10) tops out around 180MB of PostgreSQL backend memory; a node hosting many tenant databases multiplies that by the number of databases in use. Lower DB_STATEMENT_CACHE_SIZE for multi-tenant deployments — a per-tenant connection only ever executes the shapes that tenant’s users touch, so a few hundred is ample there, and the ceiling above is what you are trading against. The cap is an upper bound rather than an allocation: a connection caches only the statements it actually runs, and anything idle longer than asyncpg’s max_cached_statement_lifetime is dropped.

Three things a pooler changes that aren’t obvious

Section titled “Three things a pooler changes that aren’t obvious”

TLS is negotiated unless you say otherwise. The driver disables it automatically when DB_HOST is a loopback address — but a pooler is normally reached by hostname, at which point it is no longer recognised as local and every new connection pays a TLS handshake to encrypt traffic that never leaves the machine. Set it explicitly:

DB_SSL: disable # a pooler on this host

Accepted values are true/false and PostgreSQL’s own sslmode names (disable, prefer, require, verify-ca, verify-full).

A missing database does not fail fast. Connecting directly, PostgreSQL answers immediately that the database does not exist. Through a pooler the attempt simply waits, so what surfaces is a connection-acquisition timeout after several seconds. If you are debugging a database that was dropped or renamed, that timeout is the symptom — not a slow server.

Session mode keeps server connections after the client has gone. A pooler in session mode holds its server connection for its own server_idle_timeout (often ten minutes) once the client disconnects. Anything requiring a database to have no connections is therefore blocked for that window — most visibly CREATE DATABASE ... TEMPLATE, which refuses to copy a database anyone is attached to. If you clone from a prepared template, expect it to be unclonable for a few minutes after any process connects to it (a migration run, for instance), and mark the source IS_TEMPLATE — the engine may then clear a template’s idle connections itself, while still refusing to interrupt one running a query.

Schema changes are handled automatically: when a module is installed or upgraded (-u all), the engine proactively recycles pooled connections so no cached prepared statement can outlive the schema it was planned against — you don’t need to restart or flush anything by hand.

Control how users select and access databases.

SHOW_DBS: True # Enable database selector UI
DB_FILTER: None # No filter - all databases available
DEFAULT_DB: None # No default - resolve automatically or ask
STALE_DB_REDIRECT_URL: None # Where a request goes when its database is gone

The database to serve when a request carries neither an X-DB-NAME header nor a db cookie.

Unset (the default), the server resolves automatically only when it hosts exactly one database, and otherwise sends the caller to the selector. That is fine for a single-database install, but it stops resolving the moment a second database appears — including databases the public should never be offered.

DEFAULT_DB: "acme_production"

With a default configured:

  • a request with no header and no cookie is served that database;
  • a db cookie pointing at a database that no longer exists is cleared and the browser is sent to /, which then resolves to the default — instead of the selector.

The second point makes a dropped database a graceful landing rather than a dead end, which matters for deployments that create and destroy databases routinely.

Where a browser goes when its db cookie names a database that no longer exists.

Unset, a missing database is treated as a mistake: the caller is offered the selector, or / when DEFAULT_DB names a fallback. That suits an installation whose databases are permanent, where a missing one means a stale cookie after a restore or a rename.

Set it when your databases are ephemeral by design — per-visitor environments, review apps, anything created and reclaimed on a schedule. There a missing database is the normal end of a session, not an error, and this names the page that says so:

STALE_DB_REDIRECT_URL: /session-ended

The same target is sent to the app in the JSON of the 409 DATABASE_NOT_FOUND its background requests receive, so a navigation and a background request cannot disagree about where a dead database leads. Without it, those requests send the browser to the selector — which SHOW_DBS: False answers with a 404.

How often a worker checks whether the databases it holds pools for still exist, in seconds (default 300; 0 disables it).

Each worker keeps a connection pool and a composed model cache per database it has served, and cleanup on deletion only happens in the worker that performed the deletion — the others have no reason to touch that database again. On an installation with permanent databases this sweep finds nothing and costs one catalog query per interval. On one that creates and drops databases, it is what stops each worker accumulating the pools and caches of every database it has ever served.

Controls whether users can access the database selector page (/web/database-selector).

ValueBehavior
TrueDatabase selector is accessible
FalseDatabase selector returns 404, auto-selects if only one DB matches

When SHOW_DBS: False and multiple databases match DB_FILTER, the server returns a 400 error.

A regex pattern to filter which databases are available. Supports hostname/subdomain placeholders for multi-tenant setups.

# No filter - all databases owned by DB_USERNAME are available
DB_FILTER: None
# Exact match
DB_FILTER: "^mycompany$"
# Prefix match
DB_FILTER: "prod_"
# Subdomain-based filtering (multi-tenant)
DB_FILTER: "^%s$"

Placeholders:

PlaceholderDescriptionExample
%sFirst subdomainacme from acme.example.com
%hFull hostnameacme.example.com
VALKEY_HOST: "cache" # Docker service name, or "localhost" for native
VALKEY_PORT: 6379
WORKERS: 2 # Number of Uvicorn workers
SERVER_PORT: 8000
LOG_LEVEL: "INFO" # DEBUG, INFO, WARNING, ERROR
LOG_CACHE_HITS: false # Log L2 cache hits (verbose, for debugging)
CRON_WORKERS: 1 # Background job workers (0 to disable)
SECRET_KEY: "your-secret-key-here"
REFRESH_SECRET_KEY: "your-refresh-secret-key"
ACCESS_TOKEN_EXPIRE_HOURS: 6
REFRESH_TOKEN_EXPIRE_DAYS: 14

Attachments — every uploaded document, product image and generated PDF — are stored outside the database. By default they are files on the local disk:

FILESTORE_BACKEND: "local" # the default
FILESTORE_PATH: "/app/filestore" # Inside Docker
# FILESTORE_PATH: "/var/lib/fullfinity/filestore" # Native install

Local disk cannot be shared between application servers, so an instance running on more than one node — or on a host whose filesystem is ephemeral — stores attachments in an S3-compatible bucket instead:

FILESTORE_BACKEND: "s3"
FILESTORE_S3_BUCKET: "my-instance-files"
FILESTORE_S3_ENDPOINT: "https://fra1.digitaloceanspaces.com"
FILESTORE_S3_REGION: "fra1"
FILESTORE_S3_ACCESS_KEY: "..."
FILESTORE_S3_SECRET_KEY: "..."

This is the S3 API, not a specific vendor: AWS S3, DigitalOcean Spaces, Cloudflare R2, Backblaze B2, Wasabi, MinIO and Ceph are all supported by pointing FILESTORE_S3_ENDPOINT at them. Leave the endpoint unset for AWS itself, where it is derived from the region.

SettingNotes
FILESTORE_S3_BUCKETRequired when the backend is s3. Startup fails if it is missing rather than silently writing to local disk.
FILESTORE_S3_ENDPOINTUnset for AWS; required for every other provider.
FILESTORE_S3_REGIONCloudflare R2 uses auto.
FILESTORE_S3_ADDRESSING_STYLERarely needed — derived from the endpoint. See below.
FILESTORE_LOCAL_FALLBACKMigration only — see below. Off by default.
FILESTORE_RECLAIM_GRACE_MINUTESHow long deleted bytes are kept before the sweep removes them. 0 (immediate) by default; set it when your database and your files are backed up separately — see below.

You should not need to set FILESTORE_S3_ADDRESSING_STYLE. S3 lets a request name the bucket either as a subdomain (bucket.host/key) or as the first path segment (host/bucket/key), and the two are not interchangeable — the host header is part of what a request signature covers, so the wrong shape is rejected as a bad signature. Which one works is decided by DNS: a hosted provider runs a wildcard record so the subdomain resolves, while a MinIO or Ceph endpoint on an IP address or a bare container name has no such record. The endpoint you configure already says which case you are in, so the style is derived from it. Set it only for a host that looks like a hosted provider but is not — an internal, dotted hostname with no wildcard DNS.

FILESTORE_PATH is still used under s3 — for node-local scratch space such as in-progress data imports — so leave it set and writable.

MODULE_PATHS:
- "fullfinity/modules" # Built-in modules (inside Docker image)
- "fullfinity/enterprise" # Licensed modules (gated at runtime)
- "/app/modules" # Your own modules (volume-mounted)

Every folder scanned for modules must be listed here — there is no magic default folder. Mount each of your module folders into the container and add its in-container path as its own line. To load modules from several separate locations, mount each at a distinct /app/modules/<name> and add one line per folder.

For multi-tenant setups, combine SHOW_DBS and DB_FILTER:

SHOW_DBS: False
DB_FILTER: "^%s$"

This configuration:

  • Disables the database selector UI
  • Routes requests based on subdomain:
    • acme.example.comacme database
    • contoso.example.comcontoso database

Fullfinity supports AI-assisted translations for multi-language setups. Set one of:

OPENAI_API_KEY: "sk-..."
# or
ANTHROPIC_API_KEY: "sk-ant-..."

Browser web-push (VAPID) is off by default. Provide all three keys to enable it:

VAPID_PUBLIC_KEY: "B...." # served to the browser as applicationServerKey
VAPID_PRIVATE_KEY: "...." # signs each push (keep secret — use config.local.yaml)
VAPID_CONTACT_EMAIL: "ops@example.com" # becomes the VAPID "sub" claim (mailto:)
  • Generate a VAPID key pair once (e.g. with the py-vapid / web-push tooling) and reuse it; rotating the public key invalidates existing browser subscriptions.
  • No-op when unconfigured. If VAPID_PRIVATE_KEY or VAPID_CONTACT_EMAIL is missing, sending a push silently does nothing (it never raises). If VAPID_PUBLIC_KEY is missing, the frontend simply receives no key and won’t subscribe.
  • VAPID_PRIVATE_KEY is a secret — keep it in config.local.yaml, not in the committed config.yaml.

Local Overrides and Secrets (config.local.yaml)

Section titled “Local Overrides and Secrets (config.local.yaml)”

A sibling *.local.yaml file (e.g. config.local.yaml next to config.yaml), if present, is merged over the base config at the top level when the config is loaded. It is gitignored, so secrets — SECRET_KEY, REFRESH_SECRET_KEY, DB_PASSWORD, OPENAI_API_KEY, etc. — can live there instead of in the committed config.yaml. Keys in the local file override the base file.

# config.local.yaml (gitignored)
DB_PASSWORD: "real-password"
SECRET_KEY: "..."
REFRESH_SECRET_KEY: "..."

An Enterprise license is not a configuration key. It is activated in the app — Settings → License — and stored in the database, so it can be moved, renewed and revoked without anyone editing a file on the server.

Individual configuration keys are not overridable via environment variables — there is no FULLFINITY_-prefixed override mechanism for config values. Configuration comes only from config.yaml plus the optional config.local.yaml overlay. (The one environment variable the runtime reads is FULLFINITY_CONFIG_PATH, the path to the config file, which the CLI sets automatically from -c/--config so worker processes can find it.)

SettingDocker (default)Native install
DB_HOSTdblocalhost
VALKEY_HOSTcachelocalhost
FILESTORE_PATH/app/filestoreLocal path
FILESTORE_BACKENDlocallocal
MODULE_PATHSfullfinity/modules, fullfinity/enterprise, your mounted pathsfullfinity/modules, your paths