Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,60 @@ your certificate file and the corresponding private key:
kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem


### SAN Certificate Batching

Kamal Proxy automatically batches multiple domains into a single SAN (Subject
Alternative Name) certificate. This dramatically reduces the number of certificates
needed and helps avoid Let's Encrypt rate limits.

**How it works:**

1. When services with TLS enabled are deployed, domains are queued for certificate provisioning
2. All pending domains (up to 100) are batched into a single certificate request
3. The resulting SAN certificate covers all domains, regardless of their root domain

**Example:**

```bash
kamal-proxy deploy app1 --target web-1:3000 --host app.example.com --tls
kamal-proxy deploy app2 --target web-2:3000 --host api.other.org --tls
kamal-proxy deploy app3 --target web-3:3000 --host mysite.net --tls
# → All three services share a single certificate with SANs:
# app.example.com, api.other.org, mysite.net
```

**Rate limit impact:**

| Domains | Without batching | With SAN batching |
|---------|------------------|-------------------|
| 10 | 10 certificates | 1 certificate |
| 100 | 100 certificates | 1 certificate |
| 1000 | 1000 certificates| 10 certificates |

**Benefits:**

- **Dramatic reduction**: Up to 100 domains per certificate
- **Rate limit friendly**: 1000 domains = 10 certs instead of 1000
- **Any domains**: Works across different root domains
- **Zero configuration**: Just deploy with `--tls` as usual

**Configuration options:**

| Flag | Environment Variable | Default | Description |
|------|---------------------|---------|-------------|
| `--acme-email` | `ACME_EMAIL` | (required for TLS) | Contact email for Let's Encrypt |
| `--acme-directory` | `ACME_DIRECTORY` | Let's Encrypt production | ACME directory URL |

**Using Let's Encrypt staging environment:**

For testing, use the staging environment to avoid rate limits:

```bash
kamal-proxy run --acme-email admin@example.com \
--acme-directory https://acme-staging-v02.api.letsencrypt.org/directory
```


## Specifying `run` options with environment variables

In some environments, like when running a Docker container, it can be convenient
Expand Down
13 changes: 10 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,38 @@ go 1.25.5

require (
github.com/coder/websocket v1.8.12
github.com/go-acme/lego/v4 v4.30.1
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.57.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/miekg/dns v1.1.69 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
26 changes: 20 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-acme/lego/v4 v4.30.1 h1:tmb6U0lvy8Mc3lQbqKwTat7oAhE8FUYNJ3D0gSg6pJU=
github.com/go-acme/lego/v4 v4.30.1/go.mod h1:V7m/Ip+EeFkjOe028+zeH+SwWtESxw1LHelwMIfAjm4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand All @@ -22,10 +28,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
Expand Down Expand Up @@ -57,14 +65,20 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().IntVar(&globalConfig.MetricsPort, "metrics-port", getEnvInt("METRICS_PORT", 0), "Publish metrics on the specified port (default zero to disable)")
runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3")

// ACME/TLS configuration
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL")

return runCommand
}

Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ func getEnvBool(key string, defaultValue bool) bool {

return boolValue
}

func getEnvString(key string, defaultValue string) string {
value, ok := findEnv(key)
if !ok {
return defaultValue
}
return value
}
8 changes: 8 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type Config struct {
HTTP3Enabled bool

AlternateConfigDir string

// ACME configuration for automatic TLS
ACMEEmail string
ACMEDirectory string
}

func (c Config) SocketPath() string {
Expand All @@ -34,6 +38,10 @@ func (c Config) CertificatePath() string {
return path.Join(c.dataDirectory(), "certs")
}

func (c Config) ACMEStatePath() string {
return path.Join(c.dataDirectory(), "acme.state")
}

// Private

func (c Config) runtimeDirectory() string {
Expand Down
137 changes: 137 additions & 0 deletions internal/server/domain_group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package server

import (
"sort"
"strings"

"golang.org/x/net/publicsuffix"
)

// DomainGroup represents a group of domains that share the same root domain
// and can be included in a single SAN certificate
type DomainGroup struct {
// RootDomain is the registrable domain (e.g., "example.com")
RootDomain string

// Domains contains all full domain names in this group
Domains []string

// IncludesApex is true if the apex domain itself is in the group
IncludesApex bool
}

// DomainGrouper groups domains by their root domain for efficient certificate management
type DomainGrouper struct {
// MinDomainsForBatching is the minimum number of domains needed to batch into a SAN cert
// Default is 2 (batch when we have 2+ domains for the same root)
MinDomainsForBatching int
}

// NewDomainGrouper creates a new DomainGrouper with default settings
func NewDomainGrouper() *DomainGrouper {
return &DomainGrouper{
MinDomainsForBatching: 2,
}
}

// GroupDomains analyzes a list of domains and groups them by root domain
func (g *DomainGrouper) GroupDomains(domains []string) []*DomainGroup {
if len(domains) == 0 {
return nil
}

// Group domains by their root domain
groups := make(map[string]*DomainGroup)

for _, domain := range domains {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
continue
}

rootDomain, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
// If we can't determine the root, treat domain as its own root
rootDomain = domain
}

group, exists := groups[rootDomain]
if !exists {
group = &DomainGroup{
RootDomain: rootDomain,
Domains: []string{},
}
groups[rootDomain] = group
}

// Check if this is the apex domain
if domain == rootDomain {
group.IncludesApex = true
}

// Add domain if not already present
if !contains(group.Domains, domain) {
group.Domains = append(group.Domains, domain)
}
}

// Convert map to sorted slice
result := make([]*DomainGroup, 0, len(groups))
for _, group := range groups {
// Sort domains within each group for consistent ordering
sort.Strings(group.Domains)
result = append(result, group)
}

// Sort groups by root domain for consistent ordering
sort.Slice(result, func(i, j int) bool {
return result[i].RootDomain < result[j].RootDomain
})

return result
}

// ShouldBatch returns true if the group has enough domains to warrant batching
func (g *DomainGrouper) ShouldBatch(group *DomainGroup) bool {
return len(group.Domains) >= g.MinDomainsForBatching
}

// GetDomainsForCert returns the domains that should be included in the certificate
// For SAN certificates, this is simply all domains in the group
func (group *DomainGroup) GetDomainsForCert() []string {
return group.Domains
}

// CertificateIdentifier returns a unique identifier for this group's certificate
func (group *DomainGroup) CertificateIdentifier() string {
if len(group.Domains) == 1 {
return "single:" + group.Domains[0]
}
return "san:" + group.RootDomain
}

// MatchesDomain checks if a domain belongs to this group
func (group *DomainGroup) MatchesDomain(domain string) bool {
domain = strings.ToLower(domain)
for _, d := range group.Domains {
if d == domain {
return true
}
}
return false
}

// GetRootDomain extracts the registrable domain from a full domain name
// e.g., "app.example.com" -> "example.com", "www.example.co.uk" -> "example.co.uk"
func GetRootDomain(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(domain))
}

func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
Loading