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).
CLI Flags
Section titled “CLI Flags”| Flag | Meaning |
|---|---|
-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. |
-debug | Raises the log level to debug at runtime, regardless of log_level. |
-demo | Demo mode: local SMTP sink plus demo tenants. Not for production use. |
-version | Print version, build number and build timestamp, then exit. |
Environment Variables
Section titled “Environment Variables”Intended for containers, where a mounted config.json is impractical:
| Variable | Effect |
|---|---|
FORMGW_DATA | Data directory (same as -data) |
FORMGW_HOST | Listen address, overrides host |
FORMGW_PORT | Listen port, overrides port |
FORMGW_CROWDSEC_URL | LAPI URL; if set, reporting is enabled automatically |
FORMGW_CROWDSEC_MACHINE_ID | Watcher login, overrides crowdsec.machine_id |
FORMGW_CROWDSEC_PASSWORD | Watcher password (Docker secret), overrides crowdsec.password |
TZ | Timezone 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.
config.json
Section titled “config.json”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 }}Server
Section titled “Server”| Key | Default | Meaning |
|---|---|---|
host | 127.0.0.1 | Listen address. Deliberately local — set to 0.0.0.0 for direct access without a proxy. |
port | 8080 | Listen 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_header | X-Forwarded-For | Header carrying the client IP behind the proxy. |
Database
Section titled “Database”| Key | Meaning |
|---|---|
database_path | Path to the SQLite file, relative to the data directory or absolute. |
database_dsn | If set ⇒ PostgreSQL instead of SQLite, e.g. postgres://user:pass@host:5432/db. Takes precedence over database_path. |
Secrets
Section titled “Secrets”| Key | Meaning |
|---|---|
jwt_secret | Signs the admin access tokens (HS256). Generated randomly on first start; the gateway refuses to start if it’s under 32 characters. |
encryption_key | base64-encoded 32 bytes. Encrypts SMTP passwords and signing secrets in the database. |
admin_initial_password | Only 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.
Sessions and API Keys
Section titled “Sessions and API Keys”| Key | Default | Meaning |
|---|---|---|
access_token_ttl_minutes | 60 | Lifetime 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_minute | 120 | Requests per API key per minute; 0 disables the limit. Applies only to the Admin API — the Public API has its own pipeline. |
login_max_attempts | 10 | Failed 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.
| Key | Default | Meaning |
|---|---|---|
crowdsec.enabled | false | Enables reporting. |
crowdsec.lapi_url | — | Base URL of the LAPI, without /v1, e.g. http://127.0.0.1:8080. |
crowdsec.machine_id | — | Watcher login, created with cscli machines add form-gateway --password <pw>. |
crowdsec.password | — | Password of that watcher. A secret — prefer FORMGW_CROWDSEC_PASSWORD. |
crowdsec.score_threshold | 100 | Sum of signal weights at which a report is sent. |
crowdsec.window_minutes | 10 | Observation window per IP. |
crowdsec.ban_duration | 4h | Ban duration of the decision — also the cooldown: the same IP is not reported again during this time. |
crowdsec.decision_type | ban | Remediation for the bouncer, e.g. captcha as well. |
crowdsec.origin | crowdsec | Decision origin in cscli decisions list. Must be a value CrowdSec knows. |
crowdsec.scenario | techeve/form-gateway | Scenario name in CrowdSec. |
crowdsec.insecure_skip_verify | false | Skip TLS verification — only for a LAPI with a self-signed certificate on your own network. |
Logging
Section titled “Logging”| Key | Default | Meaning |
|---|---|---|
log_level | info | debug, info, warn or error. |
access_log | true | Logs 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.
Applying Changes
Section titled “Applying Changes”config.json is read at startup — restart the gateway after making a
change:
sudo systemctl restart form-gateway # systemddocker compose restart # DockerIf 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.