Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b807ef5
implement the On-Demand TLS functionality
did Nov 1, 2024
fbbcdd3
remove debugging statements and old code
did Nov 1, 2024
92398c0
the on-demand URL must contain the http scheme
did Nov 1, 2024
d22e6a3
Update README.md
did Nov 3, 2024
24ab71c
don't check for the len of hosts when evaluating the TLSOnDemandUrl
did Nov 30, 2024
89ad217
chore: lint the code
did Dec 2, 2024
80cd7f9
chore: remove a debug message
did Dec 2, 2024
3247a51
chore: better logging when contacting the on demand url failed + writ…
did Jul 9, 2025
25a7d09
chore: remove a warning about a wrong type
did Jul 10, 2025
08de3cd
feat: no need to pass the host config attribute if tls-on-demand-url …
did Jul 10, 2025
4160a10
feat: allow path and external url for the TLSOnDemandURL option
did Jul 11, 2025
adf8772
chore: update the README.md based on the new path functionality
did Jul 11, 2025
30eadc4
fix: don't pass a Nil body when contacting a local host
did Aug 8, 2025
6daf93a
fix: new options were not correctly applied when restoring the state
did Feb 25, 2026
7fa1b2d
fix: treat local TLS on-demand checks as HTTPS when redirects are ena…
did Feb 26, 2026
65bef1e
fix: the previous didn't work in production, use another approach by …
did Feb 26, 2026
a55cfa5
chore: satisfy Copilot code review (WIP)
did Mar 4, 2026
bb45ac3
Update internal/server/service_test.go
did Mar 4, 2026
78f89e8
Update internal/server/tls_on_demand.go
did Mar 4, 2026
d588d81
chore: don't bypass the path prefix validation check
did Mar 4, 2026
af47399
Update internal/server/router_test.go
did Mar 4, 2026
d0228bc
chore: rename TLSOnDemandUrl into TLSOnDemandURL
did Mar 4, 2026
fec3323
chore: parse and merge query params instead of string-concatenating:
did Mar 4, 2026
3844ebd
Update internal/server/service.go
did Mar 4, 2026
0b33d56
Update internal/server/tls_on_demand.go
did Mar 4, 2026
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
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Kamal Proxy - A minimal HTTP proxy for zero-downtime deployments


## What it does

Kamal Proxy is a tiny HTTP proxy, designed to make it easy to coordinate
Expand All @@ -14,7 +13,6 @@ which provides a complete deployment experience including container packaging
and provisioning. However, Kamal Proxy could also be used standalone or as part
of other deployment tooling.


## A quick overview

To run an instance of the proxy, use the `kamal-proxy run` command. There's no
Expand Down Expand Up @@ -98,7 +96,6 @@ Only one service at a time can route a specific host:
kamal-proxy remove service1
kamal-proxy deploy service2 --target web-2:3000 --host app1.example.com # succeeds


### Path-based routing

For applications that split their traffic to different services based on the
Expand All @@ -118,7 +115,6 @@ the original path (including the prefix), specify `--strip-path-prefix=false`:

kamal-proxy deploy service1 --target web-1:3000 --path-prefix=/api --strip-path-prefix=false


### Automatic TLS

Kamal Proxy can automatically obtain and renew TLS certificates for your
Expand All @@ -133,6 +129,33 @@ Additionally, when using path-based routing, TLS options must be set on the
root path. Services deployed to other paths on the same host will use the same
TLS settings as those specified for the root path.

### On-demand TLS

In addition to the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate
for any host allowed by an external API endpoint of your choice. This avoids hard-coding hosts in the configuration, especially when you don't know the hosts at startup.

kamal-proxy deploy service1 --target web-1:3000 --host "" --tls --tls-on-demand-url="http://localhost:4567/check"
Comment thread
did marked this conversation as resolved.

The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance.
Kamal Proxy will call the on-demand URL with a query string parameter `host` containing the hostname received by Kamal Proxy (for example, `?host=hostname.example.com`).

- The HTTP request to the on-demand URL will time out after 2 seconds. If the endpoint is unreachable or slow, certificate issuance will fail for that host.
- If the endpoint returns any status other than 200, Kamal Proxy will log the status code and up to 256 bytes of the response body for debugging.
- **Security note:** The on-demand URL acts as an authorization gate for certificate issuance. It should be protected and only allow trusted hosts. If compromised, unauthorized certificates could be issued.
- If `--tls-on-demand-url` is not set, Kamal Proxy falls back to a static whitelist of hosts.
- If using a local path (for example, `/allow-host`), ensure that endpoint is reachable through your deployed app and returns quickly.

**Best practice:**

- Ensure your on-demand endpoint is fast, reliable, and protected (e.g., behind authentication or on a private network).
- Only allow hosts you control to prevent abuse.

Example endpoint logic (pseudo-code):

if host in allowed_hosts:
return 200 OK
else:
return 403 Forbidden

### Custom TLS certificate

Expand All @@ -142,6 +165,29 @@ 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

## TLSOnDemandURL Option

The `TLSOnDemandURL` option can be set to either:

- **An external URL** (e.g., `https://my-allow-service/allow-host`):
- The service will make an HTTP request to this external URL to determine if a certificate should be issued for a given host.

- **A local path** (e.g., `/allow-host`):
- The service will internally route a request to this path using its own load balancer and handler. You must ensure your service responds to this path appropriately.

### Example: External URL

```yaml
TLSOnDemandURL: "https://my-allow-service/allow-host"
```

### Example: Local Path

