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(' ')}`;
}