In 2026, there is no excuse for serving traffic over plain HTTP. Every PaaS must handle SSL certificates automatically -- users should not have to think about TLS at all. But "automatic" covers a spectrum. At one end, you have basic ACME integration that provisions certificates from Let's Encrypt. At the other end, you have enterprise customers who bring their own certificates, signed by their own certificate authorities, with private keys that must be encrypted at rest.
sh0 handles the full spectrum. This is the story of how we built SSL certificate management that ranges from zero-configuration ACME to custom certificate uploads with AES-256-GCM encrypted private key storage -- all orchestrated through the same Caddy reverse proxy we tamed in Article 5.
---
Caddy and ACME: The Happy Path
Caddy's killer feature is automatic HTTPS. When Caddy receives a route for myapp.example.com, it automatically:
1. Listens on port 80 for the ACME HTTP-01 challenge 2. Requests a certificate from Let's Encrypt (or ZeroSSL, its default CA) 3. Installs the certificate and begins serving HTTPS on port 443 4. Renews the certificate automatically before expiry
For sh0, this means the default SSL experience requires zero configuration from the user. Deploy an app, point your DNS to the server, and HTTPS works. The only thing Caddy needs from us is an ACME email address for certificate expiry notifications.
The Caddy configuration we generate includes the ACME issuer:
fn build_tls_automation(email: &str) -> CaddyTlsAutomation {
CaddyTlsAutomation {
policies: vec![CaddyTlsPolicy {
issuers: vec![CaddyIssuer {
module: "acme".to_string(),
email: email.to_string(),
}],
subjects: None, // applies to all domains by default
}],
}
}This single configuration block enables automatic HTTPS for every domain routed through Caddy. No per-domain certificate management, no renewal cron jobs, no expiry alerts. Caddy handles it all.
---
Runtime ACME Email Configuration
The ACME email is not just a nice-to-have -- Let's Encrypt uses it to send critical notifications about certificate problems. We made it configurable at runtime through the dashboard, not just at startup.
The implementation spans the full stack:
Backend: A POST /settings/acme-email endpoint stores the email in the database (settings table, key acme_email) and updates the ProxyManager at runtime:
pub async fn set_acme_email(
State(state): State<AppState>,
Json(req): Json<SetAcmeEmailRequest>,
) -> Result<Json<ApiResponse>> {
// Persist to database
Setting::upsert(&state.pool, "acme_email", &req.email).await?;// Update proxy at runtime -- rebuilds and reloads Caddy config state.proxy.set_email(&req.email).await?;
Ok(Json(ApiResponse::success("ACME email updated"))) } ```
ProxyManager: The email field is wrapped in an RwLock, allowing runtime updates without restarting the server:
pub async fn set_email(&self, email: &str) -> Result<()> {
{
let mut current = self.email.write().await;
*current = email.to_string();
}
// Rebuild and reload Caddy config with new email
self.rebuild_and_load().await
}Startup: The main function loads the ACME email from the database if the --acme-email CLI flag is empty, using the database setting as a fallback. This means the email survives server restarts without requiring command-line reconfiguration.
---
DNS Configuration for Self-Hosted Deployments
sh0 is self-hosted software. Users run it on their own servers, with their own domains. DNS configuration is the user's responsibility, but we guide them through it.
The dashboard's domain management panel shows the server's real IP address (fetched from the /settings API, not hardcoded) and provides clear instructions:
1. Create an A record pointing the domain to the server IP 2. Wait for DNS propagation 3. Caddy handles the rest (ACME challenge, certificate provisioning, HTTPS)
We deliberately removed two elements from earlier versions of this panel:
No CNAME row. CNAME records are relevant for sh0 Cloud (a future managed offering) where users point to a load balancer hostname. For self-hosted deployments, an A record pointing directly to the server IP is simpler and more reliable.
No "Verify DNS" button. We had initially built a DNS verification feature, but removed it. Caddy's ACME process is itself the verification: if DNS is configured correctly, the certificate is provisioned automatically. If DNS is wrong, the ACME challenge fails and Caddy logs the error. A manual verification button added complexity without adding value.
---
The Cloudflare Problem
A significant percentage of sh0 users use Cloudflare for DNS. This creates a subtle configuration challenge that we address directly in the dashboard.
When Cloudflare's proxy is enabled (the orange cloud icon), traffic flows through Cloudflare's edge network before reaching the origin server. Cloudflare terminates TLS at the edge and establishes a new TLS connection to the origin. This means two different TLS configurations are in play:
DNS Only mode (grey cloud): Traffic goes directly to the server. Caddy handles TLS end-to-end via ACME. This is the simple case -- everything works out of the box.
Proxied mode (orange cloud): Cloudflare terminates TLS at the edge. The origin server (Caddy) must have a valid certificate that Cloudflare can verify. The Cloudflare SSL/TLS setting must be "Full (Strict)" to ensure end-to-end encryption.
We added Cloudflare-specific guidance to the DNS configuration modal:
Using Cloudflare? For direct Caddy SSL, use "DNS only" (grey cloud icon). If you prefer Cloudflare's proxy (orange cloud), set your SSL/TLS mode to "Full (Strict)" in the Cloudflare dashboard.
This single paragraph prevented a category of support requests we anticipated from experience: users enabling Cloudflare's proxy with SSL set to "Flexible," which causes redirect loops and mixed content errors.
---
Custom SSL Certificates: The Enterprise Path
Automatic ACME covers 90% of use cases. But enterprise customers often have requirements that ACME cannot satisfy:
- Internal certificate authorities with certificates that are not publicly trusted
- Extended Validation (EV) certificates required by compliance policies
- Wildcard certificates covering an entire domain hierarchy
- Certificates that must be issued by a specific CA (e.g., DigiCert, Sectigo)
sh0 supports all of these through custom certificate uploads.
The Data Model
The database schema introduces two new tables:
CREATE TABLE certificates (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id),
common_name TEXT NOT NULL,
issuer_cn TEXT,
san_domains TEXT NOT NULL, -- JSON array
fingerprint TEXT NOT NULL UNIQUE,
not_before TIMESTAMP NOT NULL,
not_after TIMESTAMP NOT NULL,
cert_pem TEXT NOT NULL,
key_encrypted TEXT NOT NULL, -- AES-256-GCM encrypted
key_nonce TEXT NOT NULL,
csr_pem TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);CREATE TABLE domain_certificates ( domain_id TEXT REFERENCES domains(id) ON DELETE CASCADE, certificate_id TEXT REFERENCES certificates(id) ON DELETE CASCADE, PRIMARY KEY (domain_id, certificate_id) ); ```
The domains table gains two new columns: ssl_mode (either auto for ACME or custom for uploaded certificates) and certificate_id (a foreign key to the certificates table).
CSR Generation
For users who need their CA to issue a certificate, sh0 generates the Certificate Signing Request (CSR) on the server side using the rcgen crate:
pub async fn generate_csr(
State(state): State<AppState>,
Path(app_id): Path<String>,
Json(req): Json<GenerateCsrRequest>,
) -> Result<Json<CsrResponse>> {
let mut params = CertificateParams::new(vec![req.domain.clone()]);
params.distinguished_name.push(DnType::CommonName, &req.domain);
params.distinguished_name.push(DnType::OrganizationName, &req.organization);
params.distinguished_name.push(DnType::CountryName, &req.country);
params.distinguished_name.push(DnType::StateOrProvinceName, &req.state);
params.distinguished_name.push(DnType::LocalityName, &req.city);let key_pair = KeyPair::generate(&PKCS_ECDSA_P256_SHA256)?; let csr = params.serialize_request(&key_pair)?;
// Encrypt and store the private key let key_pem = key_pair.serialize_pem(); let (encrypted, nonce) = encrypt(&key_pem.as_bytes(), &state.master_key)?;
// Store CSR + encrypted key in database let cert = Certificate::create_from_csr( &state.pool, &app_id, &req.domain, &csr.pem()?, &encrypted, &nonce, ).await?;
Ok(Json(CsrResponse { id: cert.id, csr_pem: csr.pem()?, })) } ```
The user downloads the CSR PEM, submits it to their CA, receives the signed certificate, and uploads it back to sh0 to complete the flow.
Certificate Upload and Validation
When a certificate is uploaded (either fresh or completing a CSR flow), the backend validates it thoroughly using the x509-parser crate:
1. Parse the PEM-encoded certificate 2. Extract the Subject Common Name (CN) and Subject Alternative Names (SANs) 3. Verify the certificate is not expired 4. Compute the SHA-256 fingerprint for deduplication 5. If completing a CSR flow, verify the certificate matches the stored CSR
The validated certificate and its encrypted private key are stored in the database and written as PEM files to the certs_dir for Caddy to load.
Private Key Encryption
Private keys are the crown jewels of any TLS deployment. sh0 encrypts them at rest using AES-256-GCM, the same authenticated encryption scheme used for environment variable encryption:
// Encrypt private key before storing in database
let (encrypted, nonce) = sh0_auth::crypto::encrypt(
key_pem.as_bytes(),
&state.master_key,
)?;// Write decrypted PEM to disk with restricted permissions (for Caddy to read) let key_path = state.certs_dir.join(format!("{}.key", cert_id)); let mut file = File::create(&key_path)?; file.write_all(&decrypted_key)?;
// Unix: restrict to owner read/write only #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&key_path, Permissions::from_mode(0o600))?; } ```
The database stores only the encrypted key and its nonce. The decrypted PEM file on disk (which Caddy reads) has 0o600 permissions -- readable only by the sh0 process owner. On server restart, a startup sync routine decrypts keys from the database and re-creates the PEM files for Caddy.
---
Per-Domain SSL Mode Switching
Each domain in sh0 has an ssl_mode that determines how its TLS certificate is handled:
auto(default): Caddy's ACME integration provisions and renews certificates automaticallycustom: Caddy loads the uploaded certificate and key from disk
The Caddy configuration builder handles both modes in a single config generation pass:
pub fn build_config_full(
routes: &HashMap<String, AppRoute>,
custom_certs: &[CustomCert],
email: &str,
config: &ProxyConfig,
) -> CaddyConfig {
let mut tls = CaddyTls::default();// ACME automation for all domains in "auto" mode let mut auto_policy = CaddyTlsPolicy { issuers: vec![CaddyIssuer::acme(email)], subjects: None, };
// Skip ACME for domains with custom certificates
if !custom_certs.is_empty() {
let custom_domains: Vec
auto_policy.subjects = Some( all_domains.iter() .filter(|d| !custom_domains.contains(d)) .cloned() .collect() );
// Load custom certificates from files tls.certificates = Some(CaddyCertificates { load_files: custom_certs.iter().map(|c| CaddyCertLoadFile { certificate: c.cert_path.to_string_lossy().to_string(), key: c.key_path.to_string_lossy().to_string(), }).collect(), }); }
tls.automation = Some(CaddyTlsAutomation { policies: vec![auto_policy], });
// ... build routes and assemble full config } ```
The key insight is the per-domain ACME policy skip. When custom certificates exist, the ACME automation policy explicitly lists only the domains that should use ACME, excluding those with custom certificates. This prevents Caddy from attempting to provision ACME certificates for domains that already have custom ones -- which would fail and pollute the logs with errors.
---
Startup Certificate Sync
When sh0 restarts, the in-memory custom_certs state is empty and the PEM files on disk may not exist. The startup routine restores everything from the database:
// In main.rs, after database pool and master key are initialized
let certs = Certificate::list_active(&pool).await?;
let mut custom_certs = Vec::new();for cert in certs { // Decrypt private key from database let key_pem = decrypt(&cert.key_encrypted, &cert.key_nonce, &master_key)?;
// Write PEM files to disk for Caddy let cert_path = certs_dir.join(format!("{}.crt", cert.id)); let key_path = certs_dir.join(format!("{}.key", cert.id)); std::fs::write(&cert_path, &cert.cert_pem)?; write_with_permissions(&key_path, &key_pem, 0o600)?;
custom_certs.push(CustomCert { domains: cert.san_domains(), cert_path, key_path, }); }
tracing::info!("Restored {} custom certificates from database", custom_certs.len()); proxy.set_custom_certs(custom_certs).await?; ```
This ensures that custom certificate domains are immediately available after a restart, without manual intervention.
---
Certificate Lifecycle Management
The dashboard surfaces certificate status in the domain management panel:
- Each domain shows its SSL mode: "Auto" (with a lock icon) or "Custom" (with the issuer name)
- Certificate expiry warnings appear as colored badges: yellow when fewer than 30 days remain, red when expired
- A dedicated "SSL Certificates" section lists all certificates for an app, with full metadata: common name, issuer, SANs, fingerprint, validity dates
The API provides six certificate endpoints:
| Endpoint | Purpose |
|---|---|
POST /apps/:id/certificates/csr | Generate CSR + encrypted key pair |
POST /apps/:id/certificates | Upload certificate (fresh or complete CSR) |
GET /apps/:id/certificates | List certificates for an app |
GET /certificates/:id | Certificate detail |
DELETE /certificates/:id | Delete certificate, revert domains to ACME |
PATCH /apps/:id/domains/:domain_id/ssl | Switch domain SSL mode |
Deleting a certificate automatically reverts all associated domains back to auto mode (ACME). This cascade ensures no domain is left in custom mode pointing to a certificate that no longer exists.
---
Security Considerations
SSL certificate management is security-critical. We applied several defense-in-depth measures:
Encryption at rest. Private keys are stored in the database encrypted with AES-256-GCM. The master key is derived from the admin password via Argon2. Even if the database is compromised, private keys are not readable without the master key.
File permissions. PEM files on disk have 0o600 permissions (owner read/write only). Caddy runs as the same user as sh0, so it can read the files, but no other user on the system can.
No private key in API responses. The certificate detail endpoint returns metadata (common name, issuer, fingerprint, dates, SANs) but never the private key. Once uploaded, the private key is write-only from the API's perspective.
Fingerprint deduplication. The SHA-256 fingerprint is stored as a unique field, preventing the same certificate from being uploaded twice.
Cascade delete. When a certificate is deleted, the domain_certificates junction table cascades the delete, and all associated domains revert to ACME mode. No orphaned references.
---
Lessons Learned
Let the reverse proxy handle ACME. Implementing ACME directly in Rust would have taken weeks and introduced subtle bugs (challenge timing, rate limits, key rollover). Caddy's ACME implementation is battle-tested and handles edge cases we would never have anticipated. Our job was to configure it correctly, not to reimplement it.
Encrypt private keys even on the same server. It seems redundant -- the key is on the same disk as the encrypted version. But defense in depth matters. A database backup that leaks (via misconfigured S3 permissions, a stolen backup drive, a logging accident) will not expose private keys if they are encrypted at rest.
Guide users through DNS, do not automate it. DNS configuration is the one thing we cannot do for self-hosted users. Instead of building fragile DNS verification that gives false negatives (due to propagation delays) and false positives (due to cached records), we show clear instructions and let Caddy's ACME challenge be the real verification.
---
What Comes Next
With routing, deploys, and SSL in place, sh0 had the core infrastructure of a production PaaS. The next articles in this series will cover the authentication system, the dashboard frontend, and the monitoring layer that ties everything together. Stay tuned.
This is Part 8 of the "How We Built sh0.dev" series. sh0 is a PaaS platform built entirely by a CEO in Abidjan and an AI CTO, with zero human engineers.