Spooky requires TLS certificates for both the QUIC/HTTP3 listener and the HTTP/1.1+HTTP/2 bootstrap TLS listener.
- Certificate format: PEM X.509 (
-----BEGIN CERTIFICATE-----) - Key format: PKCS#8 PEM (
-----BEGIN PRIVATE KEY-----) - Both are validated at startup — spooky exits if either is missing or malformed
Option 1: Let's Encrypt with Certbot (Standalone)
Use when no other service is running on port 80, or when you want a fresh independent cert.
# Stop whatever is on port 80 first (e.g. sudo systemctl stop caddy/nginx/apache)
sudo apt install -y certbot
sudo certbot certonly --standalone \
-d example.com \
--email admin@example.com \
--agree-tos \
--non-interactive
Let's Encrypt issues PKCS#1 keys — convert to PKCS#8 which Spooky requires:
sudo mkdir -p /etc/spooky/certs
sudo openssl pkcs8 -topk8 -nocrypt \
-in /etc/letsencrypt/live/example.com/privkey.pem \
-out /etc/spooky/certs/privkey.pem
sudo cp /etc/letsencrypt/live/example.com/fullchain.pem \
/etc/spooky/certs/fullchain.pem
sudo chown $USER:$USER /etc/spooky/certs/*
sudo chmod 640 /etc/spooky/certs/*
Auto-renewal deploy hook
Create /etc/letsencrypt/renewal-hooks/deploy/spooky-reload.sh:
#!/bin/bash
set -e
DOMAIN="example.com"
SRC="/etc/letsencrypt/live/${DOMAIN}"
DST="/etc/spooky/certs"
cp "${SRC}/fullchain.pem" "${DST}/fullchain.pem"
openssl pkcs8 -topk8 -nocrypt \
-in "${SRC}/privkey.pem" \
-out "${DST}/privkey.pem"
chown $SUDO_USER:$SUDO_USER "${DST}"/*.pem
systemctl restart spooky
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/spooky-reload.sh
sudo certbot renew --dry-run # test renewal
Option 2: Let's Encrypt with acme.sh (No Port 80 Required)
acme.sh supports DNS-01 challenges — no need to stop any service on port 80.
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc
# Issue cert via DNS challenge (requires DNS provider API key)
# Example for Cloudflare:
export CF_Token="your-cloudflare-api-token"
~/.acme.sh/acme.sh --issue --dns dns_cf \
-d example.com \
--server letsencrypt
# Install to spooky cert dir with PKCS#8 key conversion
sudo mkdir -p /etc/spooky/certs
~/.acme.sh/acme.sh --install-cert -d example.com \
--cert-file /etc/spooky/certs/fullchain.pem \
--key-file /tmp/privkey-pkcs1.pem \
--reloadcmd "openssl pkcs8 -topk8 -nocrypt -in /tmp/privkey-pkcs1.pem -out /etc/spooky/certs/privkey.pem && systemctl restart spooky"
Multi-Domain SNI Certificates
Serve multiple domains from one listener with per-domain cert selection:
listen:
tls:
cert: /etc/spooky/certs/default-fullchain.pem # fallback when SNI unmatched
key: /etc/spooky/certs/default-privkey.pem
certificates:
- server_name: "example.com"
cert: /etc/spooky/certs/spooky-fullchain.pem
key: /etc/spooky/certs/spooky-privkey.pem
- server_name: "api.example.com"
cert: /etc/spooky/certs/api-fullchain.pem
key: /etc/spooky/certs/api-privkey.pem
Certificate selection order:
1. Exact SNI match in certificates array
2. Fallback to cert/key if no match
3. If no cert/key, falls back to first certificates entry
Verifying Your Certificates
# Check issuer, subject, expiry
openssl x509 -in /etc/spooky/certs/fullchain.pem -noout -issuer -subject -dates
# Verify cert and key match (both lines must print same hash)
openssl x509 -noout -modulus -in /etc/spooky/certs/fullchain.pem | openssl md5
openssl pkey -noout -modulus -in /etc/spooky/certs/privkey.pem | openssl md5
# Check key format (must show PRIVATE KEY, not RSA PRIVATE KEY)
head -1 /etc/spooky/certs/privkey.pem
# Good: -----BEGIN PRIVATE KEY-----
# Bad: -----BEGIN RSA PRIVATE KEY----- (PKCS#1 — needs conversion)
If the key is PKCS#1, convert it:
openssl pkcs8 -topk8 -nocrypt -in old-privkey.pem -out /etc/spooky/certs/privkey.pem
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Cannot open listen.tls.cert |
Wrong path or permissions | chown ubuntu:ubuntu /etc/spooky/certs/* |
no private key found |
Key is PKCS#1 not PKCS#8 | Convert with openssl pkcs8 -topk8 -nocrypt |
NET::ERR_CERT_AUTHORITY_INVALID |
Self-signed cert | Use Let's Encrypt cert (Options 1–3 above) |
NET::ERR_CERT_COMMON_NAME_INVALID |
Cert domain doesn't match | Issue cert for the exact domain being served |
HSTS blocks bypass |
Domain has HSTS preloaded | Must use a valid trusted cert — no bypass possible |