Zimbra manages TLS centrally. One server certificate is used by every component (proxy, mailbox server, MTA, LDAP), and optional per-domain certificates are served by the nginx proxy using SNI when you host several mail domains with their own web client hostnames. All of it is driven by zmcertmgr and stored in LDAP. Don't copy certificate files into component config directories by hand.
This guide assumes the layout from Zimbra: Architecture and Administration and runs commands as the zimbra user unless shown otherwise.
Concepts that trip people up¶
- Zimbra wants the complete chain, including the root CA. Most software is happy with "leaf + intermediates".
zmcertmgr verifycrtbuilds and checks the whole path, so the CA chain file you give it must end with the self-signed root. A missing root is the number one deployment failure. - The key must match the certificate, and the key must not be passphrase-protected. Services start unattended.
- Files must be readable by the
zimbrauser. Stage them in a directory it owns (for example/tmp/certs, ownedzimbra:zimbra, mode0700), not root's home. - PEM format only. Convert DER (
.cer/.der) or PKCS#12 (.pfx) first (see Troubleshooting). - Use RSA keys for the main server certificate unless you've confirmed your Zimbra version supports ECDSA end to end. Older
zmcertmgrversions only handle RSA.
The server ("commercial") certificate¶
"Commercial" in Zimbra terms just means "not Zimbra's own self-signed one". This includes any public CA such as Let's Encrypt.
# 1. As root: stage the files where the zimbra user can read them
sudo install -d -o zimbra -g zimbra -m 0700 /tmp/certs
sudo cp server.crt chain.crt server.key /tmp/certs/ # leaf, intermediates+root, private key
sudo chown zimbra:zimbra /tmp/certs/*
# 2. The private key goes in Zimbra's expected location
sudo cp /tmp/certs/server.key /opt/zimbra/ssl/zimbra/commercial/commercial.key
sudo chown zimbra:zimbra /opt/zimbra/ssl/zimbra/commercial/commercial.key
# 3. As zimbra: verify, then deploy
sudo su - zimbra
/opt/zimbra/bin/zmcertmgr verifycrt comm \
/opt/zimbra/ssl/zimbra/commercial/commercial.key \
/tmp/certs/server.crt /tmp/certs/chain.crt
A good result reads:
** Verifying '/tmp/certs/server.crt' against '/opt/zimbra/ssl/zimbra/commercial/commercial.key'
Certificate '/tmp/certs/server.crt' and private key '/opt/zimbra/ssl/zimbra/commercial/commercial.key' match.
** Verifying '/tmp/certs/server.crt' against '/tmp/certs/chain.crt'
Valid certificate chain: /tmp/certs/server.crt: OK
Only when both checks pass:
/opt/zimbra/bin/zmcertmgr deploycrt comm /tmp/certs/server.crt /tmp/certs/chain.crt
zmcontrol restart
/opt/zimbra/bin/zmcertmgr viewdeployedcrt # confirm subject, SANs and expiry per service
Building chain.crt¶
Your CA gives you intermediate certificate(s). Append the root they chain up to:
# Who issued the last intermediate?
openssl crl2pkcs7 -nocrl -certfile intermediates.crt | openssl pkcs7 -print_certs -noout | grep -E 'subject|issuer'
# Build the chain: intermediates first, root last
cat intermediates.crt root.crt > chain.crt
Find the root by the issuer name on the top intermediate, and download it from the CA's own repository. It's also in the OS trust store: /etc/pki/ca-trust/extracted/pem/ (RHEL) or /usr/share/ca-certificates/ (Debian).
Let's Encrypt with automatic renewal¶
Zimbra's nginx proxy owns port 80, so the simplest certbot approach is --standalone with the proxy briefly stopped.
sudo dnf install certbot # or apt install certbot
sudo certbot certonly --standalone --key-type rsa --rsa-key-size 4096 \
-d mail.domain.tld -d webmail.domain.tld \
--pre-hook "su - zimbra -c 'zmproxyctl stop'" \
--post-hook "su - zimbra -c 'zmproxyctl start'"
certbot writes cert.pem, chain.pem (intermediates only), fullchain.pem and privkey.pem under /etc/letsencrypt/live/mail.domain.tld/. For Zimbra you need chain.pem plus the root:
LIVE=/etc/letsencrypt/live/mail.domain.tld
# Identify the root the current intermediate chains to (e.g. "ISRG Root X1" for RSA chains)
openssl x509 -in "$LIVE/chain.pem" -noout -issuer
Then automate the deploy step with a deploy hook. certbot runs it only after a successful renewal:
#!/usr/bin/env bash
# /etc/letsencrypt/renewal-hooks/deploy/zimbra.sh
set -euo pipefail
LIVE=/etc/letsencrypt/live/mail.domain.tld
ROOT=/etc/pki/ca-trust/extracted/pem/isrg-root-x1.pem # path to the matching root in PEM form
STAGE=/opt/zimbra/ssl/letsencrypt
install -d -o zimbra -g zimbra -m 0700 "$STAGE"
cp "$LIVE/cert.pem" "$STAGE/cert.pem"
cat "$LIVE/chain.pem" "$ROOT" > "$STAGE/chain.pem"
cp "$LIVE/privkey.pem" /opt/zimbra/ssl/zimbra/commercial/commercial.key
chown zimbra:zimbra "$STAGE"/*.pem /opt/zimbra/ssl/zimbra/commercial/commercial.key
su - zimbra -c "/opt/zimbra/bin/zmcertmgr verifycrt comm /opt/zimbra/ssl/zimbra/commercial/commercial.key $STAGE/cert.pem $STAGE/chain.pem"
su - zimbra -c "/opt/zimbra/bin/zmcertmgr deploycrt comm $STAGE/cert.pem $STAGE/chain.pem"
su - zimbra -c "zmcontrol restart"
sudo chmod 0750 /etc/letsencrypt/renewal-hooks/deploy/zimbra.sh
sudo certbot renew --dry-run # exercises the hooks without issuing
If your distribution's trust store doesn't ship the root as a single file, save a copy from the CA's website once (for example letsencrypt.org/certificates/) and point ROOT at it. When Let's Encrypt changes chains, the openssl x509 … -issuer check above tells you which root to use.
zmcontrol restart briefly interrupts service. Renewals happen about every 60 days, so schedule the certbot timer for a quiet hour.
Self-signed certificates (lab or internal only)¶
/opt/zimbra/bin/zmcertmgr createca -new
/opt/zimbra/bin/zmcertmgr createcrt -new -days 730 \
-subjectAltNames "mail.domain.tld,webmail.domain.tld"
/opt/zimbra/bin/zmcertmgr deployca
/opt/zimbra/bin/zmcertmgr deploycrt self
zmcontrol restart
Clients will warn until they trust Zimbra's CA (/opt/zimbra/conf/ca/ca.pem). Distribute it to your machines' trust stores rather than clicking through warnings.
Per-domain certificates with SNI¶
When one Zimbra server hosts several mail domains, each can have its own web client hostname (for example mail.domain-a.tld and mail.domain-b.tld) with its own certificate. The nginx proxy picks the right certificate using SNI, the hostname the client sends in the TLS handshake.
Before SNI was available, every certificate needed its own dedicated IP address. That's why older guides, and Zimbra's own wiki, set zimbraVirtualIPAddress. With SNI enabled, the hostname is enough for modern clients. Set an IP only if you also need to serve clients that don't send SNI.
Prerequisites¶
- The domain exists in Zimbra (
zmprov gad). - DNS for the virtual hostname points at the Zimbra proxy.
- You have the domain's key, certificate and CA chain (a wildcard like
*.domain-b.tldworks well here).
Steps¶
sudo su - zimbra
cd /tmp/certs # owned by zimbra, containing:
# wildcard.domain-b.tld.key private key
# wildcard.domain-b.tld.crt leaf certificate
# wildcard.domain-b.tld-bundle.crt intermediate(s) + root
# 1. Map the hostname to the domain (and optionally a dedicated IP)
zmprov md domain-b.tld zimbraVirtualHostName mail.domain-b.tld
# zmprov md domain-b.tld +zimbraVirtualIPAddress 192.0.2.20 # only for non-SNI clients
# 2. Check that the key matches and the chain verifies
/opt/zimbra/bin/zmcertmgr verifycrt comm \
wildcard.domain-b.tld.key wildcard.domain-b.tld.crt wildcard.domain-b.tld-bundle.crt
# 3. Domain certs are stored as ONE file: leaf followed by its chain
cat wildcard.domain-b.tld.crt wildcard.domain-b.tld-bundle.crt > wildcard.domain-b.tld-full.crt
# 4. Save the cert+key into the domain's LDAP entry, then write them out for the proxy
/opt/zimbra/libexec/zmdomaincertmgr savecrt domain-b.tld \
wildcard.domain-b.tld-full.crt wildcard.domain-b.tld.key
/opt/zimbra/libexec/zmdomaincertmgr deploycrts
# 5. Make sure SNI is on (it's a global setting), then restart the proxy
zmprov mcf zimbraReverseProxySNIEnabled TRUE
zmproxyctl restart
zmproxyctl status
savecrt prints Saving domain config key zimbraSSLCertificate...done. and the same for zimbraSSLPrivateKey. Those two domain attributes are where the material lives. deploycrts writes every domain's certificate to /opt/zimbra/conf/domaincerts/ for nginx.
Two common errors in hand-written notes for this procedure:
- Concatenating with
>>into an existing file appends a second copy on every re-run. Use>(as above) so the result is always exactly leaf + chain. - Verifying against the intermediates-only bundle fails with an unhelpful message. The chain file passed to
verifycrtmust end with the root.
Wildcard caveat¶
*.domain-b.tld covers mail.domain-b.tld but not the bare domain-b.tld, and not a.b.domain-b.tld. Make sure every hostname clients actually use is covered, either in the SANs or by the wildcard.
Verifying what's being served¶
From any machine, check exactly which certificate a given hostname gets:
# Web (SNI hostname selects the certificate)
openssl s_client -connect mail.domain-b.tld:443 -servername mail.domain-b.tld </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# IMAPS / submission
openssl s_client -connect mail.domain.tld:993 -servername mail.domain.tld </dev/null
openssl s_client -connect mail.domain.tld:587 -starttls smtp </dev/null
# Full chain as sent (look for the depth and "Verify return code: 0 (ok)")
openssl s_client -connect mail.domain.tld:443 -servername mail.domain.tld -showcerts </dev/null
On the server:
/opt/zimbra/bin/zmcertmgr viewdeployedcrt
zmprov gd domain-b.tld zimbraVirtualHostName zimbraSSLCertificate | head
ls -l /opt/zimbra/conf/domaincerts/
Add certificate expiry to your monitoring (for example a Prometheus blackbox exporter probe, or a daily openssl x509 -checkend 1209600 check that alerts 14 days out). Expired mail certificates fail silently for many clients until users start complaining.
Troubleshooting¶
| Error / symptom | Cause and fix |
|---|---|
Unable to validate certificate chain / unable to get local issuer certificate |
The chain file is missing the root (or an intermediate). Rebuild it: intermediates, then root |
Certificate ... and private key ... do not match |
Wrong key file. Compare openssl x509 -noout -pubkey -in cert.crt with openssl pkey -pubout -in key.key; the outputs must be identical |
Unable to load private key / prompts for a passphrase |
Key is encrypted. Strip it: openssl pkey -in enc.key -out key.key (then protect the file permissions) |
unable to load certificate |
Not PEM. Convert: openssl x509 -inform der -in cert.cer -out cert.crt; from PFX: openssl pkcs12 -in bundle.pfx -nokeys -out cert.crt and -nocerts -nodes -out key.key |
Permission denied reading files |
Files staged in root-only locations; copy them into a zimbra-owned directory |
| Proxy won't start after deploying | Check /opt/zimbra/log/nginx.log; usually a domain cert in domaincerts/ with a broken chain or mismatched key. Fix it and re-run deploycrts |
| Browser still shows the old or default certificate for a virtual host | zimbraVirtualHostName not set on the domain, SNI not enabled, or the proxy not restarted. Also confirm the client actually sends SNI (-servername in openssl) |
| Mail clients warn but browsers don't | Browsers fetch missing intermediates; mail clients don't. Your chain is incomplete |