Proxy

SAN batching & wildcards

Batch domains onto shared SAN certificates, issue wildcards via DNS-01, and learn hostnames from your app at runtime.

Beyond one host, one cert#

Upstream kamal's automatic TLS is one Let's Encrypt certificate per configured host, HTTP-01 only, single-server. dash-proxy extends the whole surface:

  • SAN batching — many domains share one certificate, so a multi-tenant app with hundreds of custom domains doesn't run into Let's Encrypt rate limits one cert at a time.
  • Wildcard certificates — issued via DNS-01 challenges against your DNS provider's API.
  • Dynamic TLS domains — the proxy polls your application for the hostname list instead of fixing it at deploy time.
  • On-demand TLS — the proxy asks your app per-handshake whether to issue for an unknown hostname.
  • mTLSssl: client_ca_pem: requires client certificates signed by your CA bundle.

All of it terminates at the load balancer when one fronts the fleet — certificates are edge concerns.

Dynamic TLS domains & SAN batching#

ssl_domains tells the proxy to learn hostnames from your application at runtime: it polls source — a path resolved against a healthy app target, or an absolute URL — for the domain list and manages Let's Encrypt certificates for it automatically. With source set, ssl: true is allowed without host/hosts.

config/deploy.yml
proxy:
  ssl: true
  ssl_domains:
    source: /api/v1/kamal/domains
    interval: 300     # poll interval in seconds (proxy default: 300)
    batch_size: 25    # 1 = per-domain certs; 2-25 = stable SAN batching

batch_size is the SAN-batching knob: how many domains share one certificate. The batching is stable — a domain joining or leaving doesn't reshuffle every other domain's certificate.

Authentication tokens for the poll endpoint and the refresh nudge are read from DASH_PROXY_DOMAINS_TOKEN and DASH_PROXY_REFRESH_TOKEN on the proxy container (the KAMAL_PROXY_ names still work as a fallback) — set them via proxy.run.options.env, never as deploy flags. Operate the domain list with:

dash proxy domains list      # what the proxy currently serves
dash proxy domains stats     # certificate issuance state
dash proxy domains refresh   # nudge an immediate re-poll

Wildcard certificates (DNS-01)#

DNS-01 issuance is configured proxy-wide under proxy: run: acme: — every service on the proxy shares the ACME account and DNS provider. DNS-01 activates only when dns_provider is set; unset means HTTP-01 only, so credentials visible in the proxy's environment can never arm DNS-01 on their own.

config/deploy.yml
proxy:
  run:
    acme:
      email: [email protected]
      # One provider for every zone, or a hash pinning zones to the DNS
      # host that serves them, with `default` for the rest.
      dns_provider:
        platform.example: cloudflare
        legacy.example: hetzner
        default: route53
      prefer_wildcard: true   # ask for a wildcard when the provider supports it
      http_fallback: true     # fall back to HTTP-01 when DNS-01 fails
      credentials:
        - CF_DNS_API_TOKEN    # secret names, passed to the proxy as env vars

Providers: cloudflare, digitalocean, gcloud, godaddy, hetzner, namecheap, route53, vultr (plus auto, which picks from the credentials it can see and logs which one it armed). credentials names entries in .dash/secrets; they are written to a 0600 env file on the proxy host and passed with --env-file, never on the command line.

While working out a DNS setup, point directory at Let's Encrypt's staging environment so failed attempts don't burn production rate limits.

A host/hosts name is grouped into a wildcard from the name itself. A name learned from ssl_domains is tenant-owned and may sit in a zone you don't run DNS for, so the proxy never guesses a wildcard for it: from proxy v1.1.0.2, it collapses only under a zone the hash form of dns_provider maps explicitly — mapping a zone is you asserting DNS control over it. Under a mapped zone, every single-label name is ordered as that zone's wildcard; the apex and deeper names ride the same order as concrete identifiers. default and auto never collapse a dynamic name. If the wildcard order fails, its names retry once as concrete identifiers, where http_fallback can still answer — a failing DNS-01 degrades the zone to per-name orders instead of taking it off the air.

A DNS API token can rewrite your zone. Rotating a credential value does not change the proxy's config digest — run dash proxy reboot yourself after a rotation.

On-demand TLS & custom certificates#

The ssl: hash is home to the rest of the TLS surface:

  • certificate_pem / private_key_pem — bring your own certificate, loaded from .dash/secrets.
  • on_demand_url — instead of a fixed host list, the proxy asks this endpoint whether it may issue for the hostname in an incoming handshake (2xx approves). Mutually exclusive with host/hosts, certificate_pem, and ssl_domains — dash rejects the combination at config time.
  • client_ca_pem — mutual TLS: clients must present a certificate signed by this CA bundle.
config/deploy.yml
proxy:
  ssl:
    certificate_pem: CERTIFICATE_PEM
    private_key_pem: PRIVATE_KEY_PEM

Moving certificates between hosts#

Issued certificates are state worth keeping — reissuing hundreds of domains on a host move would take hours and eat rate limits. dash can export and import the proxy's certificate store:

dash proxy export_certs ./certs.tar.gz   # contains private keys — handle accordingly
dash proxy import_certs                  # from an exported archive, or a Traefik acme.json

import_certs also understands Traefik's acme.json, which is the escape hatch for migrating a Traefik-fronted fleet onto dash-proxy without reissuing.