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.
Quick Reference
Section titled “Quick Reference”# config.yaml — Docker defaults (works out of the box)WORKERS: 2SERVER_PORT: 8000
DB_USERNAME: "fullfinity"DB_PASSWORD: "fullfinity"DB_HOST: "db" # "localhost" for native installDB_PORT: "5432"DB_POOL_MIN_CONNECTIONS: 5DB_POOL_MAX_CONNECTIONS: 10
VALKEY_HOST: "cache" # "localhost" for native installVALKEY_PORT: 6379
FILESTORE_PATH: "/app/filestore" # Local path for native installSECRET_KEY: "change-me"REFRESH_SECRET_KEY: "change-me"
MODULE_PATHS: - "fullfinity/modules" - "fullfinity/enterprise" - "/app/modules" # a folder you mounted into the containerDatabase Configuration
Section titled “Database Configuration”DB_USERNAME: "fullfinity"DB_PASSWORD: "fullfinity"DB_HOST: "db" # Docker service name, or "localhost" for nativeDB_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")Connection pooling — built in
Section titled “Connection pooling — built in”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_CONNECTIONSOn 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.
Running behind PgBouncer
Section titled “Running behind PgBouncer”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, setDB_POOL_MIN_CONNECTIONS: 0(and a shortDB_MAX_INACTIVE_LIFETIME, e.g.120) — otherwise every tenant’s pool keepsminconnections 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 them — PgBouncer ≥ 1.21 with
max_prepared_statementsset to a non-zero value inpgbouncer.ini(it defaults to0= disabled). The version alone is not enough — you must set both:; pgbouncer.inipool_mode = transactionmax_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 exposemax_prepared_statements(some hosted PgBouncer / RDS-Proxy-style products) — it trades a little per-query planning overhead for transaction-mode compatibility. LeaveDB_STATEMENT_CACHE_SIZEunset (default1000) 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 × ~9KBA 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 hostAccepted 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.
Database Selector Settings
Section titled “Database Selector Settings”Control how users select and access databases.
SHOW_DBS: True # Enable database selector UIDB_FILTER: None # No filter - all databases availableDEFAULT_DB: None # No default - resolve automatically or askSTALE_DB_REDIRECT_URL: None # Where a request goes when its database is goneDEFAULT_DB
Section titled “DEFAULT_DB”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
dbcookie 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.
STALE_DB_REDIRECT_URL
Section titled “STALE_DB_REDIRECT_URL”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-endedThe 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.
DEAD_POOL_SWEEP_SECONDS
Section titled “DEAD_POOL_SWEEP_SECONDS”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.
SHOW_DBS
Section titled “SHOW_DBS”Controls whether users can access the database selector page (/web/database-selector).
| Value | Behavior |
|---|---|
True | Database selector is accessible |
False | Database 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.
DB_FILTER
Section titled “DB_FILTER”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 availableDB_FILTER: None
# Exact matchDB_FILTER: "^mycompany$"
# Prefix matchDB_FILTER: "prod_"
# Subdomain-based filtering (multi-tenant)DB_FILTER: "^%s$"Placeholders:
| Placeholder | Description | Example |
|---|---|---|
%s | First subdomain | acme from acme.example.com |
%h | Full hostname | acme.example.com |
Cache Configuration (Valkey/Redis)
Section titled “Cache Configuration (Valkey/Redis)”VALKEY_HOST: "cache" # Docker service name, or "localhost" for nativeVALKEY_PORT: 6379Server Configuration
Section titled “Server Configuration”WORKERS: 2 # Number of Uvicorn workersSERVER_PORT: 8000LOG_LEVEL: "INFO" # DEBUG, INFO, WARNING, ERRORLOG_CACHE_HITS: false # Log L2 cache hits (verbose, for debugging)CRON_WORKERS: 1 # Background job workers (0 to disable)Security Configuration
Section titled “Security Configuration”SECRET_KEY: "your-secret-key-here"REFRESH_SECRET_KEY: "your-refresh-secret-key"ACCESS_TOKEN_EXPIRE_HOURS: 6REFRESH_TOKEN_EXPIRE_DAYS: 14File Storage
Section titled “File Storage”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 defaultFILESTORE_PATH: "/app/filestore" # Inside Docker# FILESTORE_PATH: "/var/lib/fullfinity/filestore" # Native installObject storage
Section titled “Object storage”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.
| Setting | Notes |
|---|---|
FILESTORE_S3_BUCKET | Required when the backend is s3. Startup fails if it is missing rather than silently writing to local disk. |
FILESTORE_S3_ENDPOINT | Unset for AWS; required for every other provider. |
FILESTORE_S3_REGION | Cloudflare R2 uses auto. |
FILESTORE_S3_ADDRESSING_STYLE | Rarely needed — derived from the endpoint. See below. |
FILESTORE_LOCAL_FALLBACK | Migration only — see below. Off by default. |
FILESTORE_RECLAIM_GRACE_MINUTES | How 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
Section titled “Module Paths”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.
Multi-Tenant Configuration
Section titled “Multi-Tenant Configuration”For multi-tenant setups, combine SHOW_DBS and DB_FILTER:
SHOW_DBS: FalseDB_FILTER: "^%s$"This configuration:
- Disables the database selector UI
- Routes requests based on subdomain:
acme.example.com→acmedatabasecontoso.example.com→contosodatabase
AI Translations (Optional)
Section titled “AI Translations (Optional)”Fullfinity supports AI-assisted translations for multi-language setups. Set one of:
OPENAI_API_KEY: "sk-..."# orANTHROPIC_API_KEY: "sk-ant-..."Web Push Notifications (Optional)
Section titled “Web Push Notifications (Optional)”Browser web-push (VAPID) is off by default. Provide all three keys to enable it:
VAPID_PUBLIC_KEY: "B...." # served to the browser as applicationServerKeyVAPID_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-pushtooling) and reuse it; rotating the public key invalidates existing browser subscriptions. - No-op when unconfigured. If
VAPID_PRIVATE_KEYorVAPID_CONTACT_EMAILis missing, sending a push silently does nothing (it never raises). IfVAPID_PUBLIC_KEYis missing, the frontend simply receives no key and won’t subscribe. VAPID_PRIVATE_KEYis a secret — keep it inconfig.local.yaml, not in the committedconfig.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.)
Docker vs Native Defaults
Section titled “Docker vs Native Defaults”| Setting | Docker (default) | Native install |
|---|---|---|
DB_HOST | db | localhost |
VALKEY_HOST | cache | localhost |
FILESTORE_PATH | /app/filestore | Local path |
FILESTORE_BACKEND | local | local |
MODULE_PATHS | fullfinity/modules, fullfinity/enterprise, your mounted paths | fullfinity/modules, your paths |
Next Steps
Section titled “Next Steps”- Quick Start — Create your first module
- CLI Reference — Server and module management commands