Skip to content
Source: docs/deployment.md
Commit: 09a9168d5919723e43f9a8723c19677c095872cd
Source updated:  ·  Edit this page

Deployment

Ansible, graceful upgrades, Docker, and PROXY protocol deployment guidance.

Static Linux binaries are available from the GitHub releases page. Building from source or with Ansible requires Go 1.27.1 or newer.

Deploy

cd ansible
cp inventory.example inventory
cp group_vars/tlsgate.example.yml group_vars/tlsgate.yml
# Edit both files for your host, routes, and policy.
ansible-playbook --syntax-check playbook.yml
ansible-playbook playbook.yml --ask-become-pass

The real inventory and group variables are ignored; only sanitized examples are committed. To temporarily allow unknown fingerprints during initial setup, set allow_unknown: true in group_vars/tlsgate.yml and re-run.

To use JA4 instead of JA3, set fingerprint: ja4 in the group variables. Switching the method on an existing database refuses to start until you also set reset_fingerprints: true for one deployment. This purges stored fingerprints; set it back to false and re-approve clients afterward.

Graceful upgrades

tlsgate sits in front of IMAPS and SMTPS on the mail host, so a hard restart drops live mail sessions mid-transfer — a poor trade for updating a noise filter. Deploys therefore hand off rather than restart, using tableflip.

On SIGHUP the running process re-execs the newly installed binary and passes it the listening sockets over an inherited control fd. The new process starts serving immediately; the old one keeps running its existing connections until they finish, up to --drain-timeout (default 1 hour). No connection is refused in the gap, because the socket is inherited rather than rebound.

The hour matters: an IMAP IDLE session legitimately sits quiet for half an hour, and a short drain would kill exactly the sessions this is meant to protect. A SIGTERM/SIGINT stop is different — that drains for 10 seconds and exits, since the operator asked for it to stop.

The unit is Type=notify with NotifyAccess=all, because after a handoff the serving process is a child of the original main PID and has to tell systemd to track it. The playbook uses systemctl reload-or-restart, which reloads a running service and falls back to a start on first deploy.

First deploy of a tableflip build must be a hard restart. A pre-tableflip tlsgate treats SIGHUP as a clean stop and never comes back — reloading one is how a deploy takes mail down instead of keeping it up. Confirm every host is running a tableflip build before relying on the graceful path.

Docker

Prebuilt multi-arch images (linux/amd64, linux/arm64) are published to GHCR:

docker pull ghcr.io/kilo666mj/tlsgate:latest

Or build the static FROM scratch image yourself:

docker build -t tlsgate .

docker compose

The repo ships an example docker-compose.yml that fronts a mailcow backend on the standard ports. Adjust the routes/backends, then:

docker compose up -d

It uses host networking so the localhost backends are reachable and tlsgate sees real client source IPs; a bridge-network variant is included as a comment.

docker run

Run it with persistent state mounted at the default database/config directory:

docker run --rm \
  --network host \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  -v tlsgate-data:/var/lib/tlsgate \
  ghcr.io/kilo666mj/tlsgate:latest serve \
    --route '[::]:993=127.0.0.1:10993' \
    --route '[::]:465=127.0.0.1:10465' \
    --allow-unknown

The published image runs as UID/GID 65532. Named volumes initialize with the correct ownership. For a host bind mount, create the directory and run chown 65532:65532 <directory>; add :Z on SELinux hosts. The capability is needed only for listener ports below 1024.

These loopback routes are the simplest form. For a mail server in production, point each route at a PROXY-aware listener instead, as described in Mail login backends; otherwise the mail server sees every client as tlsgate.

Each --route LISTEN=BACKEND adds a proxied port; repeat it for as many services as you need (host or container-network backend addresses):

docker run --rm \
  --network host \
  -v tlsgate-data:/var/lib/tlsgate \
  ghcr.io/kilo666mj/tlsgate:latest serve \
    --route '[::]:1993=127.0.0.1:10993'

This high-port form needs no bind capability and is suitable for enrollment testing. With bridge networking, 127.0.0.1 is the container itself; use a backend address reachable from that network (and expect Docker NAT to obscure the original client address).

Preserving client addresses with PROXY protocol

For a backend such as nginx that supports PROXY protocol, pass --proxy-protocol v2. tlsgate then writes a binary PROXY v2 header before the original, byte-identical TLS stream so the backend can recover the client's address. This global default applies to routes without an override and is disabled by default. Enable it only for backends that expect PROXY protocol.

Mail login backends (IMAPS, SMTPS, submission, POP3S)

