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

View File

@@ -196,6 +196,10 @@ module.exports = function(ctx, helpers) {
if (!REGEX.SUBDOMAIN.test(config.subdomain)) { if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format'); 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)) { if (config.port && !isValidPort(config.port)) {
return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)'); 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 }); 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; let dnsWarning = null;
if (config.createDns) { if (config.createDns && !isSubdirectoryMode) {
try { try {
await ctx.dns.createRecord(config.subdomain, config.ip); await ctx.dns.createRecord(config.subdomain, config.ip);
ctx.log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: 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; let caddyConfig;
if (template.isStaticSite) { if (template.isStaticSite) {
const sitePath = platformPaths.sitePath(config.subdomain); 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); caddyConfig = ctx.caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions);
} }
// 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); await helpers.addCaddyConfig(config.subdomain, caddyConfig);
ctx.log.info('deploy', 'Caddy config added', { domain: ctx.buildDomain(config.subdomain), tailscaleOnly: config.tailscaleOnly || false }); }
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({ await ctx.addServiceToConfig({
id: config.subdomain, name: template.name, id: config.subdomain, name: template.name,
logo: template.logo || `/assets/${appId}.png`, logo: template.logo || `/assets/${appId}.png`,
url: serviceUrl,
containerId, appTemplate: appId, containerId, appTemplate: appId,
tailscaleOnly: config.tailscaleOnly || false, tailscaleOnly: config.tailscaleOnly || false,
routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString() deployedAt: new Date().toISOString()
}); });
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
const response = { const response = {
success: true, containerId, usedExisting, success: true, containerId, usedExisting,
url: `https://${ctx.buildDomain(config.subdomain)}`, url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`, message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || [] setupInstructions: template.setupInstructions || []
}; };
if (dnsWarning) response.warning = dnsWarning; if (dnsWarning) response.warning = dnsWarning;
const notificationMessage = usedExisting const notificationMessage = usedExisting
? `**${template.name}** configured using existing container.\nURL: https://${ctx.buildDomain(config.subdomain)}` ? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
: `**${template.name}** has been deployed successfully.\nURL: https://${ctx.buildDomain(config.subdomain)}`; : `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success'); ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success');
res.json(response); res.json(response);

View File

@@ -109,6 +109,18 @@ module.exports = function(ctx) {
processed.docker.environment.PLEX_CLAIM = config.plexClaimToken; 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 // Apply custom volume overrides
if (config.customVolumes?.length && processed.docker?.volumes) { if (config.customVolumes?.length && processed.docker?.volumes) {
processed.docker.volumes = processed.docker.volumes.map(vol => { processed.docker.volumes = processed.docker.volumes.map(vol => {
@@ -266,6 +278,105 @@ module.exports = function(ctx) {
await ctx.caddy.verifySite(domain); 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 { return {
checkPortConflicts, checkPortConflicts,
findExistingContainerByImage, findExistingContainerByImage,
@@ -273,6 +384,10 @@ module.exports = function(ctx) {
processTemplateVariables, processTemplateVariables,
waitForHealthCheck, waitForHealthCheck,
addCaddyConfig, addCaddyConfig,
addSubpathConfig,
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig generateStaticSiteConfig
}; };
}; };

View File

@@ -56,6 +56,17 @@ module.exports = function(ctx, helpers) {
if (shouldDeleteContainer && subdomain) { if (shouldDeleteContainer && subdomain) {
try { try {
// 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 {
// Subdomain mode: remove standalone domain block
const domain = ctx.buildDomain(subdomain); const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read(); let content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -69,6 +80,7 @@ module.exports = function(ctx, helpers) {
} else { } else {
results.caddy = 'not found'; results.caddy = 'not found';
} }
}
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy }); ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
} catch (error) { } catch (error) {
results.caddy = error.message; results.caddy = error.message;

View File

@@ -122,7 +122,7 @@ licenseManager.loadSecret(LICENSE_SECRET_FILE);
// ===== Site configuration loaded from config.json (#5) ===== // ===== Site configuration loaded from config.json (#5) =====
// These are read at startup and refreshed on config save. // These are read at startup and refreshed on config save.
// All code should use these instead of hardcoded values. // 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() { function loadSiteConfig() {
try { try {
@@ -150,6 +150,7 @@ function loadSiteConfig() {
siteConfig.dnsServers = raw.dnsServers || {}; siteConfig.dnsServers = raw.dnsServers || {};
siteConfig.configurationType = raw.configurationType || 'homelab'; siteConfig.configurationType = raw.configurationType || 'homelab';
siteConfig.domain = raw.domain || ''; siteConfig.domain = raw.domain || '';
siteConfig.routingMode = raw.routingMode || 'subdomain';
} }
} catch (e) { } catch (e) {
// log.error may not be assigned yet during initial module load // log.error may not be assigned yet during initial module load
@@ -168,6 +169,16 @@ function buildDomain(subdomain) {
return `${subdomain}${siteConfig.tld}`; 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) */ /** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */
function buildDnsUrl(server, apiPath, params) { function buildDnsUrl(server, apiPath, params) {
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https'; const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
@@ -1149,7 +1160,7 @@ Object.assign(ctx, {
auditLogger, portLockManager, auditLogger, portLockManager,
APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES, APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES,
asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage, asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage,
buildDomain, getServiceById, readConfig, saveConfig, addServiceToConfig, buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig,
validateURL, strictLimiter, validateURL, strictLimiter,
totpConfig, saveTotpConfig, totpConfig, saveTotpConfig,
loadSiteConfig, loadNotificationConfig, loadSiteConfig, loadNotificationConfig,
@@ -1227,8 +1238,8 @@ app.get('/probe/:id', asyncHandler(async (req, res) => {
} else if (service?.url) { } else if (service?.url) {
url = service.url.startsWith('http') ? service.url : `https://${service.url}`; url = service.url.startsWith('http') ? service.url : `https://${service.url}`;
} else { } else {
// Check via configured TLD through Caddy // Build URL from configured routing mode (subdomain or subdirectory)
url = `https://${buildDomain(id)}`; url = buildServiceUrl(id);
} }
} }
@@ -1552,15 +1563,37 @@ async function getTokenForServer(targetServer, role = 'readonly') {
// (Docker helper functions moved to routes/apps.js) // (Docker helper functions moved to routes/apps.js)
function generateCaddyConfig(subdomain, ip, port, options = {}) { 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`; let config = `${buildDomain(subdomain)} {\n`;
// Add Tailscale IP restriction if enabled
if (tailscaleOnly) { if (tailscaleOnly) {
// Tailscale CGNAT range: 100.64.0.0/10
config += ` @blocked not remote_ip 100.64.0.0/10`; config += ` @blocked not remote_ip 100.64.0.0/10`;
// Add any additional allowed IPs
if (allowedIPs.length > 0) { if (allowedIPs.length > 0) {
config += ` ${allowedIPs.join(' ')}`; config += ` ${allowedIPs.join(' ')}`;
} }

View File

@@ -451,13 +451,39 @@
</div> </div>
</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);"> <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> <h4 class="heading-accent-md">⚠️ Requirements:</h4>
<ul style="margin: 0; padding-left: 20px; font-size: 0.9rem; line-height: 1.6;"> <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>Port 80 and 443 must be open to the internet</li>
<li>Domain DNS must point to this server's public IP</li> <li>Domain DNS must point to this server's public IP</li>
<li>Server must be reachable from the internet</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> </ul>
</div> </div>
</div> </div>

View File

@@ -25,6 +25,7 @@
<div class="form-hint-sm"> <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> Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
</div> </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> </div>
<!-- DNS Configuration --> <!-- DNS Configuration -->
@@ -407,6 +408,25 @@
const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, ''); const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, '');
subdomainInput.value = defaultSubdomain; 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) // Pre-select DNS/SSL from site config (set during setup wizard)
const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private'); const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private');
const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal'); const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal');
@@ -622,7 +642,10 @@
const sslType = document.querySelector('input[name="ssl-type"]:checked').value; const sslType = document.querySelector('input[name="ssl-type"]:checked').value;
let url = ''; 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'; const protocol = sslType === 'none' ? 'http' : 'https';
url = `${protocol}://${buildDomain(subdomain)}`; url = `${protocol}://${buildDomain(subdomain)}`;
} else if (dnsType === 'public') { } 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 el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
function buildGrid() { function buildGrid() {

View File

@@ -32,7 +32,7 @@
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`; if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
const urlPreview = document.getElementById('url-preview'); const urlPreview = document.getElementById('url-preview');
if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`; if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain);
const config = { const config = {
subdomain, port, ip, sslType, caName, existingCa, subdomain, port, ip, sslType, caName, existingCa,
@@ -534,7 +534,7 @@
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`, `Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
`Dashboard: ${results.dashboard ? '\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(); closeAddServiceModal();

View File

@@ -11,7 +11,7 @@
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`; document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
document.getElementById('edit-service-name-display').textContent = 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-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
document.getElementById('edit-subdomain').value = service.id; document.getElementById('edit-subdomain').value = service.id;
document.getElementById('edit-port').value = service.port || ''; document.getElementById('edit-port').value = service.port || '';

View File

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

View File

@@ -104,6 +104,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
} else if (currentConfigType === 'public') { } else if (currentConfigType === 'public') {
const domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
const email = document.getElementById('setup-public-email')?.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 += ` html += `
<div> <div>
@@ -112,7 +117,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
<div><strong>Domain:</strong> ${domain}</div> <div><strong>Domain:</strong> ${domain}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${email}</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>
</div> </div>
`; `;
@@ -186,8 +192,9 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
} else if (currentConfigType === 'public') { } else if (currentConfigType === 'public') {
config.domain = document.getElementById('setup-public-domain')?.value?.trim() || ''; config.domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
config.email = document.getElementById('setup-public-email')?.value?.trim() || ''; config.email = document.getElementById('setup-public-email')?.value?.trim() || '';
config.routingMode = document.querySelector('input[name="routing-mode"]:checked')?.value || 'subdirectory';
config.defaults = { config.defaults = {
dnsType: 'public', dnsType: config.routingMode === 'subdirectory' ? 'none' : 'public',
sslType: 'letsencrypt', sslType: 'letsencrypt',
targetIP: 'localhost' 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 // Public navigation
const publicBack = document.getElementById('setup-public-back'); const publicBack = document.getElementById('setup-public-back');
if (publicBack) { if (publicBack) {