Skip to content

Allow serving an app on multiple hostnames - #89

Open
bdsimmons wants to merge 3 commits into
basecamp:mainfrom
bdsimmons:multi-host
Open

Allow serving an app on multiple hostnames#89
bdsimmons wants to merge 3 commits into
basecamp:mainfrom
bdsimmons:multi-host

Conversation

@bdsimmons

@bdsimmons bdsimmons commented Aug 11, 2026

Copy link
Copy Markdown

Fixes #88

Summary

Lets an app serve more than one hostname, and optionally redirect the extras to a canonical one:

once deploy ghcr.io/me/app:latest \
  --host example.com --host www.example.com \
  --canonical-host example.com
  • --host is now repeatable on once deploy / once update. Hostnames are stored comma-joined in the existing host settings field, keeping serialized settings (container labels, backups) backward compatible with single-host installs — no migration needed.
  • deployArgs emits one --host per hostname, so kamal-proxy routes all of them and provisions a certificate for each.
  • --canonical-host passes 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.
  • Host lookups and duplicate checks match any of an app's hostnames, so once update www.example.com resolves and conflicting aliases across apps are rejected (CLI, TUI settings form, and backup restore).
  • Display URLs and post-deploy verification use the canonical host when set, otherwise the first hostname.
  • Mixed localhost/public host lists are rejected: TLS is one proxy-wide switch for the whole service, so it can't be right for both.

The TUI hostname field accepts a comma-separated list unchanged.

Testing

  • Unit tests for host parsing, canonical selection/validation, multi-host and canonical deployArgs, and any-host namespace lookups
  • Full suite passes, including the Docker integration tests
  • gofmt and go vet clean

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.
Copilot AI balanced review requested due to automatic review settings August 11, 2026 15:11

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds multi-hostname application routing while preserving the existing serialized settings format.

Changes:

  • Supports repeated --host flags 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.

Comment thread internal/command/deploy.go Outdated
Comment on lines +47 to +48
for _, host := range hosts {
if ns.HostInUse(host) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment on lines 145 to +146
func (s ApplicationSettings) TLSEnabled() bool {
return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.Host)
return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.PrimaryHost())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.
Copilot AI review requested due to automatic review settings August 11, 2026 16:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 of ApplicationSettings, but the TUI install flow still checks the unsplit msg.Hostname (internal/ui/install.go:201). Entering new.example.com,taken.example.com therefore misses an existing taken.example.com route 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 and Namespace.Restore construct settings and deploy without doing so. A mixed value such as public.example.com,app.localhost can therefore still reach deployWithVolume, 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 serialized Settings.Host (internal/command/list.go:39 and internal/ui/dashboard_panel.go:64). Multi-host apps will therefore be displayed as example.com,www.example.com rather than using the first hostname as the canonical display URL promised by the PR. Update those display call sites to render PrimaryHost() 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.
Copilot AI review requested due to automatic review settings August 11, 2026 17:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 raw Host and passes this method; deployment then emits no --host, enables TLS, and builds an invalid https:// verification URL. Reject an empty parsed list with ErrHostRequired.
	hosts := s.Hosts()
	for _, host := range hosts {
		if IsLocalhost(host) != IsLocalhost(hosts[0]) {

internal/ui/settings.go:248

  • Apps deployed with --canonical-host cannot fully edit their host list in the TUI. The application form carries the existing CanonicalHost forward but exposes only Host, so removing the canonical hostname always fails here with ErrCanonicalHostNotServed, 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apps can only be served on a single hostname, so www aliases are unroutable

2 participants