Use PROXY protocol for every mail route that authenticates users. Without it, the mail server attributes every connection to tlsgate's address, or to a container network gateway when the backend is a Docker-published port. That has two consequences:

  • Log-based bans (for example Mailcow's netfilter or fail2ban) cannot see the real client and stop working.
  • Relay rules that trust the local network apply to every client. Mailcow's Postfix includes its Docker network in mynetworks, so an unproxied SMTPS route to a published port relays mail without authentication for any client that passes the fingerprint gate.

Route each mail port to a listener that expects PROXY protocol and set proxy_protocol to v2 on that route. Mailcow already defines such listeners inside its containers: Dovecot imaps_haproxy on port 10993 and Postfix smtps-haproxy on port 10465. Reach them on the containers' network addresses, not through the host ports Docker publishes:

{"listen": "[::]:993", "backend": "<dovecot-container-ip>:10993", "proxy_protocol": "v2"},
{"listen": "[::]:465", "backend": "<postfix-container-ip>:10465", "proxy_protocol": "v2"}

Dovecot accepts PROXY headers only from haproxy_trusted_networks; add tlsgate's source address on that network (usually the bridge gateway) in Mailcow's data/conf/dovecot/extra.conf and restart Dovecot. Postfix's PROXY-aware services accept headers from any peer, so keep them reachable only from the container network.

After switching, confirm that Dovecot's rip= and Postfix's connect from entries show real client addresses, and that an unauthenticated RCPT TO for an external domain on port 465 is rejected. tlsgate doctor warns about mail login routes on ports 465, 587, 993, and 995 that forward to a loopback or private backend without PROXY protocol.

Different policies per route

Append protocol=tls|smtp, allow-unknown=true|false, and/or proxy-protocol=off|v2 to a route to override the global defaults for that listener. Existing LISTEN=BACKEND arguments retain their behavior. Overrides are independent of flag ordering.

Routes and runtime policy can live in the JSON config. For strict mail filtering alongside HTTPS enrollment:

{
  "database": "/var/lib/tlsgate/db.sqlite",
  "fingerprint": "ja4",
  "routes": [
    {"listen": "[::]:993", "backend": "192.0.2.250:10993", "proxy_protocol": "v2"},
    {"listen": "[::]:465", "backend": "192.0.2.253:10465", "proxy_protocol": "v2"},
    {"listen": "[::]:443", "backend": "127.0.0.1:1443", "allow_unknown": true, "proxy_protocol": "v2"}
  ]
}

The routes share the fingerprint database, Gatehub decisions, trusted source ranges, and connection limits. Approvals and explicit blocks apply across routes to the same fingerprint. allow-unknown=true permits pending entries; it does not override an explicit block. A strict route still rejects a pending fingerprint first recorded by an enrollment route.

Ansible writes these values into /etc/tlsgate/config.json; its systemd unit only selects that file. Command-line flags remain supported and override the corresponding JSON values, while command-line routes replace configured routes. tlsgate doctor --config <path> reports each listener's effective settings.

Isolating routes in additional processes

Set tlsgate_extra_instances when routes on one host need independent global connection budgets, databases, metrics endpoints, or Gatehub identities. The playbook registers each node, writes its config and systemd unit, and reloads the primary service before starting an additional service that takes ownership of a removed listener. Keep every listener unique across instances.

An instance entry contains name, config, node_host, allowed_cert_name, and a complete config_data mapping. Its database parent directory is created automatically. Secrets may reference protected inventory variables; the config deployment and registration tasks suppress their output. The first split can require hard_restart: true when a previous tableflip parent is still draining and prevents another handoff. This interrupts active connections, so schedule and approve that transition explicitly.

Local Prometheus integration

Set metrics_listen to a loopback address such as 127.0.0.1:9192. When /etc/prometheus/prometheus.yml exists, the Ansible playbook adds a managed tlsgate scrape job, validates the complete installed configuration, reloads Prometheus, and verifies the endpoint. Additional isolated instances with a non-empty metrics_listen value are added to the same job. Each target receives an instance label beginning with the inventory hostname and a gate_node label containing its Gatehub instance ID; this preserves node identity through hostname-filtered Prometheus federation.

If a host already has a tlsgate scrape job, the playbook preserves it instead of adding a duplicate and normalizes the legacy tlsgate_instance target label to gate_node.

Set tlsgate_configure_local_prometheus: false if another configuration manager owns the Prometheus file. Override tlsgate_prometheus_config when it is not at the default path. Metrics stay on loopback and require no public firewall rule.

Managed SMTP correlation collector

Set tlsgate_smtp_collector_enabled: true to install the repository's bounded collector, hardened oneshot service, timer, and logrotate policy. Configure the event, verdict, output, instance, schedule, and optional network-prefix paths with the tlsgate_smtp_collector_* variables shown in the example group vars. tlsgate_smtp_collector_report_gatehub: true enables authenticated report-only uploads, including bounded campaign classifications; it never enables SMTP blocking. An optional reviewed Rspamd exporter can be installed with tlsgate_smtp_rspamd_exporter_enabled, but the MTA remains responsible for loading and reloading that file safely.

nginx listener configuration

For nginx, the corresponding listener and real-IP configuration is typically:

listen 8443 ssl proxy_protocol;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;

Keep the backend listener private and restrict set_real_ip_from to the tlsgate address. A backend that does not expect the header will interpret it as invalid TLS and reject the connection.

Site-specific systemd-to-Docker migration

migrate-to-docker.sh is an example for the original mailcow deployment. It assumes particular service names, database paths, ports, host networking, and SELinux behavior. Inspect the plan without changing the host:

./migrate-to-docker.sh --dry-run

Override IMAGE, BASE, FINGERPRINT, ROUTE_IMAP, and ROUTE_SMTP as needed. A real run stops the detected service briefly to copy a clean SQLite database. Keep a recovery session open and verify a real client before agreeing to disable the old unit.