Allow serving an app on multiple hostnames - #89
Conversation
Apps could only be registered with the proxy under a single hostname, so common aliases like www were unroutable and got no TLS certificate, even though kamal-proxy supports multiple hosts per service. The --host flag can now be repeated. Hostnames are stored as a comma-separated list in the existing host settings field, keeping serialized settings backward compatible with single-host installs. The first hostname is canonical: it is used for display URLs and post-deploy HTTP verification. Host lookups and conflict checks match any of an app's hostnames, so `once update www.example.com` works too.
There was a problem hiding this comment.
Pull request overview
Adds multi-hostname application routing while preserving the existing serialized settings format.
Changes:
- Supports repeated
--hostflags and proxy arguments. - Adds canonical-host and multi-host lookup behavior.
- Expands unit coverage for hostname parsing and routing.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
internal/ui/settings.go |
Checks every submitted hostname for conflicts. |
internal/docker/proxy.go |
Emits repeated proxy host arguments. |
internal/docker/proxy_test.go |
Tests multi-host proxy arguments. |
internal/docker/namespace.go |
Matches and validates every app hostname. |
internal/docker/namespace_test.go |
Tests alias lookup and conflicts. |
internal/docker/application.go |
Uses the primary host and deploys all aliases. |
internal/docker/application_settings.go |
Parses host lists and selects the canonical host. |
internal/docker/application_settings_test.go |
Tests host parsing, canonical selection, and TLS. |
internal/command/update.go |
Checks updated aliases for conflicts. |
internal/command/settings_flags.go |
Makes --host repeatable. |
internal/command/deploy.go |
Builds and deploys comma-joined host settings. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for _, host := range hosts { | ||
| if ns.HostInUse(host) { |
There was a problem hiding this comment.
Good catch — fixed in 6e92319. buildSettings now runs before the duplicate check, and the check iterates settings.Hosts(), so the normalized values are what get compared.
| func (s ApplicationSettings) TLSEnabled() bool { | ||
| return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.Host) | ||
| return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.PrimaryHost()) |
There was a problem hiding this comment.
Agreed, the order-dependence was a real bug. Rather than trying to serve a mixed list correctly — kamal-proxy takes one --tls switch for the whole service, so there's no way to enable it per-hostname — 6e92319 rejects mixed localhost/public lists in Validate with a new ErrMixedLocalhostHosts. TLSEnabled keeps using the primary host, which is now unambiguous because validation guarantees every hostname agrees. The TUI settings form previously bypassed Validate entirely, so it now calls it on submit and surfaces the error inline.
…localhost lists Duplicate detection ran on the raw flag values while deployment used the parsed list, so `--host ' example.com '` could slip past the check and then register a conflicting normalized hostname with the proxy. Build the settings first and check the parsed hosts. TLS is a single proxy-wide switch covering every hostname an app serves, so deriving it from the primary host alone made mixed lists order-dependent: `example.com,app.localhost` would ask ACME for a `.localhost` certificate, while the reverse order silently disabled TLS. Reject mixed localhost/public lists in Validate, and run that validation on TUI submits, which previously bypassed it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
internal/docker/application_settings.go:123
Hosts()now makes comma-separated aliases active for every source ofApplicationSettings, but the TUI install flow still checks the unsplitmsg.Hostname(internal/ui/install.go:201). Enteringnew.example.com,taken.example.comtherefore misses an existingtaken.example.comroute and proceeds to deploy both aliases. Parse the install value and check each hostname before starting the install activity, as the CLI and settings form now do.
for _, h := range strings.Split(s.Host, ",") {
if h = strings.TrimSpace(h); h != "" {
hosts = append(hosts, h)
}
internal/docker/application_settings.go:147
- This invariant is only effective when callers invoke
Validate(), but the TUI installer andNamespace.Restoreconstruct settings and deploy without doing so. A mixed value such aspublic.example.com,app.localhostcan therefore still reachdeployWithVolume, which emits both hosts and selects TLS from only the primary host—the exact order-dependent failure this validation is meant to prevent. Enforce validation at the common deployment boundary or in every deployment entry point.
hosts := s.Hosts()
for _, host := range hosts {
if IsLocalhost(host) != IsLocalhost(hosts[0]) {
return ErrMixedLocalhostHosts
internal/docker/application.go:112
- Although
URL()now targets the primary hostname, the visible CLI/TUI labels still render the serializedSettings.Host(internal/command/list.go:39andinternal/ui/dashboard_panel.go:64). Multi-host apps will therefore be displayed asexample.com,www.example.comrather than using the first hostname as the canonical display URL promised by the PR. Update those display call sites to renderPrimaryHost()while retaining the full list in editing forms.
base := scheme + "://" + a.Settings.PrimaryHost()
Serving the same app on several hostnames leaves every alias returning
200, which is usually not what you want for a www alias: it splits search
engine ranking across two URLs. kamal-proxy already supports redirecting
to a canonical hostname, so expose it.
once deploy img --host example.com --host www.example.com \
--canonical-host example.com
The canonical host must be one of the app's hostnames, otherwise visitors
are redirected to a hostname the proxy does not route. Display URLs and
post-deploy verification use the canonical host when one is set.
Also fixes the TUI's TLS field, which tested the raw hostname value for
localhost and so failed to detect it once that value could hold a list.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/docker/application_settings.go:157
Hosts()drops empty entries, but validation never requires the parsed list to contain a hostname. For example,--host ','(or comma/whitespace entered in the TUI) has a nonempty rawHostand passes this method; deployment then emits no--host, enables TLS, and builds an invalidhttps://verification URL. Reject an empty parsed list withErrHostRequired.
hosts := s.Hosts()
for _, host := range hosts {
if IsLocalhost(host) != IsLocalhost(hosts[0]) {
internal/ui/settings.go:248
- Apps deployed with
--canonical-hostcannot fully edit their host list in the TUI. The application form carries the existingCanonicalHostforward but exposes onlyHost, so removing the canonical hostname always fails here withErrCanonicalHostNotServed, with no control available to clear or change it. Expose the canonical host in the form, or define an explicit clearing policy when the host list changes.
if err := msg.Settings.Validate(); err != nil {
m.err = err
return m, nil
Fixes #88
Summary
Lets an app serve more than one hostname, and optionally redirect the extras to a canonical one:
--hostis now repeatable ononce deploy/once update. Hostnames are stored comma-joined in the existinghostsettings field, keeping serialized settings (container labels, backups) backward compatible with single-host installs — no migration needed.deployArgsemits one--hostper hostname, so kamal-proxy routes all of them and provisions a certificate for each.--canonical-hostpasses through to kamal-proxy's existing flag, so a www alias can 301 to the apex instead of serving duplicate content. It must be one of the app's hostnames — otherwise visitors get redirected to a hostname the proxy doesn't route.once update www.example.comresolves and conflicting aliases across apps are rejected (CLI, TUI settings form, and backup restore).The TUI hostname field accepts a comma-separated list unchanged.
Testing
deployArgs, and any-host namespace lookupsgofmtandgo vetclean