TLS certificates
When Let's Encrypt is enabled (see Configuration), the plugin issues and renews TLS certificates for every virtual host over the ACME protocol, using the acme client library.
How issuance works
When a virtual host is created (and Let's Encrypt is enabled), the provisioning chain runs an ACME HTTP-01 challenge:
A minimal HTTP-only Nginx configuration is written first, so the ACME server can fetch the challenge file from the host's webroot.
The plugin registers (or reuses) an ACME account with the configured email and requests a certificate for the virtual host's hostname.
The certificate authority validates ownership by fetching a token over HTTP on port 80.
On success, the certificate and key are written under the ACME storage path:
text<acme_account_storage_path>/ ├── webroot/ # HTTP-01 challenge files └── certs/ └── <hostname>/ ├── fullchain.pem └── privkey.pemThe final HTTPS Nginx configuration is generated, pointing at
fullchain.pem/privkey.pem, and Nginx is reloaded.
For this to succeed, each virtual host hostname must resolve in public DNS and be reachable on port 80 from the internet at issuance time.
Choosing the ACME environment
The certificate authority is selected with the ACME_DIRECTORY_URL Django setting:
# Production Let's Encrypt
ACME_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
# Staging (untrusted certs, much higher rate limits — use while testing)
ACME_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"TIP
Validate your whole setup against staging first. Let's Encrypt's production environment enforces strict rate limits; staging lets you create and tear down test virtual hosts without burning through them. The staging certificates are not browser-trusted — that's expected.
Automatic renewal
Certificates are renewed by a cron job, not while the instance handles web requests. The job check_certificate_renewals:
- iterates over every enabled virtual host;
- reads its
fullchain.pemfrom disk and parses the expiry date; - enqueues a renewal job (on the
modoboaqueue) for any certificate whose remaining validity is below the configured renewal threshold (default30days); - skips hosts that don't have a certificate yet (those go through normal provisioning) and disabled hosts (their challenge can't be served).
A renewal overwrites fullchain.pem / privkey.pem in place and reloads Nginx so it re-reads the new files.
Scheduling the job
There is no plugin-level hook for cron registration yet, so the job must be registered in your instance's cron_config.py — the file rqcron reads at scheduler startup (python manage.py rqcron <path>). Add:
from modoboa_pro.virtualhosts import jobs as virtualhosts_jobs
# Daily at 03:17 — renews any Let's Encrypt certificate whose remaining
# validity is below the configured threshold (30 days by default). The
# off-peak hour keeps ACME traffic out of business-hours latency budgets.
register(
virtualhosts_jobs.check_certificate_renewals,
queue_name="modoboa",
cron="17 3 * * *",
)The scanner enqueues one renewal job per expiring certificate on the modoboa queue, so each renewal runs independently — a slow ACME call for one host doesn't block the others.
WARNING
Make sure the RQ scheduler is actually running with this cron_config.py, otherwise certificates will silently expire. You can confirm renewals are happening by watching the modoboa.jobs logger, which logs each scheduled renewal and each successful issuance/renewal.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Provisioning failed right after creating a host | DNS not propagated, or port 80 not reachable from the internet. |
failed with an authorization error | Wrong ACME_DIRECTORY_URL, missing account email, or a rate limit hit on production. |
| Certificate never renews | RQ scheduler not running the renewal cron, or the host is disabled. |
| Renewal runs but browsers still see the old cert | Nginx wasn't reloaded — check the privileged worker and your reload mechanism (see Nginx integration). |
Each step logs to the modoboa.jobs logger — start there when diagnosing a failure, then use Retry provisioning once the cause is fixed.