v0.27.0 — user-configurable app subdomains

Users can now customize the subdomain for each app during deployment
instead of using a fixed value. The deploy page shows an editable text
input with the default pre-filled and the base domain as a suffix.

New "subdomain" deploy field type with DNS-safe format validation,
reserved name blocklist, and uniqueness check across deployed stacks.
Locked after deploy — changing requires Remove + Redeploy.

Backward compatible: InjectMissingFields() auto-fills SUBDOMAIN from
.felhom.yml defaults for existing deployed apps on next sync/restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 15:06:22 +01:00
parent f7556b0dad
commit 66817709ad
9 changed files with 191 additions and 22 deletions
+27 -6
View File
@@ -202,6 +202,21 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
}
data["StorageLabels"] = storageLabels
// Build effective subdomain lookup (stored env > metadata fallback)
subdomains := make(map[string]string)
for _, stack := range s.stackMgr.GetStacks() {
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
subdomains[stack.Name] = sd
continue
}
}
}
subdomains[stack.Name] = stack.Meta.Subdomain
}
data["Subdomains"] = subdomains
s.executeTemplate(w, r, "stacks", data)
}
@@ -259,12 +274,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
if alreadyDeployed && appCfg != nil {
for _, f := range meta.AutoGeneratedFields() {
if val, ok := appCfg.Env[f.EnvVar]; ok {
// For domain fields show the full URL (subdomain.base_domain) as displayed on app cards.
if f.Type == "domain" && meta.Subdomain != "" {
autoFieldValues[f.EnvVar] = meta.Subdomain + "." + val
} else {
autoFieldValues[f.EnvVar] = val
}
autoFieldValues[f.EnvVar] = val
}
}
} else if !alreadyDeployed {
@@ -275,6 +285,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
}
data["AutoFieldValues"] = autoFieldValues
// For deployed apps, pass stored field values so subdomain and other user fields show current values
if alreadyDeployed && appCfg != nil {
data["DeployedFieldValues"] = appCfg.Env
}
// Storage paths with free space info for deploy dropdown
var deployPaths []DeployStoragePath
for _, sp := range s.settings.GetSchedulableStoragePaths() {
@@ -406,6 +420,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
}
}
// Determine effective subdomain (stored env > metadata fallback)
effectiveSubdomain := found.Meta.Subdomain
if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" {
effectiveSubdomain = sd
}
data := s.baseData("stacks", found.Meta.DisplayName)
data["Stack"] = found
data["Meta"] = found.Meta
@@ -414,6 +434,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["CurrentValues"] = currentValues
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
data["EffectiveSubdomain"] = effectiveSubdomain
s.executeTemplate(w, r, "app_info", data)
}