Commit:
09a9168d5919723e43f9a8723c19677c095872cdSource 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.
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:
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.