Skip to content

Configuration

Configuration happens in three places, in this precedence: CLI flags beat environment variables, and those beat config.json.

Anything related to operating a customer site — recipients, origins, PoW bits, quotas — is not covered here, though; it lives per tenant in the database (Usage).

FlagMeaning
-data <dir>Data directory for config.json, database and logs. Default: the binary’s directory; typically /data in the container.
-config <file>Path to a config.json outside the data directory.
-debugRaises the log level to debug at runtime, regardless of log_level.
-demoDemo mode: local SMTP sink plus demo tenants. Not for production use.
-versionPrint version, build number and build timestamp, then exit.

Intended for containers, where a mounted config.json is impractical:

VariableEffect
FORMGW_DATAData directory (same as -data)
FORMGW_HOSTListen address, overrides host
FORMGW_PORTListen port, overrides port
FORMGW_CROWDSEC_URLLAPI URL; if set, reporting is enabled automatically
FORMGW_CROWDSEC_MACHINE_IDWatcher login, overrides crowdsec.machine_id
FORMGW_CROWDSEC_PASSWORDWatcher password (Docker secret), overrides crowdsec.password
TZTimezone for logs and timestamps, e.g. Europe/Berlin

TZ also works in minimal containers and on Windows — the timezone database is embedded in the binary.

Created on first start with secure random values and file permissions 0600. Complete example:

{
"host": "127.0.0.1",
"port": 8080,
"database_path": "gateway.db",
"jwt_secret": "",
"encryption_key": "",
"access_token_ttl_minutes": 60,
"admin_initial_password": "",
"log_level": "info",
"access_log": true,
"api_key_rate_limit_per_minute": 120,
"login_max_attempts": 10,
"trusted_proxies": [],
"proxy_header": "X-Forwarded-For",
"crowdsec": {
"enabled": false,
"lapi_url": "http://127.0.0.1:8080",
"machine_id": "form-gateway",
"password": "",
"score_threshold": 100,
"window_minutes": 10,
"ban_duration": "4h",
"decision_type": "ban",
"origin": "crowdsec",
"scenario": "techeve/form-gateway",
"insecure_skip_verify": false
}
}
KeyDefaultMeaning
host127.0.0.1Listen address. Deliberately local — set to 0.0.0.0 for direct access without a proxy.
port8080Listen port. Shared by the Admin UI and the Public API.
trusted_proxies[]IPs/CIDRs of your own reverse proxies whose X-Forwarded-For is trusted. Shorthands: loopback, private, linklocal.
proxy_headerX-Forwarded-ForHeader carrying the client IP behind the proxy.
KeyMeaning
database_pathPath to the SQLite file, relative to the data directory or absolute.
database_dsnIf set ⇒ PostgreSQL instead of SQLite, e.g. postgres://user:pass@host:5432/db. Takes precedence over database_path.
KeyMeaning
jwt_secretSigns the admin access tokens (HS256). Generated randomly on first start; the gateway refuses to start if it’s under 32 characters.
encryption_keybase64-encoded 32 bytes. Encrypts SMTP passwords and signing secrets in the database.
admin_initial_passwordOnly relevant during the very first seeding. Empty ⇒ a random password is generated and printed once to the console. Has no effect afterward.

Never check either value into version control. Anyone rotating these secrets should know that a new jwt_secret ends all sessions, and a new encryption_key renders the existing encrypted fields unusable.

KeyDefaultMeaning
access_token_ttl_minutes60Lifetime of an admin JWT. After expiry, the server responds with 401 and the frontend automatically logs out (no refresh-token flow).
api_key_rate_limit_per_minute120Requests per API key per minute; 0 disables the limit. Applies only to the Admin API — the Public API has its own pipeline.
login_max_attempts10Failed POST /auth/login attempts per 5-minute window, counted separately per client IP and per username; beyond that 429 with Retry-After. Successful logins do not count. 0 disables it — not recommended: the endpoint is unauthenticated and every attempt costs 64 MiB of argon2 memory.

CrowdSec (reporting attacks to the firewall)

Section titled “CrowdSec (reporting attacks to the firewall)”

When the gateway detects an attack pattern, it can report the IP to a CrowdSec Local API (LAPI). A bouncer registered there blocks it in the upstream firewall — the next request never reaches the service. Details and setup: Security → CrowdSec.

KeyDefaultMeaning
crowdsec.enabledfalseEnables reporting.
crowdsec.lapi_urlBase URL of the LAPI, without /v1, e.g. http://127.0.0.1:8080.
crowdsec.machine_idWatcher login, created with cscli machines add form-gateway --password <pw>.
crowdsec.passwordPassword of that watcher. A secret — prefer FORMGW_CROWDSEC_PASSWORD.
crowdsec.score_threshold100Sum of signal weights at which a report is sent.
crowdsec.window_minutes10Observation window per IP.
crowdsec.ban_duration4hBan duration of the decision — also the cooldown: the same IP is not reported again during this time.
crowdsec.decision_typebanRemediation for the bouncer, e.g. captcha as well.
crowdsec.origincrowdsecDecision origin in cscli decisions list. Must be a value CrowdSec knows.
crowdsec.scenariotecheve/form-gatewayScenario name in CrowdSec.
crowdsec.insecure_skip_verifyfalseSkip TLS verification — only for a LAPI with a self-signed certificate on your own network.
KeyDefaultMeaning
log_levelinfodebug, info, warn or error.
access_logtrueLogs every API request with method, path, status, duration, IP and username; 4xx as WARN, 5xx as ERROR.

Passwords, tokens, signing secrets and full form contents never end up in the log. Client IPs are stored in the database only as a truncated SHA-256 hash.

config.json is read at startup — restart the gateway after making a change:

Terminal window
sudo systemctl restart form-gateway # systemd
docker compose restart # Docker

If the file is invalid, the gateway will not start and will state the reason (secret too short, invalid port, unknown log level). For the error messages and what’s behind them, see Installation.