Commit:
e309257dd1138f3355cff3dbd2c5c08ef6162708Source 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
SIGHUPas 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:
Or build the static FROM scratch image yourself:
docker compose¶
The repo ships an example docker-compose.yml that fronts
a mailcow backend on the standard ports. Adjust the routes/backends, then:
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:
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:
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.