Installation
An installation consists of three files: the binary, a config.json,
and the database. The latter two are created automatically on first
start.
Just want to try it out? Jump to demo mode. Prefer containers? See running with Docker.
Requirements
Section titled “Requirements”To run it, the binary alone is enough — no runtime, no libraries. It’s CGO-free and statically linked, with timezone data embedded.
To build it:
| Tool | Version | Used for |
|---|---|---|
| Go | ≥ 1.26 (see go.mod) | Backend and binary |
| Node.js | LTS | Admin UI (Vite build) |
| Access to ORM++ | private Go module on gitlab.techeve.de | Persistence layer |
ORM++ is a private module hosted on the GitLab server. Set this once:
go env -w GOPRIVATE=gitlab.techeve.deCredentials come from the local keychain during development, and from the job token in CI.
Building the binary
Section titled “Building the binary”make build # npm audit → vite build → govulncheck → go buildThe result is bin/form-gateway. The security gates are part of the
build itself: if npm audit or govulncheck finds anything, the build
fails — so a vulnerable binary never gets produced in the first place.
For other target platforms (still CGO-free, so no cross-toolchain needed):
make build-linux # bin/form-gateway-linux-amd64make build-linux-arm64 # bin/form-gateway-linux-arm64make build-windows # bin/form-gateway-windows-amd64.exemake build-macos # bin/form-gateway-darwin-arm64 (Apple Silicon)make build-macos-intel # bin/form-gateway-darwin-amd64make build-all # all platformsFirst start
Section titled “First start”./bin/form-gatewayThe following happens on first start — automatically, and only once:
- A
config.jsonis created with cryptographically random secrets (JWT secret,encryption_key) and file permissions0600. - The database is created and migrated (SQLite, defaulting to
gateway.db). - The
systemandadminusers are created. The generated admin password is printed to the console exactly once.
After that, the gateway listens on http://127.0.0.1:8080 — the admin UI and the public API share the same port.
Choosing a data directory
Section titled “Choosing a data directory”Without any configuration, config.json and the database live
next to the binary. For a production server, they belong
somewhere else:
./form-gateway -data /var/lib/form-gatewayEquivalently via the environment: FORMGW_DATA=/var/lib/form-gateway.
All flags and variables are listed under Configuration.
Setup: route first, then tenant
Section titled “Setup: route first, then tenant”After logging into the admin UI, proceed in this order — a tenant without a route can’t send mail:
- Create an SMTP route and mark it as the shared default route. Use the test-send button to confirm the connection and login work.
- Create a tenant — enter the recipient address and the allowed origins.
- Copy the displayed site key into the form on the customer’s site.
For details, see Using the admin UI and Embedding a form.
Running continuously with systemd
Section titled “Running continuously with systemd”/etc/systemd/system/form-gateway.service:
[Unit]Description=Techeve Form GatewayAfter=network.target
[Service]User=formgwWorkingDirectory=/opt/form-gatewayExecStart=/opt/form-gateway/form-gateway -data /var/lib/form-gatewayRestart=on-failure
[Install]WantedBy=multi-user.targetsudo useradd --system --home /var/lib/form-gateway formgwsudo mkdir -p /opt/form-gateway /var/lib/form-gatewaysudo chown formgw /var/lib/form-gatewaysudo systemctl enable --now form-gatewaysudo journalctl -u form-gateway -f # the first-start admin password shows up hereThe binary handles SIGINT and SIGTERM with a graceful shutdown, cleanly
stopping the background workers too — systemctl restart never loses
a mail from the queue.
Reverse proxy and TLS
Section titled “Reverse proxy and TLS”The gateway speaks HTTP and binds to 127.0.0.1 by default. TLS is
terminated by a reverse proxy in front of it; the public API (/v1)
and the admin UI run over the same port and need no separate
configuration.
server { listen 443 ssl http2; server_name forms.example.de;
# ssl_certificate ... (e.g. via certbot)
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}If the gateway should be reachable directly, without a proxy, host
must be deliberately set to 0.0.0.0 — the default binds locally only,
on purpose.
Outbound connections
Section titled “Outbound connections”The server needs access to:
- the SMTP servers of the configured routes (port 587 or 465),
- iptoasn.com and check.torproject.org for the daily reputation refresh.
Without the second point, the gateway keeps running, but ASN and Tor detection then work off stale data.
PostgreSQL instead of SQLite
Section titled “PostgreSQL instead of SQLite”SQLite is the default and is more than sufficient for typical
deployments. Switching to PostgreSQL takes just one line in
config.json:
"database_dsn": "postgres://formgw:geheim@localhost:5432/formgw"That’s all there is to it — ORM++ makes both backends behave identically, and the application code never branches on which database is in use. Details: Database.
Updating
Section titled “Updating”sudo systemctl stop form-gatewaysudo cp bin/form-gateway-linux-amd64 /opt/form-gateway/form-gatewaysudo systemctl start form-gatewayORM++ applies schema changes itself on startup: additive changes
automatically, structural changes through versioned migration steps.
Still, back up the data directory before updating — config.json and
the database sit right next to each other, so a single tar is
enough.
Checking the running version:
./form-gateway -versioncurl -s http://127.0.0.1:8080/api/v1/healthIf startup fails
Section titled “If startup fails”| Message | Cause |
|---|---|
jwt_secret ist zu kurz | A hand-edited config.json. Must be at least 32 characters; alternatively, delete the file and let it be regenerated (note: a new encryption_key renders existing encrypted fields unreadable). |
encryption_key muss 32 Bytes lang sein | The key is base64-encoded and must decode to 32 bytes — don’t shorten or replace it. |
ungültiges log_level | Only debug, info, warn, and error are allowed. |
| Startup error due to schema drift | A model was changed without bumping the schema version — see Database. |
datenverzeichnis anlegen: permission denied | The service user has no write access to the -data path. |
For more detail, check the logs: ./form-gateway -debug raises the log
level to debug at runtime.