Snippy
Discover SSL certificates and keys from environment variables and produce
ready-to-use configuration for many popular TLS endpoints (i.e.
:ssl.listen/2, Cowboy, Ranch, Bandit, ThousandIsland, etc.).
Snippy turns 12-factor-style env vars (MYAPP_API_CRT, MYAPP_API_KEY, ...)
into a fully-validated, hot-reloadable cert store with built-in SNI, multi-cert
per host (e.g. ECDSA + RSA), optional public-CA chain validation, and a
:sni_fun you can hand straight to your TLS listener.
The name was inspired by the TLS Server Name Indication (SNI) extension that is used to allow multiple certificates on a single endpoint.
Requirements
- Elixir 1.19+
- Erlang/OTP 25+ (Snippy enforces this both at compile time and at startup)
Installation
The package is available on Hex and can be
installed by adding snippy to your list of dependencies in mix.exs:
def deps do
[
{:snippy, "~> 0.9.0"},
# Optional: enables public-CA chain validation against the
# Mozilla CA bundle shipped with castore.
{:castore, "~> 1.0"}
]
endDocumentation is generated with ExDoc and published on HexDocs. The docs can be found at https://hexdocs.pm/snippy.
The latest CI build also publishes documentation and a test-coverage report to GitHub Pages:
Quick Start
Collect your certificates and keys. Choose a global prefix you'll use to make them easy to find. For each one, choose an identifying "key" that shows they go together. Choose appropriate suffixes to indicate which parameter the variable represents.
Combine all that together into some variables like this:
export MYAPP_API_CRT_FILE=/run/secrets/api.crt.pem
export MYAPP_API_KEY_FILE=/run/secrets/api.key.pem
export MYAPP_ADMIN_CRT_FILE=/run/secrets/admin.crt.pem
export MYAPP_ADMIN_KEY_FILE=/run/secrets/admin.key.pem
export MYAPP_ADMIN_PASSWORD_FILE=/run/secrets/admin.key.password
Test them with this Mix task:
mix snippy.test --prefix MYAPPIf you see your keys, they're being discovered!
Now use the helper for your framework of choice to configure it:
# Plug.Cowboy
Plug.Cowboy.https(
MyAppWeb.Endpoint,
[],
[port: 4443] ++ Snippy.cowboy_opts(prefix: "MYAPP")
)
# Bandit
Bandit.start_link(
[plug: MyAppWeb.Endpoint, scheme: :https]
++ Snippy.bandit_opts(prefix: "MYAPP")
)
# Thousand Island
ThousandIsland.start_link(
[port: 4443, handler_module: MyHandler]
++ Snippy.thousand_island_opts(prefix: "MYAPP")
)
# Ranch
:ranch.start_listener(
:https,
:ranch_ssl,
Snippy.ranch_opts(prefix: "MYAPP"),
MyProtocol,
[]
)
# Plain :ssl
{:ok, listen_socket} = :ssl.listen(4443, Snippy.ssl_opts(prefix: "MYAPP"))
For Phoenix endpoints, you can even use phx_endpoint_config/1 directly in
your runtime config:
# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
https:
Snippy.phx_endpoint_config(
prefix: "MYAPP",
port: 4443,
cipher_suite: :strong
)
The produced configuration comes with both :certs_keys (for clients that
don't send SNI) and :sni_fun (for clients that do). Multiple certs whose
hostnames overlap are returned together so OTP can pick the one that matches
the client's key-exchange algorithm.
Environment-Variable Conventions
Snippy looks at every env var that begins with the configured :prefix,
followed by an underscore, then a free-form key, then a recognized
suffix:
<PREFIX>_<KEY>_<SUFFIX>
For prefix: "MYAPP", MYAPP_API_CRT_FILE decomposes as:
| segment | value |
|---|---|
| prefix | MYAPP |
| key | API |
| suffix | CRT_FILE |
All vars sharing the same (prefix, key) form one group, which represents a
single (cert, key, optional CA, optional password) bundle.
When multiple certificates cover the same domain names, Snippy configures both of them. This is how to provide certificates for two completely different key types (i.e. ECDSA falling back to RSA).
Recognized Suffixes
Suffixes correspond to the options normally used to configure TLS endpoints in Elixir:
| TLS Option | Suffix | Purpose |
| --- | --- |
| :cert | CRT, CERT | Inline PEM-encoded certificate (or chain) |
| :certfile | CRT_FILE, CERT_FILE | Path to a PEM-encoded certificate (or chain) |
| :key | KEY | Inline PEM-encoded private key (PKCS#1, SEC1, or PKCS#8; encrypted or not) |
| :keyfile | KEY_FILE | Path to a PEM-encoded private key |
| :password | PWD, PASS, PASSWD, PASSWORD | Inline password for an encrypted key |
| none | PWD_FILE, PASS_FILE, PASSWD_FILE, PASSWORD_FILE | Path to a password file |
| :cacerts | CACRT, CACERT | Inline PEM-encoded CA chain (intermediates, root last) |
| :cacertfile | CACRT_FILE, CACERT_FILE | Path to a PEM-encoded CA chain |
Snippy raises if more than one alias is set for the same option on the same group, so you don't end up wondering which one took effect.
Multiple Prefixes
:prefix accepts a string, an atom, or a list of either. Snippy raises if
one prefix is a strict prefix of another (e.g. ["MY", "MYAPP"]) so that
matching is unambiguous.
Public API
{:ok, discovered_certs} = Snippy.reload(opts)
sni_fun = Snippy.sni(opts)
ssl_opts = Snippy.ssl_opts(opts)
cowboy_opts = Snippy.cowboy_opts(opts)
ranch_opts = Snippy.ranch_opts(opts)
bandit_opts = Snippy.bandit_opts(opts)
ti_opts = Snippy.thousand_island_opts(opts)
phx_opts = Snippy.phx_endpoint_config(opts)All helpers accept the same option groups (each is optional unless noted):
Required
| Option | Description |
|---|---|
:prefix | String, atom, or list of either |
Discovery Settings
| Option | Default | Description |
|---|---|---|
:case_sensitive | true | Match env-var names case-sensitively |
:env | System.get_env() | Env map override (mainly for testing) |
:reload_interval_ms | nil | If set, the Store schedules background re-scans at this cadence |
Lookup Settings
| Option | Default | Description |
|---|---|---|
:default_hostname | nil |
Hostname used to seed :certs_keys for non-SNI clients |
:expiry_grace_seconds | 0 | Tolerate certs that expired up to this many seconds ago |
:public_ca_validation | :auto | :auto, :always, or :never (see below) |
:only | nil | List of hostname patterns; only matching groups are exposed |
:keys | nil | List of group key strings (or atoms); only matching groups are exposed |
:only and :keys are unioned: a group is included if it matches either.
Validation Pipeline
For each discovered group, Snippy runs a series of checks before adding it to the live cert store. Failed groups are dropped with a logged error.
- PEM decoding. Both inline and
_FILEsources are decoded inside Snippy; encrypted PKCS#8 keys are decrypted using the supplied password. The password is tried trimmed first, then untrimmed, to forgive trailing newlines from_FILEsources. - Cert/key match. Snippy signs a probe message with the private key and verifies it against the SubjectPublicKeyInfo from the certificate. This works for RSA, ECDSA, and EdDSA in any PEM form.
- Validity window.
notBeforeandnotAfterare checked against the current time.:expiry_grace_secondsextends the upper bound only. - Chain validation.
-
If a
_CACRT*is provided, it's used as the trust anchor. -
Otherwise, if
castoreis loaded, the leaf is checked against the Mozilla bundle. - Otherwise the cert is accepted as self-signed (with an info-level log).
:public_ca_validationcontrols strictness::auto(default) - trycastoreif available, accept failures.:always- require successful public-CA validation, drop on failure.:never- skip public-CA validation entirely.
-
If a
SNI and Multi-Cert Per Host
When multiple groups advertise the same hostname (e.g. an ECDSA cert and an RSA
fallback for api.example.com), the SNI fun returns all matching certs_keys
so OTP can choose based on the client's signature algorithm.
Wildcard certs (leftmost-label * only) are matched using
domainname for correctness.
Reloading
{:ok, discovered_certs} = Snippy.reload()reload/1 re-scans the environment and re-reads every _FILE source. If no
group in the discovery has any _FILE source, a warning is logged - reload
would do nothing, since inline values come from the OS env at boot.
Environmental variables are generally not changed once an application is loaded, so we don't check non-file variables. If there is a compelling reason, this may be changed in the future.
:reload_interval_ms schedules background reloads at the requested cadence.
Background reload errors are logged and the previous good state is retained.
Supervisor Restart Tuning
Snippy's supervision tree uses a :one_for_all strategy. Under heavy load,
transient validation or filesystem errors during a reload can cause the
store to crash; if those crashes happen too quickly the supervisor will
itself give up.
The default restart budget tolerates roughly a 20% failure rate on a server handling 50 requests per second over a 15-second window (about 150 failures). You can tune both bounds via application config:
# config/runtime.exs
config :snippy,
max_restarts: 150,
max_seconds: 15Why
It all started with a Cloudflare Tunnel. What I wanted seemed simple--HTTPS all the way to my application. I also wanted to use something better than just a self-signed certificate.
I quickly discovered that Cloudflare issues free
Origin CA
certificates. cloudflared can verify these certificates against their own
Cloudflare Origin Root CA. This seemed ideal. Should be easy, right?
I started by wiring everything together and immediately hit another problem. I didn't want to store the secret information on disk. I wanted it in the environment. It was here I hit my first obstacle: Bandit (and thus Phoenix) didn't have a good way to give it key data that wasn't on disk.
For the time being, I put the secrets on disk--figuring that my first goal (end-to-end encryption in transit) was the harder one to achieve. Still, I kept that item on my to-do list.
It so happens that I am serving the same site from two different domains. Next I discovered that Cloudflare won't put the different domains on the same certificate, so I needed to create two.
Then I discovered that configuring Bandit (and thus Phoenix) with both of these certificates was not really well documented and could possibly be done no less than four different ways--each of them rather tedious and fiddly. I did discover how to provide information without writing files, so I had a solution to my to-do item.
After figuring out the correct syntax to get everything to work together, I
fired up the tunnel. It had previously worked with HTTP, so I figured HTTPS
would just be a simple change. Immediately, the tunnel started giving errors
mentioning that localhost didn't match the name on the certificate.
Looking around, all of the advice out there was to turn off TLS-verification.
That kind of defeats the point, so I kept digging. I eventually prevailed by
setting the
caPool / originServerName
tunnel parameters.
Now it was working, but this all seemed way more complicated than it needed to be. I have the certificates, they have the names in them. I just want to find them in environmental variables and configure one of my TLS endpoints with them.
Thus, Snippy was born. It provides the plumbing from environmental variables (and possibly files) all the way to the parameters for your favorite framework.
License
MIT. Copyright (c) 2026 Jayson Vantuyl.