Skip to content

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:

  1. A minimal HTTP-only Nginx configuration is written first, so the ACME server can fetch the challenge file from the host's webroot.

  2. The plugin registers (or reuses) an ACME account with the configured email and requests a certificate for the virtual host's hostname.

  3. The certificate authority validates ownership by fetching a token over HTTP on port 80.

  4. 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.pem
  5. The 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:

python
# 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:

  1. iterates over every enabled virtual host;
  2. reads its fullchain.pem from disk and parses the expiry date;
  3. enqueues a renewal job (on the modoboa queue) for any certificate whose remaining validity is below the configured renewal threshold (default 30 days);
  4. 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:

python
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

SymptomLikely cause
Provisioning failed right after creating a hostDNS not propagated, or port 80 not reachable from the internet.
failed with an authorization errorWrong ACME_DIRECTORY_URL, missing account email, or a rate limit hit on production.
Certificate never renewsRQ scheduler not running the renewal cron, or the host is disabled.
Renewal runs but browsers still see the old certNginx 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.

Built with VitePress