Skip to content

CodeLynther/mod_dynssl

mod_dynssl

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.


How It Works

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.


Requirements

  • 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

Support matrix

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

Building

macOS (Homebrew)

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.la

Linux (Ubuntu/Debian)

sudo 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.la

Linux (RHEL/CentOS/Fedora)

sudo dnf install httpd-devel openssl-devel libcurl-devel

apxs -c -lssl -lcrypto -lcurl mod_dynssl.c
sudo apxs -i -a -n dynssl mod_dynssl.la

Configuration

Minimal setup

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
DynSSLStoreSSLVerify  on
DynSSLStoreCAFile     /etc/ssl/internal-ca.crt

For 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.

Full configuration with all directives

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>

Directives

DynSSLEnable

DynSSLEnable on

Enables the module. Default is off.


DynSSLStoreURL

DynSSLStoreURL https://localhost:8888/certs

Base 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

DynSSLStoreToken your-secret-bearer-token

Bearer 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

DynSSLStoreSSLVerify on

Controls TLS certificate verification when DynSSLStoreURL uses HTTPS. Default is on.

Keep this enabled in production. Set off only for temporary local testing.


DynSSLStoreCAFile

DynSSLStoreCAFile /etc/ssl/internal-ca.crt

Optional 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

DynSSLTimeout 2000

Timeout 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

DynSSLCacheTTL 300

Minimum 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

DynSSLCacheRenewDays 7

Days 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

DynSSLSharedCache 512m

Size 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

DynSSLCircuitBreaker 5

Consecutive 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

DynSSLCircuitReset 30

Seconds before retrying the store after the circuit opens. Default is 30.


Status Endpoint

<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-status

Example 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.


Flush Endpoints

<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.

Flush all domains

curl -X POST http://127.0.0.1:8080/dynssl-flush/all

Clears the entire shared cache. Use when rotating the store or making bulk certificate changes.

Flush one domain

curl -X POST http://127.0.0.1:8080/dynssl-flush/example.com

Removes 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.

Domain offboarding

When a domain leaves your platform:

  1. Remove the domain from your certificate store
  2. Flush the domain from the cache:
curl -X POST http://127.0.0.1:8080/dynssl-flush/example.com

After the flush, the next connection for that domain contacts the store, receives a 404, and falls back to Apache's default certificate.


Store Protocol

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.com returns the cert for that domain
  • SAN resolution: GET /certs/www.example.com returns the cert that covers www.example.com even if stored under example.com
  • Wildcard resolution: GET /certs/shop.example.com returns the *.example.com cert 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).


VirtualHost Setup

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.


Testing

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.py

Run the test store over HTTPS

Generate 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.py

Point mod_dynssl to HTTPS store transport:

DynSSLStoreURL        https://localhost:8888/certs
DynSSLStoreToken      mysecrettoken
DynSSLStoreSSLVerify  on
DynSSLStoreCAFile     /absolute/path/to/certs/store.crt

Sanity-check the store endpoint directly:

curl --cacert certs/store.crt \
    -H "Authorization: Bearer mysecrettoken" \
    https://localhost:8888/certs/example.com

Verify dynamic loading

# 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"

Verify shared cache warm-up

# 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 repeats

Verify flush

curl -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 hit

Security Considerations

Store access

Configure DynSSLStoreToken and run the store on localhost or a trusted internal network. Use HTTPS for store transport in production, with certificate verification enabled:

  • DynSSLStoreSSLVerify on
  • DynSSLStoreCAFile /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.

Private keys in shared memory

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.

Flush endpoint access

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>

License

Apache License 2.0

About

Dynamic SSL certificate loading for Apache httpd -- no restarts, no VirtualHost blocks, unlimited domains.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors