An Apache httpd module that loads SSL/TLS certificates dynamically at TLS handshake time, without requiring server restarts or per-domain configuration.
For the reasoning behind every design decision -- hook selection, shared memory cache design, TTL strategy, SAN handling, circuit breaker, flush mechanism, and what was considered and rejected -- see ARCHITECTURE.md.
When a TLS connection arrives, mod_dynssl intercepts the SNI hostname, fetches the matching certificate and private key from your certificate store, and injects them into the connection. New domains are served instantly. No VirtualHost blocks. No reloads. No restarts.
Client connects
-> TLS ClientHello arrives with SNI hostname
-> mod_dynssl pre_handshake hook fires
-> Check shared memory for flush signals
-> Look up domain in shared memory cache (lock-free read)
-> Cache HIT: inject cert into SSL connection
-> Cache MISS: fetch cert+key from store
cache under SNI hostname + all SANs
inject cert into SSL connection
-> TLS handshake completes with the correct certificate
-> Apache serves the request normally
All worker processes share one certificate cache in shared memory allocated before workers fork. One store call populates the cache for all workers. There is no per-worker cache.
Cache TTL is derived from the certificate's own expiry date. The cache entry expires DynSSLCacheRenewDays before the cert does, triggering a re-fetch while the cert is still valid. The store is responsible for having a renewed cert ready before that window opens.
On first fetch, all Subject Alternative Names (SANs) in the certificate are parsed and the cert is cached under each SAN domain. This eliminates redundant store calls for multi-domain certificates.
- Apache httpd 2.4.62+ (target range; validated locally on 2.4.62 and 2.4.66)
- OpenSSL 3.x (validated locally); OpenSSL 1.1.1 likely compatible but not yet validated
- libcurl (required for store HTTP(S) lookups)
- mod_ssl loaded and enabled
- apxs for building
| Component | Status |
|---|---|
| Apache httpd 2.4.62, 2.4.66 | Validated in local testing |
| Apache httpd 2.4.62+ | Supported target range |
| OpenSSL 3.x | Validated in local testing |
| OpenSSL 1.1.1 | Likely compatible, not yet validated |
| OpenSSL 1.1.0 or older | Not supported |
brew install httpd openssl
git clone https://github.com/CodeLynther/mod_dynssl.git
cd mod_dynssl
apxs -c \
-I/opt/homebrew/opt/openssl@3/include \
-L/opt/homebrew/opt/openssl@3/lib \
-lssl -lcrypto -lcurl \
mod_dynssl.c
sudo apxs -i -a -n dynssl mod_dynssl.lasudo apt-get install apache2-dev libssl-dev libcurl4-openssl-dev
apxs -c -lssl -lcrypto -lcurl mod_dynssl.c
sudo apxs -i -a -n dynssl mod_dynssl.lasudo dnf install httpd-devel openssl-devel libcurl-devel
apxs -c -lssl -lcrypto -lcurl mod_dynssl.c
sudo apxs -i -a -n dynssl mod_dynssl.laLoadModule ssl_module lib/httpd/modules/mod_ssl.so
LoadModule dynssl_module lib/httpd/modules/mod_dynssl.so
DynSSLEnable on
DynSSLStoreURL https://localhost:8888/certs
DynSSLStoreSSLVerify on
DynSSLStoreCAFile /etc/ssl/internal-ca.crtFor production, keep DynSSLStoreURL on loopback (localhost/127.0.0.1) and
run a local store or local proxy only. Do not expose this hop on public
networks.
LoadModule ssl_module lib/httpd/modules/mod_ssl.so
LoadModule dynssl_module lib/httpd/modules/mod_dynssl.so
DynSSLEnable on
DynSSLStoreURL https://localhost:8888/certs
DynSSLStoreToken your-secret-bearer-token
DynSSLStoreSSLVerify on
DynSSLStoreCAFile /etc/ssl/internal-ca.crt
DynSSLTimeout 2000
DynSSLCacheTTL 300
DynSSLCacheRenewDays 7
DynSSLSharedCache 512m
DynSSLCircuitBreaker 5
DynSSLCircuitReset 30
<Location "/dynssl-status">
SetHandler dynssl-status
Require ip 127.0.0.1
</Location>
<Location "/dynssl-flush">
SetHandler dynssl-flush
Require ip 127.0.0.1
</Location>DynSSLEnable onEnables the module. Default is off.
DynSSLStoreURL https://localhost:8888/certsBase URL of your certificate store. Required. mod_dynssl appends the SNI hostname and makes a GET request:
GET {DynSSLStoreURL}/{domain}
Authorization: Bearer {DynSSLStoreToken} <- if token configured
HTTP 200
{
"cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}
The cert field may contain a full PEM chain (leaf certificate followed by intermediate certificates). All certificates in the chain are loaded correctly.
Return HTTP 404 if the domain is unknown. mod_dynssl falls back to Apache's default certificate for that connection.
Newlines inside PEM strings must be encoded as \n in the JSON.
The store is intentionally agnostic. It can be backed by MySQL, PostgreSQL,
Redis, MongoDB, AWS Secrets Manager, HashiCorp Vault, flat files, or any
other system. As long as it speaks HTTP(S) and returns this JSON shape,
mod_dynssl works with it. The store is also responsible for SAN and wildcard
resolution -- when asked for shop.example.com, the store should return the
cert that covers it even if stored internally under example.com or
*.example.com.
DynSSLStoreToken your-secret-bearer-tokenBearer token for store authentication. Optional. When set, sent as:
Authorization: Bearer your-secret-bearer-token
Standard HTTP Bearer auth -- recognised by any web framework or API gateway without custom code. If the store returns 401, mod_dynssl logs a clear error and falls back to the default certificate.
Use a strong random token and avoid committing real tokens in config files.
DynSSLStoreSSLVerify onControls TLS certificate verification when DynSSLStoreURL uses HTTPS.
Default is on.
Keep this enabled in production. Set off only for temporary local testing.
DynSSLStoreCAFile /etc/ssl/internal-ca.crtOptional path to a custom CA bundle for verifying the store certificate when using HTTPS. Use this for internal PKI/private CA deployments.
If unset, libcurl uses the system CA trust store.
DynSSLTimeout 2000Timeout in milliseconds for store lookups. Default is 2000 (2 seconds).
Store lookups happen in the TLS handshake critical path. Keep this low. The circuit breaker protects against store outages automatically.
DynSSLCacheTTL 300Minimum cache TTL in seconds. Default is 300 (5 minutes).
Acts as a floor only. The actual TTL is computed from the certificate's own expiry date via DynSSLCacheRenewDays. This floor activates only when a cert is very close to expiry and the store has not yet renewed it. Under normal operation with a well-maintained store, most domains never hit this floor.
DynSSLCacheRenewDays 7Days before certificate expiry at which the cache entry expires and a re-fetch is triggered. Default is 7 days.
TTL = (cert notAfter - now) - (DynSSLCacheRenewDays * 86400)
TTL = max(TTL, DynSSLCacheTTL)
Examples with the default of 7 days:
90 day cert -> cache for 83 days, re-fetch at 7 days remaining
1 year cert -> cache for 358 days, re-fetch at 7 days remaining
10 year cert -> cache for 9.98 years, re-fetch at 7 days remaining
The store is responsible for having a renewed certificate ready before the re-fetch window opens. Set this to give your store enough lead time. 7 days suits automated renewal pipelines. Increase to 14 or 30 days if your store requires more preparation time.
DynSSLSharedCache 512mSize of the shared memory certificate cache. All worker processes share this
single cache. Default is 512m (512 MB). Accepts k, m, g suffixes.
Minimum is 32m, maximum is 32g.
Each cache slot is approximately 10.5 KB (6 KB cert + 4 KB key + overhead). Domain capacity at common sizes:
128m -> ~12,000 domains
256m -> ~24,000 domains
512m -> ~50,000 domains (default)
1g -> ~100,000 domains
2g -> ~200,000 domains
4g -> ~400,000 domains
These figures are based on real-world CA chain sizes:
- Let's Encrypt RSA (most common): leaf + 1 intermediate = ~3,400 bytes
- Let's Encrypt ECDSA: leaf + 1 intermediate = ~1,800 bytes
- DigiCert G2: leaf + 1 intermediate = ~3,500 bytes
- DigiCert G5: leaf + 2 intermediates = ~5,200 bytes
Certificates exceeding 6 KB (RSA 4096 with 3+ intermediates, rare legacy enterprise PKI) are rejected with a clear log message stating the cert size.
The shared memory is allocated before workers fork, so all workers map the same physical pages. There is no per-worker cache. One store call on first access populates the cache for all workers simultaneously.
DynSSLCircuitBreaker 5Consecutive store failures before the circuit breaker opens. Default is 5.
When open, mod_dynssl skips the store entirely and falls back to Apache's default certificate for all uncached domains, preventing a failing store from blocking worker processes during the timeout on every connection.
The circuit is per-worker. After DynSSLCircuitReset seconds, it moves to half-open and allows one probe. A successful probe closes the circuit.
DynSSLCircuitReset 30Seconds before retrying the store after the circuit opens. Default is 30.
<Location "/dynssl-status">
SetHandler dynssl-status
Require ip 127.0.0.1
</Location>Returns shared cache state and per-worker circuit breaker state. Always restrict to localhost.
curl http://127.0.0.1:8080/dynssl-statusExample output:
mod_dynssl status
========================================
Shared Cache
entries : 3 / 50343
shm_size : 512 MB
slot_size : 10560 bytes
min_ttl : 300s
renew_buffer : 7 days
flush_gen : all=0 domain=0
flush_domain : (none)
Cached domains (sample, up to 20):
[example.com] re-fetch in 357d cert expires in 364d
[customer42.com] re-fetch in 82d cert expires in 89d
[acme-corp.com] re-fetch in 6d cert expires in 13d
Circuit Breaker (pid 12345)
status : closed
failures : 0 / 5
reset after: 30s
Store
url : https://localhost:8888/certs
auth : Bearer
timeout : 2000ms
The shared cache section reflects all workers since they share the same memory. The circuit breaker section is per-worker -- call the endpoint multiple times to see different workers.
<Location "/dynssl-flush">
SetHandler dynssl-flush
Require ip 127.0.0.1
</Location>Flush operations act directly on the shared memory cache. All workers see the change immediately on their next TLS handshake -- no need to call the endpoint multiple times.
Always restrict to localhost.
curl -X POST http://127.0.0.1:8080/dynssl-flush/allClears the entire shared cache. Use when rotating the store or making bulk certificate changes.
curl -X POST http://127.0.0.1:8080/dynssl-flush/example.comRemoves a single domain from the shared cache. The next TLS handshake for that domain fetches fresh from the store.
Use when a certificate is rotated due to compromise, reissuance, or when a customer reports their new certificate is not being served.
When a domain leaves your platform:
- Remove the domain from your certificate store
- Flush the domain from the cache:
curl -X POST http://127.0.0.1:8080/dynssl-flush/example.comAfter the flush, the next connection for that domain contacts the store, receives a 404, and falls back to Apache's default certificate.
Your store is any service reachable over HTTP(S) from the Apache host.
For production, this endpoint should be loopback-only (for example,
https://127.0.0.1:8888/certs) and backed by a local service/proxy. The
backend system behind that local service can be MySQL, PostgreSQL, Redis,
files, Vault, or any other store.
The store must handle:
- Exact domain lookup:
GET /certs/example.comreturns the cert for that domain - SAN resolution:
GET /certs/www.example.comreturns the cert that coverswww.example.comeven if stored underexample.com - Wildcard resolution:
GET /certs/shop.example.comreturns the*.example.comcert if that is what covers it - 404 for unknown domains: mod_dynssl falls back to the default cert
A minimal file-based store for development is included (test_store.py).
mod_dynssl requires a catch-all VirtualHost. Apache needs a certificate at startup even though mod_dynssl replaces it at runtime.
Generate a fallback certificate:
openssl req -x509 -newkey rsa:2048 \
-keyout /etc/httpd/ssl/fallback.key \
-out /etc/httpd/ssl/fallback.crt \
-days 3650 -nodes -subj "/CN=localhost"Add a catch-all VirtualHost:
<VirtualHost *:443>
ServerName _default_
SSLEngine on
SSLCertificateFile /etc/httpd/ssl/fallback.crt
SSLCertificateKeyFile /etc/httpd/ssl/fallback.key
</VirtualHost>The fallback certificate is served only when mod_dynssl cannot find a cert -- store returns 404, store is unreachable, circuit breaker is open, or the cert PEM is too large for the configured slot size.
A minimal file-based store is included:
mkdir -p certs
openssl req -x509 -newkey rsa:2048 \
-keyout certs/example.com.key \
-out certs/example.com.crt \
-days 90 -nodes -subj "/CN=example.com"
# Without auth
python3 test_store.py
# With Bearer token
DYNSSL_TOKEN=mysecrettoken python3 test_store.pyGenerate a local TLS certificate for the test store:
openssl req -x509 -newkey rsa:2048 \
-keyout certs/store.key \
-out certs/store.crt \
-days 365 -nodes -subj "/CN=localhost"Start test_store.py in HTTPS mode:
DYNSSL_USE_HTTPS=1 \
DYNSSL_TLS_CERT=certs/store.crt \
DYNSSL_TLS_KEY=certs/store.key \
DYNSSL_TOKEN=mysecrettoken \
python3 test_store.pyPoint mod_dynssl to HTTPS store transport:
DynSSLStoreURL https://localhost:8888/certs
DynSSLStoreToken mysecrettoken
DynSSLStoreSSLVerify on
DynSSLStoreCAFile /absolute/path/to/certs/store.crtSanity-check the store endpoint directly:
curl --cacert certs/store.crt \
-H "Authorization: Bearer mysecrettoken" \
https://localhost:8888/certs/example.com# Known domain -- should show CN=example.com
curl -k -v --resolve example.com:8443:127.0.0.1 \
https://example.com:8443 2>&1 | grep -A4 "Server certificate"
# Unknown domain -- should show CN=localhost (fallback)
curl -k -v --resolve unknown.com:8443:127.0.0.1 \
https://unknown.com:8443 2>&1 | grep -A4 "Server certificate"# All workers hit the store once on first access, then nothing
for i in {1..20}; do
curl -k -s --resolve example.com:8443:127.0.0.1 \
https://example.com:8443 > /dev/null
done
# Store terminal should show one initial hit, then cache hits for repeatscurl -X POST http://127.0.0.1:8080/dynssl-flush/example.com
# Next request re-fetches from store
curl -k -s --resolve example.com:8443:127.0.0.1 \
https://example.com:8443 > /dev/null
# Store shows a fresh hitConfigure DynSSLStoreToken and run the store on localhost or a trusted internal network. Use HTTPS for store transport in production, with certificate verification enabled:
DynSSLStoreSSLVerify onDynSSLStoreCAFile /etc/ssl/internal-ca.crt(for internal CAs)- Apache -> store/proxy over HTTPS
- Store/proxy -> remote backend over TLS/mTLS as needed
Do not configure DynSSLStoreURL to a public IP or untrusted remote network.
Certificate private keys are held in plaintext in the shared memory segment. The segment is inherited by all worker processes. On systems where process memory or the shared memory segment can be dumped, keys could be exposed. Evaluate this against your threat model.
Always restrict to localhost. An unauthenticated flush endpoint reachable externally would allow anyone to force cache invalidation and trigger mass store lookups.
<Location "/dynssl-flush">
SetHandler dynssl-flush
Require ip 127.0.0.1
</Location>Apache License 2.0