Skip to content
Source: docs/deployment.md
Commit: e309257dd1138f3355cff3dbd2c5c08ef6162708
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.

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.

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": "127.0.0.1:10993"},
    {"listen": "[::]:465", "backend": "127.0.0.1:10465"},
    {"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.

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.