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
| Variable | Description |
|---|---|
APP_URL | The 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_URL | PostgreSQL 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_SECRET | At least 16 characters; use 32 random bytes: openssl rand -base64 32. Keys internal signatures (for example the anonymised rate-limit counters). |
APP_ENCRYPTION_KEY | At 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
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL | info | trace, debug, info, warn, error or fatal. Logs are JSON lines on standard output, and in a file with LOG_FILE (below). |
RUN_MIGRATIONS | true | Apply database migrations on start. Set false only if you run them another way. |
TRUST_PROXY_HOPS | 1 | How 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_URL | https://sol2flow.com | Where 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:
| Variable | Default | Description |
|---|---|---|
DATABASE_POOL_SIZE | 10; 5 with ROLE=worker | Connections 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_SIZE | 5; 2 with ROLE=web, which only queues jobs | Connections 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.
| Setup | Connections at most |
|---|---|
One container (no ROLE), the default compose.yml | 10 + 5 = 15 |
A ROLE=web container | 10 + 2 = 12 each |
A ROLE=worker container | 5 + 5 = 10 each |
Migrations on start, command-line tools, backups, psql | 1–2 each while they run |
| A rolling update that starts the new container first | the 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.
| Variable | Default | Description |
|---|---|---|
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:
- In the Cloudflare dashboard, open your domain and check Network → IP Geolocation is on (it is by default). That
adds the country (
CF-IPCountry). - For the city as well, go to Rules → Settings, find Managed Transforms, and turn on Add visitor location
headers. That adds
cf-ipcityandcf-region, among others. - Set
GEO_HEADERS=cloudflarein.envand apply it withdocker compose up -d.
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 yourcompose.yml:services:geoipupdate:image: ghcr.io/maxmind/geoipupdaterestart: unless-stoppedenvironment:GEOIPUPDATE_ACCOUNT_ID: '123456'GEOIPUPDATE_LICENSE_KEY: '<your licence key>'GEOIPUPDATE_EDITION_IDS: GeoLite2-CityGEOIPUPDATE_FREQUENCY: '72' # hoursvolumes: ['./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:
| Variable | Default | Description |
|---|---|---|
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_FREQUENCY | daily | A 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_FILES | 10 | How 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.logalways points to the current file (a symbolic link), sotail -F /data/logs/app.logkeeps following the log across new files. Use-F, not-f:tail -fstays on the file it opened first. WithLOG_FILE_FREQUENCY=noneand no size limit,app.logis the file itself; with a size limit the full ones becomeapp.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.logfor the app and/data/logs/worker.logfor 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:
| Variable | Default | Description |
|---|---|---|
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_ENVIRONMENT | production | The environment the errors are filed under, e.g. staging. |
SENTRY_RELEASE | the image version | The release the errors belong to; by default the version of the image, e.g. 1.4.0. |
SENTRY_TRACES_SAMPLE_RATE | 0 | Share of requests traced for performance, from 0 to 1. 0 = errors only. |
SENTRY_LOGS | false | true 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 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
| Variable | Default | Description |
|---|---|---|
STORAGE_DRIVER | local | local (a directory, mount a volume) or s3 (any S3-compatible object storage). |
STORAGE_LOCAL_DIR | /data/uploads | Where 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_ENDPOINT | API address browsers use for download links, when it differs from the server's (internal host name, private network). |
S3_REGION | us-east-1 | The bucket's region (Cloudflare R2: auto). |
S3_BUCKET | sol2flow | The 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_STYLE | true | true for https://host/bucket/key (most self-hosted stores), false for https://bucket.host/key. |
MAX_UPLOAD_MB | 10 | Legacy. Upload limits are set in Admin → General → Uploads; this only sizes internal form requests. |
Details and examples: Storage.
Email
| Variable | Default | Description |
|---|---|---|
MAIL_DRIVER | log | smtp to send email; log writes messages to the app log instead (testing only). |
MAIL_FROM | sol2flow <[email protected]> | Sender name and address. |
SMTP_HOST | localhost | SMTP server. |
SMTP_PORT | 1025 | Usually 587 (STARTTLS) or 465 (TLS). |
SMTP_USER, SMTP_PASSWORD | (empty) | Credentials, if the server needs them. |
SMTP_SECURE | false | true 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.
| Variable | Default | Description |
|---|---|---|
BOOTSTRAP_ADMIN_EMAIL | (empty) | Email of the first staff member, the Owner. Optional. |
BOOTSTRAP_ADMIN_USERNAME | admin | Their 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/.
| Variable | From a file |
|---|---|
AUTH_SECRET | AUTH_SECRET_FILE |
APP_ENCRYPTION_KEY | APP_ENCRYPTION_KEY_FILE |
DATABASE_URL | DATABASE_URL_FILE |
S3_ACCESS_KEY_ID | S3_ACCESS_KEY_ID_FILE |
S3_SECRET_ACCESS_KEY | S3_SECRET_ACCESS_KEY_FILE |
SMTP_USER | SMTP_USER_FILE |
SMTP_PASSWORD | SMTP_PASSWORD_FILE |
BOOTSTRAP_ADMIN_PASSWORD | BOOTSTRAP_ADMIN_PASSWORD_FILE |
SENTRY_DSN | SENTRY_DSN_FILE |
The rules:
- The whole file is the value, except one trailing line break (LF or CRLF), which
docker secret createand most editors add. Spaces at the start or end belong to the value. - Set one or the other. When both
XandX_FILEare 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 (mode0444by default); with a file you mount yourself, check its permissions. DATABASE_URL_FILEholds 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:
| Variable | Default | Description |
|---|---|---|
SOL2FLOW_VERSION | latest | The image tag to run. Pin a release in production. |
POSTGRES_PASSWORD | sol2flow | Password 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.
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.