Skip to main content

Configuration reference

sol2flow reads its configuration from environment variables — in the Docker Compose setup, from the .env file next to compose.yml. The app checks them on start and refuses to start with a clear message when a required one is missing or invalid. Change a value, then apply it with docker compose up -d.

Most settings are not environment variables: sign-in methods, self sign-up, upload limits and notification emails are set in the app under Admin (Instance settings).

Secrets such as AUTH_SECRET or DATABASE_URL can also come from files, for example Docker secrets (Secrets from files).

Required​

VariableDescription
APP_URLThe public address of the instance, e.g. https://flow.example.com. Used for links in emails, sign-in redirects and passkeys. Must match what people type exactly.
DATABASE_URLPostgreSQL connection string, e.g. postgresql://sol2flow:secret@db:5432/sol2flow?schema=public. PostgreSQL 16, 17 or 18. The example compose.yml builds it from POSTGRES_PASSWORD.
AUTH_SECRETAt least 16 characters; use 32 random bytes: openssl rand -base64 32. Keys internal signatures (for example the anonymised rate-limit counters).
APP_ENCRYPTION_KEYAt least 32 characters; use openssl rand -base64 32. Encrypts secrets stored in the database. Never lose or change it casually — see rotating the encryption key.

General​

VariableDefaultDescription
LOG_LEVELinfotrace, debug, info, warn, error or fatal. Logs are JSON lines on standard output, and in a file with LOG_FILE (below).
RUN_MIGRATIONStrueApply database migrations on start. Set false only if you run them another way.
TRUST_PROXY_HOPS1How many reverse proxies in front of the app append to X-Forwarded-For (1–10). Decides which address counts as the client's. See Reverse proxy and TLS.
PUBLIC_WEBSITE_URLhttps://sol2flow.comWhere the footer “Shared with sol2flow” of a public board link points (http or https; opens in a new tab without a referrer). Point it at your own site, or leave it empty to show the text without a link.
ROLE(empty)web = don't run background jobs in this container; used with a separate worker container (Installation).

Database connections​

Each app container keeps two pools of PostgreSQL connections: one for pages, actions and the API, and a smaller one for the background job queue. Their sizes are per container:

VariableDefaultDescription
DATABASE_POOL_SIZE10; 5 with ROLE=workerConnections for pages, actions, the API and job handlers. A connection_limit in DATABASE_URL (or in the DATABASE_URL_FILE secret) takes precedence over it.
DATABASE_JOBS_POOL_SIZE5; 2 with ROLE=web, which only queues jobsConnections for the job queue.

When a pool is full, a request waits for a free connection (up to 10 seconds) instead of opening another one. In the database, the connections show up as sol2flow-web, sol2flow-worker or sol2flow-all (a container without ROLE), and the job queue's with -jobs appended. To see who holds how many:

SELECT application_name, state, count(*) FROM pg_stat_activity WHERE datname = 'sol2flow' GROUP BY 1, 2;

Everything that connects counts against the database server's max_connections: 100 by default, of which 3 are reserved for superusers, and other databases on the same server count too.

SetupConnections at most
One container (no ROLE), the default compose.yml10 + 5 = 15
A ROLE=web container10 + 2 = 12 each
A ROLE=worker container5 + 5 = 10 each
Migrations on start, command-line tools, backups, psql1–2 each while they run
A rolling update that starts the new container firstthe updated containers twice, until the old ones stop

Web and worker on one server use 22 connections, 44 at most while both are being replaced, well below the default limit. More containers or larger pools add up: keep the total, doubled if your updates start new containers before stopping the old ones, below max_connections minus 3. If the log shows sorry, too many clients already, lower the pool sizes or raise max_connections in PostgreSQL.

Sign-in location​

sol2flow tells people about unusual sign-ins: a new device, a new country, or a sign-in from another country right after one elsewhere. For the country it needs the approximate location of each sign-in. Without a location source the alerts still work, but they compare devices and networks instead of countries.

VariableDefaultDescription
GEO_HEADERS(empty)cloudflare = read the visitor's location from Cloudflare's headers (CF-IPCountry, cf-ipcity, cf-region). Only when all traffic reaches the app through Cloudflare.
GEOIP_DB(empty)Path inside the container to a MaxMind GeoLite2 City or Country file, or a DB-IP Lite file (.mmdb), for example /data/geoip/GeoLite2-City.mmdb.

When both are set, the Cloudflare headers win and the file is the fallback for requests without them. Only the country and the city are kept, never the IP address: see what is stored.

Behind Cloudflare​

