Add subdirectory routing mode for public domain deployments

Apps can now be served at domain.com/appname/ instead of requiring
subdomain DNS records (appname.domain.com). Supports three subpath
modes per template: native (URL base env var), strip (handle_path),
and none (incompatible warning). Tested on Linux with deploy/removal
lifecycle verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 03:03:17 -08:00
parent f61e85d9a7
commit 77030931b7
13 changed files with 407 additions and 41 deletions

View File

@@ -29,6 +29,7 @@ const APP_TEMPLATES = {
subdomain: "plex",
defaultPort: 32400,
healthCheck: "/web/index.html",
subpathSupport: 'none',
mediaMount: {
required: true,
containerPath: "/data",
@@ -75,6 +76,8 @@ const APP_TEMPLATES = {
subdomain: "jellyfin",
defaultPort: 8096,
healthCheck: "/health",
subpathSupport: 'native',
urlBaseEnv: 'JELLYFIN_BaseUrl',
mediaMount: {
required: true,
containerPath: "/media",
@@ -113,6 +116,7 @@ const APP_TEMPLATES = {
subdomain: "emby",
defaultPort: 8096,
healthCheck: "/emby/web/",
subpathSupport: 'none',
mediaMount: {
required: true,
containerPath: "/media",
@@ -152,6 +156,8 @@ const APP_TEMPLATES = {
subdomain: "sonarr",
defaultPort: 8989,
healthCheck: "/api/v3/system/status",
subpathSupport: 'native',
urlBaseEnv: 'URL_BASE',
setupInstructions: [
"Configure download clients (qBittorrent, etc.)",
"Add indexers for content discovery",
@@ -182,7 +188,9 @@ const APP_TEMPLATES = {
},
subdomain: "radarr",
defaultPort: 7878,
healthCheck: "/api/v3/system/status"
healthCheck: "/api/v3/system/status",
subpathSupport: 'native',
urlBaseEnv: 'URL_BASE'
},
"prowlarr": {
@@ -204,7 +212,9 @@ const APP_TEMPLATES = {
},
subdomain: "prowlarr",
defaultPort: 9696,
healthCheck: "/api/v1/system/status"
healthCheck: "/api/v1/system/status",
subpathSupport: 'native',
urlBaseEnv: 'URL_BASE'
},
"qbittorrent": {
@@ -231,6 +241,8 @@ const APP_TEMPLATES = {
subdomain: "torrent",
defaultPort: 8080,
healthCheck: "/",
subpathSupport: 'native',
urlBaseEnv: 'WEBUI_BASE_PATH',
setupInstructions: [
"Default login: admin/adminadmin",
"Change default password immediately",
@@ -262,6 +274,7 @@ const APP_TEMPLATES = {
subdomain: "cloud",
defaultPort: 8080,
healthCheck: "/status.php",
subpathSupport: 'none',
setupInstructions: [
"Change the default admin password",
"Configure trusted domains",
@@ -301,6 +314,7 @@ const APP_TEMPLATES = {
subdomain: "code",
defaultPort: 8443,
healthCheck: "/healthz",
subpathSupport: 'strip',
secrets: [
{
envVar: "VSCODE_PASSWORD",
@@ -332,7 +346,8 @@ const APP_TEMPLATES = {
},
subdomain: "portainer",
defaultPort: 9000,
healthCheck: "/api/status"
healthCheck: "/api/status",
subpathSupport: 'strip'
},
"grafana": {
@@ -353,6 +368,8 @@ const APP_TEMPLATES = {
subdomain: "grafana",
defaultPort: 3000,
healthCheck: "/api/health",
subpathSupport: 'native',
urlBaseEnv: 'GF_SERVER_ROOT_URL',
secrets: [
{
envVar: "GRAFANA_ADMIN_PASSWORD",
@@ -380,7 +397,8 @@ const APP_TEMPLATES = {
},
subdomain: "uptime",
defaultPort: 3002,
healthCheck: "/"
healthCheck: "/",
subpathSupport: 'strip'
},
// === NETWORKING & SECURITY ===
@@ -406,6 +424,7 @@ const APP_TEMPLATES = {
subdomain: "pihole",
defaultPort: 80,
healthCheck: "/admin/",
subpathSupport: 'strip',
secrets: [
{
envVar: "PIHOLE_WEB_PASSWORD",
@@ -443,6 +462,7 @@ const APP_TEMPLATES = {
subdomain: "vpn",
defaultPort: 51820,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure your external IP/domain",
"Set up port forwarding on router",
@@ -477,6 +497,7 @@ const APP_TEMPLATES = {
subdomain: "dns1",
defaultPort: 5380,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Access web interface at https://dns1.sami",
"Login with admin credentials",
@@ -529,6 +550,7 @@ const APP_TEMPLATES = {
subdomain: "dns2",
defaultPort: 953,
healthCheck: null,
subpathSupport: 'strip',
setupInstructions: [
"Configure zone files in /opt/bind9/config/",
"Create named.conf.local for your .sami zone",
@@ -570,6 +592,7 @@ const APP_TEMPLATES = {
subdomain: "dns3",
defaultPort: 8081,
healthCheck: "/api/v1/servers",
subpathSupport: 'strip',
setupInstructions: [
"Access API at https://dns3.sami:8081",
"Use API key for authentication",
@@ -625,6 +648,7 @@ const APP_TEMPLATES = {
subdomain: "dns4",
defaultPort: 53,
healthCheck: null,
subpathSupport: 'strip',
setupInstructions: [
"Create Corefile in /opt/coredns/config/",
"Define .sami zone with file plugin",
@@ -656,6 +680,7 @@ const APP_TEMPLATES = {
subdomain: "files",
defaultPort: 8085,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Default login: admin/admin",
"Change default password immediately",
@@ -686,6 +711,7 @@ const APP_TEMPLATES = {
subdomain: "sync",
defaultPort: 8384,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Add devices using their Device IDs",
"Configure shared folders",
@@ -721,6 +747,7 @@ const APP_TEMPLATES = {
subdomain: "mail",
defaultPort: 25,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure DNS records (MX, SPF, DKIM, DMARC)",
"Create email accounts using setup.sh",
@@ -750,6 +777,7 @@ const APP_TEMPLATES = {
subdomain: "webmail",
defaultPort: 8086,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure IMAP/SMTP server settings",
"Set up database connection",
@@ -776,6 +804,7 @@ const APP_TEMPLATES = {
subdomain: "matrix",
defaultPort: 8008,
healthCheck: "/_matrix/client/versions",
subpathSupport: 'none',
setupInstructions: [
"Generate initial config with --generate",
"Configure homeserver.yaml",
@@ -802,6 +831,7 @@ const APP_TEMPLATES = {
subdomain: "chat",
defaultPort: 3004,
healthCheck: "/api/info",
subpathSupport: 'strip',
setupInstructions: [
"Requires MongoDB - deploy mongo container first",
"Complete admin setup wizard",
@@ -831,6 +861,7 @@ const APP_TEMPLATES = {
subdomain: "home",
defaultPort: 8123,
healthCheck: "/api/",
subpathSupport: 'strip',
setupInstructions: [
"Complete onboarding wizard",
"Add integrations for your smart devices",
@@ -856,6 +887,7 @@ const APP_TEMPLATES = {
subdomain: "nodered",
defaultPort: 1880,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Install additional nodes from palette",
"Create flows for automation",
@@ -884,6 +916,7 @@ const APP_TEMPLATES = {
subdomain: "postgres",
defaultPort: 5432,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Change default password immediately",
"Create databases and users as needed",
@@ -918,6 +951,7 @@ const APP_TEMPLATES = {
subdomain: "redis",
defaultPort: 6379,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure redis.conf for persistence",
"Set up authentication if needed",
@@ -944,6 +978,7 @@ const APP_TEMPLATES = {
subdomain: "mongo",
defaultPort: 27017,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Change default admin password",
"Create application databases and users",
@@ -980,6 +1015,7 @@ const APP_TEMPLATES = {
subdomain: "adminer",
defaultPort: 8087,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Connect to your database servers",
"Supports MySQL, PostgreSQL, SQLite, etc."
@@ -1006,6 +1042,7 @@ const APP_TEMPLATES = {
subdomain: "vault",
defaultPort: 8088,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Change admin token immediately",
"Create your account",
@@ -1036,6 +1073,7 @@ const APP_TEMPLATES = {
subdomain: "ca",
defaultPort: null, // Static site, no port needed
healthCheck: null,
subpathSupport: 'strip',
features: [
"Automatic OS detection",
"One-click installation",
@@ -1065,6 +1103,7 @@ const APP_TEMPLATES = {
subdomain: null,
defaultPort: null,
healthCheck: null,
subpathSupport: 'strip',
features: [
"Current temperature and conditions",
"Wind speed and direction",
@@ -1091,6 +1130,7 @@ const APP_TEMPLATES = {
subdomain: null,
defaultPort: null,
healthCheck: null,
subpathSupport: 'strip',
features: [
"12-hour format with AM/PM",
"Live seconds display",
@@ -1130,6 +1170,8 @@ const APP_TEMPLATES = {
subdomain: "lidarr",
defaultPort: 8686,
healthCheck: "/api/v1/system/status",
subpathSupport: 'native',
urlBaseEnv: 'URL_BASE',
setupInstructions: [
"Configure download clients",
"Add indexers",
@@ -1161,6 +1203,8 @@ const APP_TEMPLATES = {
subdomain: "readarr",
defaultPort: 8787,
healthCheck: "/api/v1/system/status",
subpathSupport: 'native',
urlBaseEnv: 'URL_BASE',
setupInstructions: [
"Configure download clients",
"Add indexers for books",
@@ -1192,6 +1236,8 @@ const APP_TEMPLATES = {
subdomain: "bazarr",
defaultPort: 6767,
healthCheck: "/",
subpathSupport: 'native',
urlBaseEnv: 'BASE_URL',
setupInstructions: [
"Connect to Sonarr and Radarr",
"Configure subtitle providers",
@@ -1218,6 +1264,8 @@ const APP_TEMPLATES = {
subdomain: "requests",
defaultPort: 5055,
healthCheck: "/api/v1/status",
subpathSupport: 'native',
urlBaseEnv: 'BASE_PATH',
setupInstructions: [
"Connect to Plex, Jellyfin, or Emby server",
"Link Sonarr and Radarr",
@@ -1245,6 +1293,8 @@ const APP_TEMPLATES = {
subdomain: "tautulli",
defaultPort: 8181,
healthCheck: "/",
subpathSupport: 'native',
urlBaseEnv: 'TAUTULLI_HTTP_ROOT',
setupInstructions: [
"Connect to Plex server",
"Configure notifications",
@@ -1276,6 +1326,8 @@ const APP_TEMPLATES = {
subdomain: "git",
defaultPort: 3005,
healthCheck: "/",
subpathSupport: 'native',
urlBaseEnv: 'GITEA__server__ROOT_URL',
setupInstructions: [
"Complete initial setup wizard",
"Create admin account",
@@ -1299,6 +1351,7 @@ const APP_TEMPLATES = {
subdomain: "jenkins",
defaultPort: 8089,
healthCheck: "/login",
subpathSupport: 'strip',
setupInstructions: [
"Get initial admin password from logs",
"Install suggested plugins",
@@ -1327,6 +1380,7 @@ const APP_TEMPLATES = {
subdomain: "drone",
defaultPort: 8090,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure Git provider integration",
"Set up shared secret",
@@ -1370,6 +1424,7 @@ const APP_TEMPLATES = {
subdomain: "wiki",
defaultPort: 8091,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Requires MariaDB/MySQL database",
"Default login: admin@admin.com / password",
@@ -1408,6 +1463,7 @@ const APP_TEMPLATES = {
subdomain: "outline",
defaultPort: 3006,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Requires PostgreSQL and Redis",
"Configure OAuth provider",
@@ -1453,6 +1509,7 @@ const APP_TEMPLATES = {
subdomain: "notes",
defaultPort: 3007,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure environment variables",
"Set up database connection",
@@ -1486,6 +1543,7 @@ const APP_TEMPLATES = {
subdomain: "photos",
defaultPort: 2283,
healthCheck: "/api/server-info/ping",
subpathSupport: 'strip',
setupInstructions: [
"Requires PostgreSQL and Redis",
"Install mobile apps for backup",
@@ -1527,6 +1585,7 @@ const APP_TEMPLATES = {
subdomain: "gallery",
defaultPort: 2342,
healthCheck: "/api/v1/status",
subpathSupport: 'strip',
setupInstructions: [
"Change admin password",
"Import your photos",
@@ -1569,6 +1628,8 @@ const APP_TEMPLATES = {
subdomain: "sabnzbd",
defaultPort: 8092,
healthCheck: "/",
subpathSupport: 'native',
urlBaseEnv: 'SABNZBD_URL_BASE',
setupInstructions: [
"Configure Usenet server credentials",
"Set up download categories",
@@ -1599,6 +1660,7 @@ const APP_TEMPLATES = {
subdomain: "nzbget",
defaultPort: 6789,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Default login: nzbget/tegbzn6789",
"Configure news servers",
@@ -1629,6 +1691,8 @@ const APP_TEMPLATES = {
subdomain: "transmission",
defaultPort: 9092,
healthCheck: "/transmission/web/",
subpathSupport: 'native',
urlBaseEnv: 'TRANSMISSION_WEB_HOME',
setupInstructions: [
"Configure download paths",
"Set bandwidth limits",
@@ -1655,6 +1719,7 @@ const APP_TEMPLATES = {
subdomain: "jdownloader",
defaultPort: 5800,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Access web interface to configure",
"Link to MyJDownloader account",
@@ -1685,6 +1750,7 @@ const APP_TEMPLATES = {
subdomain: "music",
defaultPort: 4533,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Point to your music library",
"Create user accounts",
@@ -1716,6 +1782,7 @@ const APP_TEMPLATES = {
subdomain: "airsonic",
defaultPort: 4040,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Default login: admin/admin",
"Configure media folders",
@@ -1743,6 +1810,7 @@ const APP_TEMPLATES = {
subdomain: "dashboard",
defaultPort: 3008,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Edit config files to add services",
"Configure widgets",
@@ -1770,6 +1838,7 @@ const APP_TEMPLATES = {
subdomain: "homarr",
defaultPort: 7575,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Add your services via UI",
"Configure integrations",
@@ -1793,6 +1862,7 @@ const APP_TEMPLATES = {
subdomain: "watch",
defaultPort: 5001,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Add URLs to monitor",
"Configure check frequency",
@@ -1820,6 +1890,7 @@ const APP_TEMPLATES = {
subdomain: "speedtest",
defaultPort: 8093,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Configure test schedule",
"View historical data",
@@ -1843,6 +1914,7 @@ const APP_TEMPLATES = {
subdomain: "whoami",
defaultPort: 8094,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Useful for testing reverse proxy setup",
"Shows request headers and info"
@@ -1873,6 +1945,7 @@ const APP_TEMPLATES = {
subdomain: "pdf",
defaultPort: 8084,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Access the web interface to start manipulating PDFs",
"Supports merge, split, rotate, convert, compress, and more",
@@ -1899,6 +1972,7 @@ const APP_TEMPLATES = {
subdomain: "budget",
defaultPort: 5006,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Create your first budget in the web interface",
"Import transactions from your bank (OFX, QFX, CSV)",
@@ -1930,6 +2004,7 @@ const APP_TEMPLATES = {
subdomain: "mealie",
defaultPort: 9925,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Default login: changeme@example.com / MyPassword",
"Import recipes from URLs or add them manually",
@@ -1965,6 +2040,7 @@ const APP_TEMPLATES = {
subdomain: "paperless",
defaultPort: 8095,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Create admin account via: docker exec -it <container> python3 manage.py createsuperuser",
"Drop documents into the consume folder for automatic import",
@@ -1993,6 +2069,7 @@ const APP_TEMPLATES = {
subdomain: "audiobooks",
defaultPort: 13378,
healthCheck: "/",
subpathSupport: 'strip',
mediaMount: {
required: true,
containerPath: "/audiobooks",
@@ -2031,6 +2108,7 @@ const APP_TEMPLATES = {
subdomain: "books",
defaultPort: 8083,
healthCheck: "/",
subpathSupport: 'strip',
mediaMount: {
required: true,
containerPath: "/books",
@@ -2067,6 +2145,7 @@ const APP_TEMPLATES = {
subdomain: "komga",
defaultPort: 25600,
healthCheck: "/",
subpathSupport: 'strip',
mediaMount: {
required: true,
containerPath: "/data",
@@ -2101,6 +2180,7 @@ const APP_TEMPLATES = {
subdomain: "kavita",
defaultPort: 5004,
healthCheck: "/",
subpathSupport: 'strip',
mediaMount: {
required: true,
containerPath: "/data",
@@ -2134,6 +2214,7 @@ const APP_TEMPLATES = {
subdomain: "notes",
defaultPort: 8085,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Set your password on first access",
"Organize notes in a tree hierarchy",
@@ -2158,6 +2239,7 @@ const APP_TEMPLATES = {
subdomain: "draw",
defaultPort: 8086,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Start drawing immediately - no account needed",
"Share drawings via link for real-time collaboration",
@@ -2182,6 +2264,7 @@ const APP_TEMPLATES = {
subdomain: "tools",
defaultPort: 8087,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"Access the web interface for instant tools access",
"Includes: hash generators, UUID, JWT decoder, base64, regex tester, and 70+ more",
@@ -2208,6 +2291,7 @@ const APP_TEMPLATES = {
subdomain: "logs",
defaultPort: 8088,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: [
"View real-time logs from all running containers",
"Filter and search across container logs",
@@ -2239,6 +2323,7 @@ const APP_TEMPLATES = {
subdomain: "watchtower",
defaultPort: 8089,
healthCheck: "/v1/update",
subpathSupport: 'strip',
setupInstructions: [
"Watchtower checks for image updates daily at 4 AM by default",
"Customize schedule via WATCHTOWER_SCHEDULE (cron format)",
@@ -2269,6 +2354,7 @@ const APP_TEMPLATES = {
subdomain: "auth",
defaultPort: 9010,
healthCheck: "/-/health/live/",
subpathSupport: 'strip',
setupInstructions: [
"Requires a PostgreSQL database and Redis instance",
"Consider deploying via the Dev Environment recipe for full stack",
@@ -2298,6 +2384,7 @@ const APP_TEMPLATES = {
subdomain: "crowdsec",
defaultPort: 8091,
healthCheck: "/health",
subpathSupport: 'strip',
setupInstructions: [
"Register at app.crowdsec.net for community threat intelligence",
"Install bouncers on your reverse proxy for active blocking",
@@ -2331,6 +2418,7 @@ const APP_TEMPLATES = {
subdomain: "mc",
defaultPort: 25565,
healthCheck: null,
subpathSupport: 'none',
setupInstructions: [
"Server accepts the Minecraft EULA automatically",
"Connect with your Minecraft client to the server IP:port",
@@ -2364,6 +2452,7 @@ const APP_TEMPLATES = {
subdomain: "valheim",
defaultPort: 2456,
healthCheck: null,
subpathSupport: 'none',
setupInstructions: [
"Connect via Steam: Add Server > IP:2456",
"Default server password is auto-generated (check environment variables)",

View File

@@ -93,13 +93,31 @@ function validateConfig(config) {
}
}
// Routing mode validation
if (config.routingMode !== undefined) {
const validModes = ['subdomain', 'subdirectory'];
if (!validModes.includes(config.routingMode)) {
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
}
}
// Domain validation
if (config.domain !== undefined) {
if (typeof config.domain !== 'string') {
errors.push('domain must be a string');
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
}
}
// Warn on unknown top-level keys
const knownKeys = [
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped'
'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP'
];
for (const key of Object.keys(config)) {
if (!knownKeys.includes(key)) {

View File

@@ -196,6 +196,10 @@ module.exports = function(ctx, helpers) {
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
}
// Block reserved path names in subdirectory mode
if (ctx.siteConfig.routingMode === 'subdirectory' && helpers.RESERVED_SUBPATHS.includes(config.subdomain)) {
return ctx.errorResponse(res, 400, `[DC-301] "${config.subdomain}" is a reserved path and cannot be used in subdirectory mode`);
}
}
if (config.port && !isValidPort(config.port)) {
return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)');
@@ -236,8 +240,11 @@ module.exports = function(ctx, helpers) {
ctx.log.info('deploy', 'Container is healthy', { containerId });
}
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
// DNS record creation (skip in subdirectory mode — only one domain needed)
let dnsWarning = null;
if (config.createDns) {
if (config.createDns && !isSubdirectoryMode) {
try {
await ctx.dns.createRecord(config.subdomain, config.ip);
ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
@@ -248,7 +255,12 @@ module.exports = function(ctx, helpers) {
}
}
const caddyOptions = { tailscaleOnly: config.tailscaleOnly || false, allowedIPs: config.allowedIPs || [] };
// Caddy config generation
const caddyOptions = {
tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [],
subpathSupport: template.subpathSupport || 'strip',
};
let caddyConfig;
if (template.isStaticSite) {
const sitePath = platformPaths.sitePath(config.subdomain);
@@ -261,29 +273,40 @@ module.exports = function(ctx, helpers) {
caddyConfig = ctx.caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions);
}
await helpers.addCaddyConfig(config.subdomain, caddyConfig);
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), tailscaleOnly: config.tailscaleOnly || false });
// Write Caddy config (subdirectory: inject into main block; subdomain: append as new block)
if (isSubdirectoryMode && !template.isStaticSite) {
await helpers.ensureMainDomainBlock();
await helpers.addSubpathConfig(config.subdomain, caddyConfig);
} else {
await helpers.addCaddyConfig(config.subdomain, caddyConfig);
}
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), routingMode: ctx.siteConfig.routingMode, tailscaleOnly: config.tailscaleOnly || false });
// Build service URL based on routing mode
const serviceUrl = ctx.buildServiceUrl(config.subdomain);
await ctx.addServiceToConfig({
id: config.subdomain, name: template.name,
logo: template.logo || `/assets/${appId}.png`,
url: serviceUrl,
containerId, appTemplate: appId,
tailscaleOnly: config.tailscaleOnly || false,
routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString()
});
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
const response = {
success: true, containerId, usedExisting,
url: `https://${ctx.buildDomain(config.subdomain)}`,
url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || []
};
if (dnsWarning) response.warning = dnsWarning;
const notificationMessage = usedExisting
? `**${template.name}** configured using existing container.\nURL: https://${ctx.buildDomain(config.subdomain)}`
: `**${template.name}** has been deployed successfully.\nURL: https://${ctx.buildDomain(config.subdomain)}`;
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success');
res.json(response);

View File

@@ -109,6 +109,18 @@ module.exports = function(ctx) {
processed.docker.environment.PLEX_CLAIM = config.plexClaimToken;
}
// Inject URL base env var for subdirectory routing mode
if (ctx.siteConfig.routingMode === 'subdirectory' && template.urlBaseEnv) {
processed.docker.environment = processed.docker.environment || {};
const basePath = `/${config.subdomain}`;
// Some apps need the full URL, not just the path
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/';
} else {
processed.docker.environment[template.urlBaseEnv] = basePath;
}
}
// Apply custom volume overrides
if (config.customVolumes?.length && processed.docker?.volumes) {
processed.docker.volumes = processed.docker.volumes.map(vol => {
@@ -266,6 +278,105 @@ module.exports = function(ctx) {
await ctx.caddy.verifySite(domain);
}
// Reserved paths that cannot be used as subpath names in subdirectory mode
const RESERVED_SUBPATHS = ['api', 'probe', 'assets', 'health', 'dist', 'js', 'css', 'fonts', 'favicon.ico'];
/** Ensure the main domain block with route markers exists in the Caddyfile.
* Called before the first subdirectory app is deployed. */
async function ensureMainDomainBlock() {
if (ctx.siteConfig.routingMode !== 'subdirectory' || !ctx.siteConfig.domain) return;
const content = await ctx.caddy.read();
const domain = ctx.siteConfig.domain;
const ROUTE_MARKER = '# === DashCaddy App Routes ===';
// Already has markers — nothing to do
if (content.includes(ROUTE_MARKER)) return;
// Domain block exists but lacks markers — inject them
if (content.includes(`${domain} {`)) {
const result = await ctx.caddy.modify(c => {
// Insert markers before the final catch-all handle block inside the domain block
const domainStart = c.indexOf(`${domain} {`);
// Find standalone "handle {" (catch-all SPA fallback) — match tabs or spaces
const searchFrom = domainStart + domain.length;
const handleMatch = c.slice(searchFrom).match(/^([ \t]+)handle\s*\{/m);
if (!handleMatch) return null;
const handleIdx = searchFrom + handleMatch.index;
const indent = handleMatch[1];
const markerBlock = `${indent}${ROUTE_MARKER}\n${indent}# === End App Routes ===\n\n`;
return c.slice(0, handleIdx) + markerBlock + c.slice(handleIdx);
});
if (result.success) {
ctx.log.info('caddy', 'Injected route markers into existing domain block', { domain });
}
return;
}
// No domain block at all — create one with dashboard + markers
const dashboardRoot = platformPaths.sitePath('status');
const apiPort = process.env.PORT || 3001;
const block = `\n${domain} {\n root * ${dashboardRoot}\n encode gzip\n\n handle /api/* {\n reverse_proxy localhost:${apiPort}\n }\n\n handle /probe/* {\n reverse_proxy localhost:${apiPort}\n }\n\n ${ROUTE_MARKER}\n # === End App Routes ===\n\n handle {\n @notFile not file {path}\n rewrite @notFile /index.html\n file_server\n }\n}\n`;
const result = await ctx.caddy.modify(c => c + block);
if (result.success) {
ctx.log.info('caddy', 'Created main domain block with route markers', { domain });
} else {
throw new Error(`[DC-303] Failed to create main domain block for ${domain}: ${result.error}`);
}
}
/** Inject a subpath config block between route markers in the Caddyfile. */
async function addSubpathConfig(subdomain, configBlock) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
const END_ROUTE_MARKER = '# === End App Routes ===';
const result = await ctx.caddy.modify(content => {
if (content.includes(marker)) {
ctx.log.info('caddy', 'Subpath config already exists, skipping', { subdomain });
return null;
}
const endIdx = content.indexOf(END_ROUTE_MARKER);
if (endIdx === -1) {
throw new Error(`Route marker "${END_ROUTE_MARKER}" not found in Caddyfile`);
}
// Detect indentation from the end marker line
const lineStart = content.lastIndexOf('\n', endIdx) + 1;
const indent = content.slice(lineStart, endIdx).match(/^([ \t]*)/)?.[1] || '\t';
const injection = `${indent}${marker}\n${configBlock}\n${indent}${endMarker}\n`;
return content.slice(0, endIdx) + injection + content.slice(endIdx);
});
if (!result.success) {
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
}
}
/** Remove a subpath config block from between its markers in the Caddyfile. */
async function removeSubpathConfig(subdomain) {
const marker = `# --- DashCaddy: ${subdomain} ---`;
const endMarker = `# --- End: ${subdomain} ---`;
return await ctx.caddy.modify(content => {
const startIdx = content.indexOf(marker);
if (startIdx === -1) return null;
const endIdx = content.indexOf(endMarker);
if (endIdx === -1) return null;
// Remove from the line start of the marker to the line end of the end marker
const lineStart = content.lastIndexOf('\n', startIdx);
const lineEnd = content.indexOf('\n', endIdx + endMarker.length);
const modified = content.slice(0, lineStart) + content.slice(lineEnd);
return modified.replace(/\n{3,}/g, '\n\n');
});
}
return {
checkPortConflicts,
findExistingContainerByImage,
@@ -273,6 +384,10 @@ module.exports = function(ctx) {
processTemplateVariables,
waitForHealthCheck,
addCaddyConfig,
addSubpathConfig,
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig
};
};

View File

@@ -56,18 +56,30 @@ module.exports = function(ctx, helpers) {
if (shouldDeleteContainer && subdomain) {
try {
const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
const originalLength = content.length;
content = content.replace(siteBlockRegex, '\n');
if (content.length !== originalLength) {
content = content.replace(/\n{3,}/g, '\n\n');
const caddyResult = await ctx.caddy.modify(() => content);
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
// Check if this service was deployed in subdirectory mode
const services = await ctx.servicesStateManager.read();
const serviceList = Array.isArray(services) ? services : [];
const service = serviceList.find(s => s.id === subdomain);
if (service?.routingMode === 'subdirectory') {
// Subdirectory mode: remove handle block from inside main domain block
const subResult = await helpers.removeSubpathConfig(subdomain);
results.caddy = subResult?.success ? 'removed' : 'not found';
} else {
results.caddy = 'not found';
// Subdomain mode: remove standalone domain block
const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
const originalLength = content.length;
content = content.replace(siteBlockRegex, '\n');
if (content.length !== originalLength) {
content = content.replace(/\n{3,}/g, '\n\n');
const caddyResult = await ctx.caddy.modify(() => content);
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
} else {
results.caddy = 'not found';
}
}
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
} catch (error) {

View File

@@ -122,7 +122,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE);
// ===== Site configuration loaded from config.json (#5) =====
// These are read at startup and refreshed on config save.
// All code should use these instead of hardcoded values.
let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '' };
let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' };
function loadSiteConfig() {
try {
@@ -150,6 +150,7 @@ function loadSiteConfig() {
siteConfig.dnsServers = raw.dnsServers || {};
siteConfig.configurationType = raw.configurationType || 'homelab';
siteConfig.domain = raw.domain || '';
siteConfig.routingMode = raw.routingMode || 'subdomain';
}
} catch (e) {
// log.error may not be assigned yet during initial module load
@@ -168,6 +169,16 @@ function buildDomain(subdomain) {
return `${subdomain}${siteConfig.tld}`;
}
/** Build full service URL (protocol + host + path) for a given subdomain.
* Subdirectory mode: https://example.com/sonarr
* Subdomain mode: https://sonarr.example.com */
function buildServiceUrl(subdomain) {
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
return `https://${siteConfig.domain}/${subdomain}`;
}
return `https://${buildDomain(subdomain)}`;
}
/** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */
function buildDnsUrl(server, apiPath, params) {
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
@@ -1149,7 +1160,7 @@ Object.assign(ctx, {
auditLogger, portLockManager,
APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES,
asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage,
buildDomain, getServiceById, readConfig, saveConfig, addServiceToConfig,
buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig,
validateURL, strictLimiter,
totpConfig, saveTotpConfig,
loadSiteConfig, loadNotificationConfig,
@@ -1227,8 +1238,8 @@ app.get('/probe/:id', asyncHandler(async (req, res) => {
} else if (service?.url) {
url = service.url.startsWith('http') ? service.url : `https://${service.url}`;
} else {
// Check via configured TLD through Caddy
url = `https://${buildDomain(id)}`;
// Build URL from configured routing mode (subdomain or subdirectory)
url = buildServiceUrl(id);
}
}
@@ -1552,15 +1563,37 @@ async function getTokenForServer(targetServer, role = 'readonly') {
// (Docker helper functions moved to routes/apps.js)
function generateCaddyConfig(subdomain, ip, port, options = {}) {
const { tailscaleOnly = false, allowedIPs = [] } = options;
const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options;
// Subdirectory mode: generate handle/handle_path block (injected into main domain block)
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
let config = '';
// Native-support apps: use handle (preserve path prefix)
// Strip-mode apps: use handle_path (remove path prefix before proxying)
if (subpathSupport === 'native') {
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
config += `\thandle /${subdomain}/* {\n`;
} else {
config += `\thandle_path /${subdomain}/* {\n`;
}
if (tailscaleOnly) {
config += `\t\t@blocked not remote_ip 100.64.0.0/10`;
if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`;
config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`;
}
config += `\t\treverse_proxy ${ip}:${port}\n`;
config += `\t}`;
return config;
}
// Subdomain mode (default): standalone domain block
let config = `${buildDomain(subdomain)} {\n`;
// Add Tailscale IP restriction if enabled
if (tailscaleOnly) {
// Tailscale CGNAT range: 100.64.0.0/10
config += ` @blocked not remote_ip 100.64.0.0/10`;
// Add any additional allowed IPs
if (allowedIPs.length > 0) {
config += ` ${allowedIPs.join(' ')}`;
}

View File

@@ -451,13 +451,39 @@
</div>
</div>
<div>
<label class="form-label-accent">
🔀 App Routing Mode
</label>
<div style="display: grid; gap: 10px; margin-top: 8px;">
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
<input type="radio" name="routing-mode" value="subdirectory" checked style="margin-top: 3px;" />
<div>
<strong>Subdirectory</strong> <span style="color: var(--muted);">(example.com/app)</span>
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
Only needs a single DNS record. Recommended for most setups.
</div>
</div>
</label>
<label style="display: flex; align-items: start; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
<input type="radio" name="routing-mode" value="subdomain" style="margin-top: 3px;" />
<div>
<strong>Subdomain</strong> <span style="color: var(--muted);">(app.example.com)</span>
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 2px;">
Requires wildcard DNS (*.example.com) or per-app DNS records
</div>
</div>
</label>
</div>
</div>
<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 8px; border: 1px solid var(--accent);">
<h4 class="heading-accent-md">⚠️ Requirements:</h4>
<ul style="margin: 0; padding-left: 20px; font-size: 0.9rem; line-height: 1.6;">
<li>Port 80 and 443 must be open to the internet</li>
<li>Domain DNS must point to this server's public IP</li>
<li>Server must be reachable from the internet</li>
<li>You'll need to configure DNS manually for each subdomain</li>
<li id="dns-requirement-note">Only one DNS record needed (for the main domain)</li>
</ul>
</div>
</div>

View File

@@ -25,6 +25,7 @@
<div class="form-hint-sm">
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
</div>
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
</div>
<!-- DNS Configuration -->
@@ -407,6 +408,25 @@
const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, '');
subdomainInput.value = defaultSubdomain;
// Show subpath compatibility warning in subdirectory mode
const subpathWarning = document.getElementById('subpath-compat-warning');
if (subpathWarning) {
if (SITE.routingMode === 'subdirectory') {
const support = appTemplate.subpathSupport || 'strip';
if (support === 'none') {
subpathWarning.style.display = 'block';
subpathWarning.innerHTML = '<span style="color: #ff9800;">&#9888; <strong>' + appTemplate.name + '</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>';
} else if (support === 'strip') {
subpathWarning.style.display = 'block';
subpathWarning.innerHTML = '<span style="color: var(--muted);">&#9432; ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.</span>';
} else {
subpathWarning.style.display = 'none';
}
} else {
subpathWarning.style.display = 'none';
}
}
// Pre-select DNS/SSL from site config (set during setup wizard)
const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private');
const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal');
@@ -622,7 +642,10 @@
const sslType = document.querySelector('input[name="ssl-type"]:checked').value;
let url = '';
if (dnsType === 'private') {
if (SITE.routingMode === 'subdirectory' && SITE.domain) {
// Subdirectory mode: domain.com/app
url = `https://${SITE.domain}/${subdomain}`;
} else if (dnsType === 'private') {
const protocol = sslType === 'none' ? 'http' : 'https';
url = `${protocol}://${buildDomain(subdomain)}`;
} else if (dnsType === 'public') {

View File

@@ -112,7 +112,7 @@
}
}
function serviceUrl(id) { return `https://${buildDomain(id)}`; }
function serviceUrl(id) { return buildServiceUrl(id); }
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
function buildGrid() {

View File

@@ -32,7 +32,7 @@
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
const urlPreview = document.getElementById('url-preview');
if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`;
if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain);
const config = {
subdomain, port, ip, sslType, caName, existingCa,
@@ -534,7 +534,7 @@
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
];
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
closeAddServiceModal();

View File

@@ -11,7 +11,7 @@
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
document.getElementById('edit-service-name-display').textContent = service.name;
document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`;
document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id);
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
document.getElementById('edit-subdomain').value = service.id;
document.getElementById('edit-port').value = service.port || '';

View File

@@ -33,7 +33,8 @@ const SITE = {
dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {},
configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab',
domain: (_cachedCfg && _cachedCfg.domain) || '',
defaults: (_cachedCfg && _cachedCfg.defaults) || {}
defaults: (_cachedCfg && _cachedCfg.defaults) || {},
routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain'
};
(async function loadSiteConfig() {
try {
@@ -51,10 +52,12 @@ const SITE = {
if (c.configurationType) SITE.configurationType = c.configurationType;
if (c.domain) SITE.domain = c.domain;
if (c.defaults) SITE.defaults = c.defaults;
if (c.routingMode) SITE.routingMode = c.routingMode;
// Cache config so next page load uses correct TLD even if API is slow
localStorage.setItem('dashcaddy_site_config', JSON.stringify({
tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers,
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults,
routingMode: SITE.routingMode
}));
// Render DNS cards dynamically based on configured servers
renderDnsCards();
@@ -68,6 +71,11 @@ const SITE = {
if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; }
})();
function buildDomain(sub) { return sub + SITE.tld; }
function buildServiceUrl(sub) {
if (SITE.routingMode === 'subdirectory' && SITE.domain) return 'https://' + SITE.domain + '/' + sub;
if (SITE.configurationType === 'public' && SITE.domain) return 'https://' + sub + '.' + SITE.domain;
return 'https://' + buildDomain(sub);
}
function getDnsServerAddr(dnsId) {
const s = SITE.dnsServers[dnsId];
return s ? `${s.ip}:${s.port}` : buildDomain(dnsId);

View File

@@ -104,6 +104,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
} else if (currentConfigType === 'public') {
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
const routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory';
const exampleUrls = routingMode === 'subdirectory'
? `https://${domain}/sonarr, https://${domain}/grafana`
: `https://sonarr.${domain}, https://grafana.${domain}`;
const routingLabel = routingMode === 'subdirectory' ? 'Subdirectory (domain.com/app)' : 'Subdomain (app.domain.com)';
html += `
<div>
@@ -112,7 +117,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
<div><strong>Domain:</strong> ${domain}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${email}</div>
<div><strong>Example URLs:</strong> https://app.${domain}, https://cloud.${domain}</div>
<div><strong>Routing:</strong> ${routingLabel}</div>
<div><strong>Example URLs:</strong> ${exampleUrls}</div>
</div>
</div>
`;
@@ -186,8 +192,9 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
} else if (currentConfigType === 'public') {
config.domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
config.email = document.getElementById('setup-public-email')?.value?.trim() || '';
config.routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory';
config.defaults = {
dnsType: 'public',
dnsType: config.routingMode === 'subdirectory' ? 'none' : 'public',
sslType: 'letsencrypt',
targetIP: 'localhost'
};
@@ -311,6 +318,18 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
};
}
// Public routing mode toggle — update requirement text
document.querySelectorAll('input[name="routing-mode"]').forEach(function(radio) {
radio.onchange = function() {
var note = document.getElementById('dns-requirement-note');
if (note) {
note.textContent = this.value === 'subdirectory'
? 'Only one DNS record needed (for the main domain)'
: 'You\'ll need to configure DNS manually for each subdomain';
}
};
});
// Public navigation
const publicBack = document.getElementById('setup-public-back');
if (publicBack) {