How We Solved Wildcard SSL for Multi-Tenant Rails Apps with Kamal 2 and Cloudflare

P

Promise Uka

How We Solved Wildcard SSL for Multi-Tenant Rails Apps with Kamal 2 and Cloudflare

The Problem
EdSkool is a multi-tenant school management platform. Each school gets its own subdomain — danmayschool.edskool.com, marvgrace.edskool.com, and so on. When a new school signs up, their subdomain needs to work immediately over HTTPS.

We deploy with Kamal 2 to a Hetzner server, with Cloudflare handling DNS. The standard Kamal setup uses Let's Encrypt for automatic SSL, and it works beautifully — until you need wildcard subdomains.

The issue: Let's Encrypt's automatic certificate issuance (via HTTP-01 challenges) only works for explicitly listed domains. You can't pass *.edskool.com to Kamal's proxy config. Every time a new school joined, we had to add their subdomain to the deploy config and redeploy. For a growing platform, that's not sustainable.

We searched everywhere — GitHub issues, Reddit threads, Stack Overflow. The common advice was either "use Traefik" (which means replacing Kamal's built-in proxy entirely) or "just add subdomains manually." Neither felt right.

Here's what actually worked.

The Solution: Cloudflare Origin Certificates
The key insight is that Kamal 2 supports custom SSL certificates — you're not locked into Let's Encrypt. And Cloudflare offers free Origin Certificates that support wildcards, last up to 15 years, and are trusted by Cloudflare's edge network.

The architecture looks like this:
Visitor → Cloudflare (edge SSL) → Your server (origin SSL via Cloudflare cert) → Rails app
Cloudflare terminates SSL at their edge using their own certificates, then connects to your origin server over HTTPS using the Origin Certificate you install. Both legs of the connection are encrypted.

Step-by-Step Setup
1. Generate a Cloudflare Origin Certificate
In your Cloudflare dashboard:
  1. - Go to SSL/TLS → Origin Server
  2. - Click Create Certificate
  3. - Let Cloudflare generate the private key (RSA 2048)
  4. - Set hostnames to *.yourdomain.com and yourdomain.com
  5. - Set validity to 15 years
  6. - Click Create
- You'll get two text blocks — a certificate (PEM) and a private key. Save both immediately. Cloudflare only shows the private key once.

Save them as files in your project:
- config/ssl/yourdomain.pem # The certificate
- config/ssl/yourdomain.key # The private key
Add these to your .gitignore so they never get committed:
/config/ssl/.key
/config/ssl/
.pem

2. Configure Cloudflare DNS

A    │ yourdomain.com │ Your server IP │ Proxied (orange cloud)
A    │ *                            │ Your server IP │ Proxied (orange cloud) 

The wildcard * record ensures that any subdomain resolves to your server through Cloudflare's proxy.
Set your SSL/TLS encryption mode to Full (not "Full (Strict)" and not "Flexible"). This tells Cloudflare to connect to your origin over HTTPS and accept the Origin Certificate.

3. Configure Kamal
In your deploy.yml (or deploy.staging.yml), configure the proxy with custom SSL:
proxy:
  ssl:
    certificate_pem: SSL_CERTIFICATE_PEM
    private_key_pem: SSL_PRIVATE_KEY_PEM
  hosts:
    - yourdomain.com
    - "*.yourdomain.com"
  forward_headers: true
  healthcheck:
    interval: 10
    path: /up
    timeout: 15

Notice:
  • ssl is a hash with certificate_pem and private_key_pem — these reference secret names, not file paths
  • hosts includes both the apex domain and the wildcard
  • forward_headers: true is important because Cloudflare sets X-Forwarded-* headers your Rails app needs

4. Set Up Secrets
In .kamal/secrets:
SSL_CERTIFICATE_PEM=$(cat config/ssl/yourdomain.pem)
SSL_PRIVATE_KEY_PEM=$(cat config/ssl/yourdomain.key)

Kamal reads these at deploy time and passes them to kamal-proxy. The cert files never leave your local machine or get baked into the Docker image.

5. Deploy
kamal deploy -c config/deploy.staging.yml

That's it. Every subdomain now works over HTTPS automatically.

Why This Works
Let's Encrypt uses HTTP-01 challenges, which require each domain to be individually verified. Wildcard certificates require DNS-01 challenges, which kamal-proxy doesn't support.
Cloudflare Origin Certificates skip this entirely. They're pre-generated, cover the wildcard, and are trusted by Cloudflare's edge. Since all traffic flows through Cloudflare's proxy, the Origin Certificate only needs to be trusted by Cloudflare — not by browsers directly. Cloudflare handles the browser-facing SSL with their own certificates.

What We Tried (And Why It Didn't Work)
Let's Encrypt with explicit hosts: Works, but requires adding each subdomain to the config and redeploying. Not scalable.
ssl: false with Cloudflare Flexible mode: Removes encryption between Cloudflare and your origin. Security downgrade, and caused intermittent 525 errors.
Traefik as a replacement proxy: Works with wildcards, but replaces Kamal's built-in proxy and adds complexity.

The Rails Side
On the Rails side, our app uses subdomain-based routing to identify tenants:
# config/routes.rb
constraints(Constraints::SchoolDomain.new) do
  # School-specific routes
end

constraints(Constraints::RootDomain.new) do
  # Public website routes
end

The constraint checks request.subdomain against the schools table. When a new school is created and gets a subdomain, it just works — no deployment, no SSL config, no DNS changes.

Summary
If you're building a multi-tenant Rails app with Kamal 2 and need wildcard subdomain support:
  1. - Generate a Cloudflare Origin Certificate (free, 15-year validity)
  2. - Use Kamal's custom SSL support via secrets
  3. - Set Cloudflare to "Full" SSL mode
  4. - Add a wildcard DNS record
- No Traefik. No redeployments per tenant. No Let's Encrypt limitations. Just deploy once and every new subdomain works instantly.

We're running this setup in production with Kamal 2.11.0 on Hetzner, serving multiple schools across Nigeria. It's been rock solid.
WhatsApp