If your instance is behind Cloudflare — proxied (orange cloud) or through a Cloudflare Tunnel — Cloudflare can add the location to every request:

  1. In the Cloudflare dashboard, open your domain and check Network → IP Geolocation is on (it is by default). That adds the country (CF-IPCountry).
  2. For the city as well, go to Rules → Settings, find Managed Transforms, and turn on Add visitor location headers. That adds cf-ipcity and cf-region, among others.
  3. Set GEO_HEADERS=cloudflare in .env and apply it with docker compose up -d.
warning

Set GEO_HEADERS=cloudflare only if the app can't be reached any other way. Anyone can send a CF-IPCountry header; only Cloudflare in front of the app guarantees it's Cloudflare's. With a Tunnel, don't publish the app's port on the host; with a proxied DNS record, let only Cloudflare's addresses through your firewall. Without the variable, sol2flow ignores these headers.

A Cloudflare Tunnel also sets X-Forwarded-For; with nothing else in front of the app, keep TRUST_PROXY_HOPS=1.

With a GeoIP database​

If you're not behind Cloudflare, download a free GeoIP database and point GEOIP_DB at it. The lookup happens inside the app: no address is sent anywhere. sol2flow doesn't ship a database, because of their licences:

  • MaxMind GeoLite2 (City or Country): create a free MaxMind account, accept the GeoLite2 licence (EULA) and create a licence key. The licence asks you to keep the data current and to credit MaxMind where you show it. The simplest way to keep it current is MaxMind's geoipupdate, for example as a service in your compose.yml:

    services:
    geoipupdate:
    image: ghcr.io/maxmind/geoipupdate
    restart: unless-stopped
    environment:
    GEOIPUPDATE_ACCOUNT_ID: '123456'
    GEOIPUPDATE_LICENSE_KEY: '<your licence key>'
    GEOIPUPDATE_EDITION_IDS: GeoLite2-City
    GEOIPUPDATE_FREQUENCY: '72' # hours
    volumes: ['./geoip:/usr/share/GeoIP']
  • DB-IP Lite ("IP to City Lite", MMDB format): download it from db-ip.com once a month. It is licensed under CC BY 4.0, which asks for attribution to DB-IP.

Then mount the folder into the app and set the path:

services:
app:
volumes: ['./geoip:/data/geoip:ro']
GEOIP_DB=/data/geoip/GeoLite2-City.mmdb

The app reads the file on the first sign-in and notices a newer one within 10 minutes; no restart is needed. If the file is missing or unreadable, it logs one warning and carries on without locations.

What is stored​

For each person, sol2flow keeps the devices and places they signed in from: the browser and operating system family (such as "Chrome", "macOS"), the country and city, and a keyed hash of the network (the first three parts of an IPv4 address, or the first three groups of an IPv6 address). No IP address and no full browser identification. Entries not used for 180 days are deleted automatically, and all of them go with the account. Alerts show the IP address partly hidden (203.0.x.x).

Logs and error tracking​

The app logs JSON lines to standard output (docker compose logs app). To keep them in files as well, set LOG_FILE: the app writes date-stamped files next to that path and starts new ones on its own:

VariableDefaultDescription
LOG_FILE(empty)Where the log files go and how they are named, e.g. /data/logs/app.log; standard output keeps the lines too. Empty = standard output only.
LOG_FILE_FREQUENCYdailyA new file every day (daily, app-2026-09-30.log) or every hour (hourly, app-2026-09-30-14.log), in UTC; none = no date in the name (app.log).
LOG_FILE_MAX_SIZE(empty)Empty = no size limit. With a size such as 20m (k, m or g), a full file continues in app-2026-09-30.1.log, then .2.log and so on.
LOG_FILE_MAX_FILES10How many log files of this name to keep, the newest ones; older ones are deleted. Daily without a size limit, that is ten days. 0 = keep all.

With LOG_FILE=/data/logs/app.log the folder holds, for example:

app-2026-09-29.log
app-2026-09-30.log
app-2026-09-30.1.log # only with LOG_FILE_MAX_SIZE: the day's second file
app.log -> app-2026-09-30.1.log
  • Days are UTC days: the new file starts at midnight UTC, not at local midnight.
  • app.log always points to the current file (a symbolic link), so tail -F /data/logs/app.log keeps following the log across new files. Use -F, not -f: tail -f stays on the file it opened first. With LOG_FILE_FREQUENCY=none and no size limit, app.log is the file itself; with a size limit the full ones become app.1.log, app.2.log … (higher = newer).
  • A restart continues today's file (the newest one of the day); nothing is overwritten. Retention only ever deletes files of this name: files of the older naming (app.log.1 …) and other files in the folder stay.
  • One file name per container. Two processes must never write the same LOG_FILE: with a separate worker sharing the logs folder, use e.g. /data/logs/web.log for the app and /data/logs/worker.log for the worker.

