Skip to content

Installation

The fastest way to get running. Requires only Docker.

Terminal window
git clone https://github.com/fullfinity/fullfinity.git
cd fullfinity
docker compose up

This starts:

  • Fullfinity at http://localhost:8000 (compiled engine + built frontend)
  • PostgreSQL 16 database
  • Valkey 8 cache

On first run, config.yaml is created automatically with defaults that work out of the box.

After setup, your project directory looks like:

fullfinity/
├── docker-compose.yml # Service definitions
├── config.yaml # Your configuration (mounted; lists MODULE_PATHS)
├── config.example.yaml # Reference config with all options
└── modules/ # Your modules (volume-mounted; listed in MODULE_PATHS)
└── my_module/
├── manifest.yaml
├── models/
├── views/
└── security/

The Docker image contains the compiled engine, pre-built React frontend, and all standard modules. Your config.yaml and your mounted module folders persist across upgrades. There is no built-in modules folder — each module folder you mount is loaded only because it’s listed under MODULE_PATHS in config.yaml.

An upgrade is two steps: swap the image, then migrate the database. Nothing migrates on boot — new code runs against the old schema until you migrate, and view/menu changes stay inert until then too. Your config.yaml and mounted module folders are untouched throughout.

The compose file takes its image tag from FULLFINITY_VERSION:

image: ghcr.io/fullfinity/fullfinity:${FULLFINITY_VERSION:-latest}

Every release publishes both :X.Y.Z and :latest, so choosing a version is one line in a .env file next to docker-compose.yml:

Terminal window
FULLFINITY_VERSION=1.0.0

Leave it unset and you track :latest. That’s fine for development, but pin it in production: an unattended docker compose pull on :latest can carry you across a major version, and major is exactly where breaking changes and destructive migrations live. With a pin, “upgrade to the next release” is editing that value deliberately.

Nothing is backed up for you, and a delete migration drops columns. Take both the database and the filestore — an image swap discards the container’s filesystem, and a database restored without its matching filestore serves 404s for every attachment:

Terminal window
docker compose exec -T db pg_dump -U fullfinity mycompany > backup.sql
docker run --rm -v fullfinity_filestore:/f -v "$PWD:/out" alpine \
tar czf /out/filestore.tgz -C /f .

The volume name is prefixed with your Compose project name — confirm it with docker volume ls.

If you store attachments in a bucket (FILESTORE_BACKEND: s3) that volume holds no attachments, so the tar above backs up nothing that matters and the database dump is the whole local backup. The files are already in object storage — but make sure versioning is enabled on that bucket, because an attachment is deleted from it when the last record referencing it goes, and without versioning a database restored to a point before such a deletion has rows pointing at bytes that no longer exist.

3. Preview what changes (custom modules only)

Section titled “3. Preview what changes (custom modules only)”

If you run your own modules, audit them against the new release before committing to it:

Terminal window
docker compose run --rm fullfinity fullfinity -c /app/config.yaml preview --db mycompany

Read-only. It lists every reference in your modules that no longer resolves and exits non-zero if it finds any, so CI can gate on it. Without --db it prints the release’s reference changelog instead. See Upgrade Transparency.

Terminal window
docker compose pull # fetch the new image
docker compose stop fullfinity # take the app out of service
docker compose run --rm fullfinity \
fullfinity -c /app/config.yaml -db mycompany upgrade
docker compose up -d # back in service, on the new code

Stopping the app first is what keeps new code from serving an unmigrated schema. upgrade is an alias for -u all: it runs the additive schema sync (which never drops a column on a code diff) and replays the schema-change ledger in one atomic transaction, rolling the whole thing back on any failure. It is idempotent — safe to re-run.

Running more than one database? Swap -db mycompany for --all-databases; each database is upgraded in its own transaction and you get a pass/fail summary at the end.

See Migrations & Upgrades for what a migration can and cannot do.

After the server is running, create a database and install modules:

  1. Open http://localhost:8000
  2. Use the database selector to create a new database
  3. Go to Settings > Modules to install modules (sales, invoicing, inventory, etc.)

Or via CLI:

Terminal window
docker compose exec fullfinity fullfinity -c /app/config.yaml -db mycompany --init core,sales,invoicing --init-only

No. Fullfinity is distributed as a container image, and that is the only supported way to run it. The engine ships compiled inside that image rather than as source, so there is nothing to pip install — a “native” install would need a package that is not published.

This is not a limitation in practice: your modules, your configuration and your files all live OUTSIDE the image, mounted in (see Project Structure above). You edit them on the host with your own tools, and the container is just the runtime.

In production the application sits behind nginx (or another proxy), usually with TLS terminating at a CDN. Four things in that layer are load-bearing, and three of them fail silently.

Tell the application the request was HTTPS

Section titled “Tell the application the request was HTTPS”
proxy_set_header X-Forwarded-Proto https;

The engine reads this to decide whether session cookies carry the Secure flag. Where TLS terminates at a CDN the origin genuinely speaks plain HTTP, so nginx must assert https — the scheme it sees is not the scheme the visitor used. Omit it and cookies are set without Secure, which a browser on an HTTPS page then refuses to send back: sign-in appears to succeed and the next request is anonymous.

That header names the database to serve and beats the db cookie. A proxy that sets it puts every visitor into the same database regardless of who they are. Nothing sets it by default; the rule is simply never to add it.

location /api/ws {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}

Without the upgrade headers the connection is proxied as ordinary HTTP and real-time delivery — chat, presence, notifications — silently never connects.

gzip on;
gzip_proxied any;
gzip_types application/json application/javascript text/css text/plain application/xml image/svg+xml;

Two nginx defaults each disable it independently, so gzip on alone does nothing here: gzip_proxied defaults to off, meaning nginx compresses nothing that came from an upstream — and everything here is proxied. gzip_types defaults to text/html alone, which excludes application/json. A record’s form-load payload is roughly 61KB raw and 9KB compressed.

Where a CDN sits in front this is invisible in a browser (the CDN compresses its own leg), which is exactly why it gets missed — the uncompressed hop is origin to edge, and it is paid on every request.

docker-compose.yml
ports:
- "127.0.0.1:8000:8000"

So the only way in is through the proxy. Otherwise the application port is reachable directly, bypassing every rule above.

  1. Open http://localhost:8000 — you should see the login page or database selector
  2. Create a database and install the core module
  3. Log in with the admin credentials you set during database creation