Skip to content

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.

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:

ToolVersionUsed for
Go≥ 1.26 (see go.mod)Backend and binary
Node.jsLTSAdmin UI (Vite build)
Access to ORM++private Go module on gitlab.techeve.dePersistence layer

ORM++ is a private module hosted on the GitLab server. Set this once:

Terminal window
go env -w GOPRIVATE=gitlab.techeve.de

Credentials come from the local keychain during development, and from the job token in CI.

Terminal window
make build # npm audit → vite build → govulncheck → go build

The 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):

Terminal window
make build-linux # bin/form-gateway-linux-amd64
make build-linux-arm64 # bin/form-gateway-linux-arm64
make build-windows # bin/form-gateway-windows-amd64.exe
make build-macos # bin/form-gateway-darwin-arm64 (Apple Silicon)
make build-macos-intel # bin/form-gateway-darwin-amd64
make build-all # all platforms
Terminal window
./bin/form-gateway

The following happens on first start — automatically, and only once:

  1. A config.json is created with cryptographically random secrets (JWT secret, encryption_key) and file permissions 0600.
  2. The database is created and migrated (SQLite, defaulting to gateway.db).
  3. The system and admin users 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.

Without any configuration, config.json and the database live next to the binary. For a production server, they belong somewhere else:

Terminal window
./form-gateway -data /var/lib/form-gateway

Equivalently via the environment: FORMGW_DATA=/var/lib/form-gateway. All flags and variables are listed under Configuration.

After logging into the admin UI, proceed in this order — a tenant without a route can’t send mail:

  1. Create an SMTP route and mark it as the shared default route. Use the test-send button to confirm the connection and login work.
  2. Create a tenant — enter the recipient address and the allowed origins.
  3. Copy the displayed site key into the form on the customer’s site.

For details, see Using the admin UI and Embedding a form.

/etc/systemd/system/form-gateway.service:

[Unit]
Description=Techeve Form Gateway
After=network.target
[Service]
User=formgw
WorkingDirectory=/opt/form-gateway
ExecStart=/opt/form-gateway/form-gateway -data /var/lib/form-gateway
Restart=on-failure
[Install]
WantedBy=multi-user.target
Terminal window
sudo useradd --system --home /var/lib/form-gateway formgw
sudo mkdir -p /opt/form-gateway /var/lib/form-gateway
sudo chown formgw /var/lib/form-gateway
sudo systemctl enable --now form-gateway
sudo journalctl -u form-gateway -f # the first-start admin password shows up here

The binary handles SIGINT and SIGTERM with a graceful shutdown, cleanly stopping the background workers too — systemctl restart never loses a mail from the queue.

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.

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.

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.

Terminal window
sudo systemctl stop form-gateway
sudo cp bin/form-gateway-linux-amd64 /opt/form-gateway/form-gateway
sudo systemctl start form-gateway

ORM++ 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:

Terminal window
./form-gateway -version
curl -s http://127.0.0.1:8080/api/v1/health
MessageCause
jwt_secret ist zu kurzA 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 seinThe key is base64-encoded and must decode to 32 bytes — don’t shorten or replace it.
ungültiges log_levelOnly debug, info, warn, and error are allowed.
Startup error due to schema driftA model was changed without bumping the schema version — see Database.
datenverzeichnis anlegen: permission deniedThe 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.