Distributes renewed TLS certificates to web servers (Apache, Nginx, Docker) over SSH/SFTP. Backs up old certificates, applies ownership declaratively, and reloads the service. Works as a declarative remote executor: a YAML inventory describes the desired state, and the distributor reconciles on each run.
Designed to be used together with TLS-Renewer, which renews certificates via a webhook API and places them in a local directory. The distributor reads that directory and propagates the certificates to dozens of web servers.
- Declarative inventory (
hosts.yml) — easy to version-control, review, and temporarily disable entries with#. - OS handlers — Rocky/AlmaLinux/CentOS/RHEL, Ubuntu/Debian, openSUSE/SLES. Easily extensible.
- Non-root user support — uploads to
/tmpthensudo mvto final destination. - Per-host grouping — multiple domains on the same server trigger a single reload.
- Operation modes:
--preflight— tests SSH connectivity only (no distribution)--dry-run— shows what would be done, without executing--domain X— distributes only one domain--force— redistributes even if already distributed
- Multiple files per entry:
cert_path— individual certificate or fullchainintermediate_path— separate intermediate.pem (ApacheSSLCertificateChainFile)ca_bundle_path— intermediate+root concatenated (PHPcurl.cainfo/openssl.cafile)
- Declarative ownership —
cert_owner,intermediate_owner,ca_bundle_owner. - post_commands — escape hatch for custom post-deploy actions.
- Automatic backup with timestamp before overwriting.
- Email notifications with HTML summary.
+------------------------------------------------------------------+
| Orchestrator host |
| |
| tls-distributor.timer (daily, configurable) |
| | |
| v |
| tls_distributor.py |
| | |
| +-- 1. Loads hosts.yml |
| +-- 2. For each domain: |
| | +-- .crt exists in CERT_DIR? No -> SKIP |
| | +-- Newer than .distributed? No -> SKIP |
| | | (unless --force) |
| | +-- Builds cert_content |
| | (cert alone, or fullchain if chain: true) |
| +-- 3. Groups entries by (user@host) |
| +-- 4. For each host (paramiko SSH/SFTP): |
| | +-- Backup of existing file (with timestamp) |
| | +-- Upload cert (chmod + chown if defined) |
| | +-- Upload intermediate (if intermediate_path) |
| | +-- Upload CA bundle (if ca_bundle_path) |
| | +-- Run post_commands (if any) |
| | +-- Reload web server (1x per host) |
| +-- 5. Sends HTML email summary |
+------------------------------------------------------------------+
|
v SSH/SFTP
+------------------------------------------+
| Web servers (Apache / Nginx / Docker) |
| Rocky, AlmaLinux, Ubuntu, etc. |
+------------------------------------------+
- Linux (tested on Rocky Linux 9)
- Python 3.10+
- SSH access with public-key authentication to all target servers
- For non-root users on targets:
sudo NOPASSWDconfigured - tls-renewer installed (or another process populating
CERT_DIRwith.crtfiles)
Python dependencies (requirements.txt):
paramiko>= 3.4.0pyyaml>= 6.0.0python-dotenv>= 1.0.0
git clone https://github.com/wgfreitas/tls-distributor.git
cd tls-distributor
sudo bash install.shAfter installation:
sudo $EDITOR /opt/tls-distributor/.env # SMTP, paths, SSH key
sudo $EDITOR /opt/tls-distributor/hosts.yml # Your host inventory
# Set up SSH access (one-time per host)
ssh-copy-id -i /root/.ssh/id_ed25519.pub user@host
# Validate before enabling automation
sudo tls-distribute --preflight # Test SSH on all hosts
sudo tls-distribute --dry-run # Simulate without executing
sudo tls-distribute --domain app.example.org --force # Test single host
# Enable daily execution
sudo systemctl enable --now tls-distributor.timerEach entry in the inventory describes a certificate for a domain on a specific host. See hosts.yml.example for examples covering all scenarios.
- domain: app.example.org # Certificate CN (must match the .csr)
host: 10.0.0.10 # SSH IP or hostname of target server
user: root # SSH user
os: rocky # OS (see handler table below)
webserver: apache # Web server type (informational)
cert_path: /etc/pki/tls/certs/app.crt # Destination path for the certificate
reload_cmd: systemctl reload httpd # Reload command (1x per host) chain: false # true = fullchain (cert+intermediate+root)
intermediate_path: /path/intermediate.pem
ca_bundle_path: /path/ca-bundle.pem
cert_owner: apache:apache
intermediate_owner: apache:apache
ca_bundle_owner: apache:apache
post_commands:
- chmod 640 /etc/pki/tls/certs/app.crt
- systemctl restart php-fpmos value |
Family | Default cert dir |
|---|---|---|
rocky, almalinux, centos, rhel |
RHEL | /etc/pki/tls/certs |
ubuntu, debian |
Debian | /etc/ssl/certs |
opensuse, sles |
RHEL-like | /etc/pki/trust/anchors |
Unknown OS falls back to Rocky. To add a new OS, edit os_handlers.py.
This field overwrites the destination file with only 2 certificates (intermediate + root from your internal CA). If the path points to a standard Mozilla CA bundle (e.g. WordPress's wp-includes/certificates/ca-bundle.crt, which has ~150 public CAs), you will break SSL verification for external sites (plugin updates, external APIs, etc.).
Use ca_bundle_path only if the application validates exclusively internal certificates.
For WordPress/Laravel/applications that make external HTTPS calls, use post_commands to sync the system's ca-bundle (which already includes Mozilla CAs + your internal CAs via update-ca-trust):
post_commands:
- cp /etc/pki/tls/certs/ca-bundle.crt /var/www/blog/wp-includes/certificates/ca-bundle.crt
- chown apache:apache /var/www/blog/wp-includes/certificates/ca-bundle.crt# Tests SSH on ALL unique hosts, without distributing
tls-distribute --preflight
# Simulates distribution without executing any remote action
tls-distribute --dry-run
# Full distribution (default)
tls-distribute
# Single domain only
tls-distribute --domain app.example.org
# Force redistribution (ignore .distributed marker)
tls-distribute --force
# Force a single domain
tls-distribute --domain app.example.org --force
# Use alternative inventory file
tls-distribute --hosts-file /tmp/hosts-test.yml- Validates required fields via the OS handler.
- Checks
CERT_DIR/<domain>.crt— if missing, SKIP. - Checks
cert_is_newer— comparesmtimeof.crtwith.distributedmarker. If already distributed and older, SKIP (unless--force). - Builds
cert_content:chain: false→ just the.crtcontentchain: true→ concatenatescert + intermediate.pem + root.pem
- Groups by
user@hostto optimize reload. - Connects via SSH using paramiko and the key in
SSH_KEY. - For each entry:
- Backup with timestamp:
<file>.bak.YYYYMMDDHHMMSS mkdir -pof destination directory- Upload via SFTP (non-root: upload to
/tmp+sudo mv) chownif*_ownerdefined- Same for
intermediate_pathandca_bundle_path - Run
post_commands
- Backup with timestamp:
- Reload (1x per host).
- Marks distributed: creates/touches
<domain>.distributedinCERT_DIR.
See .env.example. Main variables:
| Variable | Description | Default |
|---|---|---|
CERT_DIR |
Where .crt files are (tls-renewer output) |
/opt/tls-renewer/certs |
CHAIN_DIR |
intermediate.pem and root.pem |
/opt/tls-distributor/chain |
HOSTS_FILE |
YAML inventory | /opt/tls-distributor/hosts.yml |
SSH_KEY |
SSH private key | /root/.ssh/id_ed25519 |
SSH_PORT / SSH_TIMEOUT |
SSH config | 22 / 30 |
SMTP_* |
Email settings | — |
- Edit
hosts.ymland add the entry. - Set up passwordless SSH:
ssh-copy-id -i /root/.ssh/id_ed25519.pub user@host
- For non-root users, configure
sudo NOPASSWD:ssh user@host sudo bash -c 'echo "user ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/user' sudo chmod 440 /etc/sudoers.d/user - Validate:
tls-distribute --preflight tls-distribute --domain new.example.org --dry-run --force
rm /opt/tls-renewer/certs/domain.example.org.distributed
tls-distribute --domain domain.example.orgOr simply tls-distribute --force (redistributes everything).
SFTP upload runs without sudo. For non-root, the distributor uploads to /tmp and then runs sudo mv. Confirm the user has sudo NOPASSWD: ALL:
ssh user@host "sudo -n whoami" # Should return 'root' without promptinggrep SSH_KEY /opt/tls-distributor/.env
ssh -i $SSH_KEY user@host "cat ~/.ssh/authorized_keys"
# Permissions on target:
# ~/.ssh/ 700
# ~/.ssh/authorized_keys 600Host runs Docker but reload_cmd is systemctl reload httpd. Adjust in hosts.yml:
reload_cmd: docker exec container_name apachectl graceful
# or
reload_cmd: docker compose -f /path/to/docker-compose.yml restartThe web server may be using a different file. Check VirtualHost config:
ssh root@host "grep -rE 'SSLCertificateFile|ssl_certificate' /etc/httpd/conf.d/" | grep domainAdjust cert_path in hosts.yml to point to the file actually referenced.
See the warning in the ca_bundle_path section. Restore the system bundle:
ssh root@host "cp /etc/pki/tls/certs/ca-bundle.crt /path/wp-includes/certificates/ca-bundle.crt"
ssh root@host "chown apache:apache /path/wp-includes/certificates/ca-bundle.crt"And remove ca_bundle_path from hosts.yml (use post_commands instead).
.envshould have permission600, owned by root.- Dedicated SSH key for the distributor (don't use a personal key).
sudo NOPASSWDonly where necessary, with specific users.- Automatic backup before overwriting (
*.bak.YYYYMMDDHHMMSS) — clean periodically. - Systemd unit with
ProtectSystem=strict,NoNewPrivileges=yes,PrivateTmp=yes. hosts.ymlcontains internal IPs — listed in.gitignore. Version onlyhosts.yml.example.
Edit os_handlers.py:
class MyOSHandler(BaseOSHandler):
name = "My OS"
family = "rhel" # or "debian"
@property
def default_cert_dir(self) -> str:
return "/custom/path"
# Register in the dictionary:
OS_HANDLERS["my_os"] = MyOSHandler()Then use os: my_os in hosts.yml.
Replace send_notification() in tls_distributor.py with integration to Slack, Microsoft Teams, or another service.
- tls-renewer — Renews certificates via webhook API and populates the
CERT_DIRread by this project.
Originally developed at SINFO/IRR/Fiocruz (Instituto René Rachou, Brazil) to distribute TLS certificates across dozens of web servers (Apache, Nginx, Docker) running Rocky Linux, AlmaLinux, and Ubuntu. Generalized and released as open source under MIT.
MIT — see LICENSE.