The image has a /data/logs directory for it: mount a volume there, as in the example. If the files can't be written (a wrong path, a missing permission, a full disk), the app logs one warning and keeps logging to standard output; file logging never stops it.

Error tracking sends errors to Sentry or to a service that speaks its protocol, such as the self-hosted Bugsink or GlitchTip. It is off until you set a DSN:

VariableDefaultDescription
SENTRY_DSN(empty)The DSN of your project (https://<key>@<host>/<project>). Empty = off: nothing is loaded or sent. From a file: SENTRY_DSN_FILE.
SENTRY_ENVIRONMENTproductionThe environment the errors are filed under, e.g. staging.
SENTRY_RELEASEthe image versionThe release the errors belong to; by default the version of the image, e.g. 1.4.0.
SENTRY_TRACES_SAMPLE_RATE0Share of requests traced for performance, from 0 to 1. 0 = errors only.
SENTRY_LOGSfalsetrue also sends log entries of level warn and above to Sentry's Logs. Bugsink and GlitchTip have no logs: there it has no effect.

What is sent: errors on the server (failed requests, errors the app logs, failed background jobs, tagged with the job's name) and in the browser (errors on pages, including the ones behind the "Something went wrong" page). What isn't: no session replays, no names, email addresses or IP addresses, no cookies, request bodies or query strings; tokens in addresses (public board links, invitations, password resets) are removed. The browser gets the DSN from the page, so a change needs only a restart of the app.

For example with Bugsink: run it next to sol2flow behind your reverse proxy (here on errors.example.com), sign in, create a team and a project, and copy the project's DSN into .env:

docker run -d --name bugsink --restart unless-stopped -p 127.0.0.1:8000:8000 -v bugsink:/data \
-e SECRET_KEY="$(openssl rand -base64 50)" -e CREATE_SUPERUSER=[email protected]:<a password> \
-e BASE_URL=https://errors.example.com -e BEHIND_HTTPS_PROXY=true bugsink/bugsink
SENTRY_DSN=https://<key>@errors.example.com/1

Browsers send their errors to the DSN's address too, so it must be reachable from outside, with HTTPS.

Storage​

VariableDefaultDescription
STORAGE_DRIVERlocallocal (a directory, mount a volume) or s3 (any S3-compatible object storage).
STORAGE_LOCAL_DIR/data/uploadsWhere files are stored with the local driver.
S3_ENDPOINT(empty)API address the server uses, e.g. https://s3.eu-central-1.amazonaws.com or https://<account>.r2.cloudflarestorage.com. Empty = AWS for S3_REGION.
S3_PUBLIC_ENDPOINT= S3_ENDPOINTAPI address browsers use for download links, when it differs from the server's (internal host name, private network).
S3_REGIONus-east-1The bucket's region (Cloudflare R2: auto).
S3_BUCKETsol2flowThe bucket; create it beforehand.
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY(empty)Credentials. Empty = the SDK's default chain (for example an IAM role).
S3_FORCE_PATH_STYLEtruetrue for https://host/bucket/key (most self-hosted stores), false for https://bucket.host/key.
MAX_UPLOAD_MB10Legacy. Upload limits are set in Admin → General → Uploads; this only sizes internal form requests.

Details and examples: Storage.

Email​

VariableDefaultDescription
MAIL_DRIVERlogsmtp to send email; log writes messages to the app log instead (testing only).
MAIL_FROMsol2flow <[email protected]>Sender name and address.
SMTP_HOSTlocalhostSMTP server.
SMTP_PORT1025Usually 587 (STARTTLS) or 465 (TLS).
SMTP_USER, SMTP_PASSWORD(empty)Credentials, if the server needs them.
SMTP_SECUREfalsetrue for implicit TLS (port 465); false for STARTTLS or plain.

Details: Email.

First administrator​

Used only on a start while the instance has no users; ignored afterwards.

VariableDefaultDescription
BOOTSTRAP_ADMIN_EMAIL(empty)Email of the first staff member, the Owner. Optional.
BOOTSTRAP_ADMIN_USERNAMEadminTheir username.
BOOTSTRAP_ADMIN_PASSWORD(empty)Their password. The account is only created when it's set; leave it empty to create the Owner with the command line instead (Installation).

Secrets from files (Docker secrets)​

Every secret can also be read from a file instead of the environment, the way the official PostgreSQL image does it: set X_FILE to the path of a file that contains the value of X. This is how Docker Swarm and Compose secrets reach a container: as files under /run/secrets/.

VariableFrom a file
AUTH_SECRETAUTH_SECRET_FILE
APP_ENCRYPTION_KEYAPP_ENCRYPTION_KEY_FILE
DATABASE_URLDATABASE_URL_FILE
S3_ACCESS_KEY_IDS3_ACCESS_KEY_ID_FILE
S3_SECRET_ACCESS_KEYS3_SECRET_ACCESS_KEY_FILE
SMTP_USERSMTP_USER_FILE
SMTP_PASSWORDSMTP_PASSWORD_FILE
BOOTSTRAP_ADMIN_PASSWORDBOOTSTRAP_ADMIN_PASSWORD_FILE
SENTRY_DSNSENTRY_DSN_FILE

The rules:

  • The whole file is the value, except one trailing line break (LF or CRLF), which docker secret create and most editors add. Spaces at the start or end belong to the value.
  • Set one or the other. When both X and X_FILE are set, the app refuses to start and names the variable. It also refuses to start when the file is missing or can't be read. An empty variable counts as not set.
  • Values never appear in the log, only variable names and paths.
  • The file must be readable by the container's user, which isn't root (uid 1001). Swarm secrets are (mode 0444 by default); with a file you mount yourself, check its permissions.
  • DATABASE_URL_FILE holds the whole connection string, not only the password. URL-encode a password with special characters (@, :, /, %, …) in it, or use a hex password as below.

Example for Docker Swarm, with PostgreSQL reading its password from a secret too. Create the secrets once:

DB_PASSWORD=$(openssl rand -hex 16)
printf '%s' "$DB_PASSWORD" | docker secret create sol2flow_db_password -
printf 'postgresql://sol2flow:%s@db:5432/sol2flow?schema=public' "$DB_PASSWORD" | docker secret create sol2flow_database_url -
unset DB_PASSWORD
openssl rand -base64 32 | docker secret create sol2flow_auth_secret -
openssl rand -base64 32 | docker secret create sol2flow_app_encryption_key -
openssl rand -hex 12 | tee /dev/stderr | docker secret create sol2flow_admin_password - # prints the password once

Keep a copy of the encryption key somewhere safe outside the swarm: a secret can't be read back (why it matters). Then a stack file, stack.yml:

services:
app:
image: ghcr.io/sol2flow/sol2flow:latest # pin a release in production
environment:
APP_URL: https://flow.example.com
AUTH_SECRET_FILE: /run/secrets/sol2flow_auth_secret
APP_ENCRYPTION_KEY_FILE: /run/secrets/sol2flow_app_encryption_key
DATABASE_URL_FILE: /run/secrets/sol2flow_database_url
BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/sol2flow_admin_password
LOG_FILE: /data/logs/app.log # optional: app-YYYY-MM-DD.log files, app.log → the current one
secrets: [sol2flow_auth_secret, sol2flow_app_encryption_key, sol2flow_database_url, sol2flow_admin_password]
ports: ['3000:3000']
volumes: [uploads:/data/uploads, logs:/data/logs]
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: sol2flow
POSTGRES_DB: sol2flow
POSTGRES_PASSWORD_FILE: /run/secrets/sol2flow_db_password
secrets: [sol2flow_db_password]
volumes: [pgdata:/var/lib/postgresql] # from PostgreSQL 18 not …/data (data: …/18/docker)
secrets:
sol2flow_auth_secret: { external: true }
sol2flow_app_encryption_key: { external: true }
sol2flow_database_url: { external: true }
sol2flow_db_password: { external: true }
sol2flow_admin_password: { external: true }
volumes: { pgdata: {}, uploads: {}, logs: {} }

Deploy it with docker stack deploy -c stack.yml sol2flow and sign in as admin with the printed password. Plain Docker Compose works the same way with file-based secrets (secrets: { sol2flow_auth_secret: { file: ./auth_secret } }).

Compose file only​

These are read by the example compose.yml, not by the app:

VariableDefaultDescription
SOL2FLOW_VERSIONlatestThe image tag to run. Pin a release in production.
POSTGRES_PASSWORDsol2flowPassword of the bundled PostgreSQL. Set a random one.

SOL2FLOW_VERSION from .env also reaches the container, where it is the version that error tracking reports. Pin a full version such as 1.4.0 rather than latest.

Not for production

The app also knows a few settings that exist only for development and automated tests. Don't set anything that isn't on this page in a production .env.