```yaml
TLSOnDemandURL: "/allow-host"
```
Comment on lines +178 to +188

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation uses YAML syntax in the examples (lines 184-191), but this appears to be a CLI tool that uses command-line flags, not YAML configuration. The examples should use the CLI flag format (--tls-on-demand-url) as shown in line 142, not YAML format, to avoid confusing users about how to configure this option.

Copilot uses AI. Check for mistakes.

When using a local path, your service should implement a handler for the specified path (e.g., `/allow-host`) that returns `200 OK` to allow certificate issuance, or another status code to deny it.

## Specifying `run` options with environment variables

Expand All @@ -163,7 +209,6 @@ example:

KAMAL_PROXY_HTTP_PORT=8080 kamal-proxy run


## Building

To build Kamal Proxy locally, if you have a working Go environment you can:
Expand All @@ -174,7 +219,6 @@ Alternatively, build as a Docker container:

make docker


## Trying it out

See the [example](./example) folder for a Docker Compose setup that you can use
Expand Down
14 changes: 10 additions & 4 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.StripPrefix, "strip-path-prefix", true, "With --path-prefix, strip prefix from request before forwarding")

deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSEnabled, "tls", false, "Configure TLS for this target (requires a non-empty host)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSOnDemandURL, "tls-on-demand-url", "", "Will make an HTTP request to the given URL, asking whether a host is allowed to have a certificate issued.")
deployCommand.cmd.Flags().BoolVar(&deployCommand.tlsStaging, "tls-staging", false, "Use Let's Encrypt staging environment for certificate provisioning")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSCertificatePath, "tls-certificate-path", "", "Configure custom TLS certificate path (PEM format)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSPrivateKeyPath, "tls-private-key-path", "", "Configure custom TLS private key path (PEM format)")
Expand Down Expand Up @@ -101,13 +102,18 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error {
}

if c.args.ServiceOptions.TLSEnabled {
if len(c.args.ServiceOptions.Hosts) == 0 {
return fmt.Errorf("host must be set when using TLS")
}

if !slices.Contains(c.args.ServiceOptions.PathPrefixes, "/") {
return fmt.Errorf("TLS settings must be specified on the root path service")
}

if c.args.ServiceOptions.TLSOnDemandURL != "" {
c.args.ServiceOptions.Hosts = []string{""}
return nil
}

if len(c.args.ServiceOptions.Hosts) == 0 {
return fmt.Errorf("host must be set when using TLS")
}
Comment on lines +110 to +116

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preRun calls ServiceOptions.Normalize() before validating TLS host requirements. Since NormalizeHosts converts an empty host list into []string{""}, the later check len(c.args.ServiceOptions.Hosts) == 0 will never catch “no host provided”, and --tls can slip through with an empty host when --tls-on-demand-url is not set. Consider validating that at least one non-empty host is present (e.g., Hosts[0] != "") when TLS is enabled and on-demand URL is empty, and avoid the early return nil so the rest of preRun validations continue to run.

Suggested change
c.args.ServiceOptions.Hosts = []string{""}
return nil
}
if len(c.args.ServiceOptions.Hosts) == 0 {
return fmt.Errorf("host must be set when using TLS")
}
// When using TLS on demand, use an empty host sentinel and allow
// the rest of the validations below to run.
c.args.ServiceOptions.Hosts = []string{""}
} else {
// When TLS is enabled without on-demand URL, require at least one
// non-empty host. Normalize() may have converted an empty host
// list into []string{""}, so we must check for a non-empty value.
if len(c.args.ServiceOptions.Hosts) == 0 || c.args.ServiceOptions.Hosts[0] == "" {
return fmt.Errorf("host must be set when using TLS")
}
}

Copilot uses AI. Check for mistakes.
}

// Validate canonical host is present in hosts when both are specified
Expand Down
37 changes: 37 additions & 0 deletions internal/cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,40 @@ func TestDeployCommand_CanonicalHostValidation(t *testing.T) {
})
}
}

func TestDeployCommand_preRun_TLSOnDemandURL(t *testing.T) {
t.Run("TLS enabled with TLS on-demand URL should set hosts to empty string", func(t *testing.T) {
deployCmd := newDeployCommand()

// Set flags for TLS with on-demand URL
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("tls-on-demand-url", "http://example.com/validate")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/")

// Call preRun
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.NoError(t, err)

// Verify that hosts is set to empty string
assert.Equal(t, []string{""}, deployCmd.args.ServiceOptions.Hosts)
})

t.Run("TLS enabled without TLS on-demand URL should not modify hosts", func(t *testing.T) {
deployCmd := newDeployCommand()

// Set flags for TLS without on-demand URL
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/")

// Call preRun
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.NoError(t, err)

// Verify that hosts is not modified
assert.Equal(t, []string{"example.com"}, deployCmd.args.ServiceOptions.Hosts)
})

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no coverage for the expected validation behavior when --tls-on-demand-url is set but TLS is configured on a non-root path prefix (which should be rejected, consistent with the existing TLS root-path rule). Adding a test case here will prevent regressions once preRun enforces the root-path requirement for on-demand TLS too.

Suggested change
})
})
t.Run("TLS on-demand URL with non-root path prefix should return error", func(t *testing.T) {
deployCmd := newDeployCommand()
// Set flags for TLS with on-demand URL and non-root path prefix
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("tls-on-demand-url", "http://example.com/validate")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/api")
// Call preRun and expect an error due to non-root path prefix
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.Error(t, err)
})

Copilot uses AI. Check for mistakes.
}
Loading