From dea9f606f76e092ed4a99223b0c20a0e7c7eaf77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5?= Date: Thu, 19 Feb 2026 14:18:48 +0500 Subject: [PATCH 1/5] feat: add N:M domain-to-document-root mapping support Add DOMAINS and DOMAIN_MAP env variables for multi-domain projects. DOMAINS allows multiple domains with a shared document root, DOMAIN_MAP allows per-domain document root configuration. Includes startup validation, dynamic Traefik rule and nginx/apache config generation, multi-domain SSL certificates, and Bitrix support. Co-Authored-By: Claude Opus 4.6 --- command/up.go | 40 +++++-- project/apache.go | 50 ++++++++ project/deploy_db.go | 14 ++- project/env.go | 166 +++++++++++++++++++++++++++ project/nginx.go | 86 ++++++++++++++ project/ssl.go | 9 +- project/types.go | 8 ++ templates/.env.example | 5 + templates/.env.example-bitrix | 5 + templates/docker-compose-apache.yaml | 4 +- templates/docker-compose-fpm.yaml | 4 +- 11 files changed, 369 insertions(+), 22 deletions(-) create mode 100644 project/apache.go create mode 100644 project/nginx.go diff --git a/command/up.go b/command/up.go index 9cee823..1bf6e7f 100644 --- a/command/up.go +++ b/command/up.go @@ -62,6 +62,22 @@ func upRun() { project.CreateCert() } + // Generate web server config for multi-domain + if len(project.DomainMappings) > 1 { + phpVersion := project.Env.GetString("PHP_VERSION") + if strings.Contains(phpVersion, "fpm") { + if err := project.WriteNginxConfig(); err != nil { + pterm.FgRed.Printfln("Failed to generate nginx config: %s", err) + return + } + } else if strings.Contains(phpVersion, "apache") { + if err := project.WriteApacheConfig(); err != nil { + pterm.FgRed.Printfln("Failed to generate apache config: %s", err) + return + } + } + } + bin, option := utils.GetCompose() Args := []string{bin} preArgs := []string{"-p", project.Env.GetString("NETWORK_NAME"), "--project-directory", project.Env.GetString("PWD"), "up", "-d"} @@ -117,20 +133,26 @@ func startLocalServices() error { // showProjectInfo Display project links func showProjectInfo() { - l := project.Env.GetString("LOCAL_DOMAIN") - n := project.Env.GetString("NIP_DOMAIN") - schema := "http" - if viper.GetBool("ca") { schema = "https" } pterm.FgCyan.Println() - panels := pterm.Panels{ - {{Data: pterm.FgYellow.Sprintf("nip.io\nlocal")}, - {Data: pterm.FgYellow.Sprintf(schema+"://%s/\n"+schema+"://%s/", n, l)}}, - } - _ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render() + if len(project.DomainMappings) > 1 { + tableData := pterm.TableData{{"Domain", "Document Root"}} + for _, m := range project.DomainMappings { + tableData = append(tableData, []string{schema + "://" + m.LocalDomain + "/", m.DocumentRoot}) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + } else { + l := project.Env.GetString("LOCAL_DOMAIN") + n := project.Env.GetString("NIP_DOMAIN") + panels := pterm.Panels{ + {{Data: pterm.FgYellow.Sprintf("nip.io\nlocal")}, + {Data: pterm.FgYellow.Sprintf(schema+"://%s/\n"+schema+"://%s/", n, l)}}, + } + _ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render() + } } diff --git a/project/apache.go b/project/apache.go new file mode 100644 index 0000000..603b5f7 --- /dev/null +++ b/project/apache.go @@ -0,0 +1,50 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// GenerateApacheConfig builds Apache vhost configuration from DomainMappings +func GenerateApacheConfig() string { + var b strings.Builder + + for i, dm := range DomainMappings { + if i > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "\n") + fmt.Fprintf(&b, " ServerName %s\n", dm.LocalDomain) + fmt.Fprintf(&b, " ServerAlias %s\n", dm.NipDomain) + fmt.Fprintf(&b, " DocumentRoot %s\n", dm.DocumentRoot) + fmt.Fprintf(&b, "\n") + fmt.Fprintf(&b, " \n", dm.DocumentRoot) + fmt.Fprintf(&b, " AllowOverride All\n") + fmt.Fprintf(&b, " Require all granted\n") + fmt.Fprintf(&b, " \n") + fmt.Fprintf(&b, "\n") + } + + return b.String() +} + +// WriteApacheConfig writes the generated Apache vhost config to .docker/apache/vhosts.conf +func WriteApacheConfig() error { + pwd := Env.GetString("PWD") + dir := filepath.Join(pwd, ".docker", "apache") + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + config := GenerateApacheConfig() + configPath := filepath.Join(dir, "vhosts.conf") + + if err := os.WriteFile(configPath, []byte(config), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", configPath, err) + } + + return nil +} diff --git a/project/deploy_db.go b/project/deploy_db.go index 3960515..1ac1462 100644 --- a/project/deploy_db.go +++ b/project/deploy_db.go @@ -430,14 +430,18 @@ func (c SSHClient) ImportDB(ctx context.Context) error { } if c.Config.FwType == "bitrix" { - local := Env.GetString("LOCAL_DOMAIN") - nip := Env.GetString("NIP_DOMAIN") + firstDomain := DomainMappings[0] + + var domainInserts strings.Builder + for _, m := range DomainMappings { + domainInserts.WriteString(fmt.Sprintf("INSERT IGNORE INTO b_lang_domain VALUES ('s1', '%s');\n", m.LocalDomain)) + domainInserts.WriteString(fmt.Sprintf("INSERT IGNORE INTO b_lang_domain VALUES ('s1', '%s');\n", m.NipDomain)) + } strSQL := `"UPDATE b_option SET VALUE = 'Y' WHERE MODULE_ID = 'main' AND NAME = 'update_devsrv'; -UPDATE b_lang SET SERVER_NAME='` + site + `.localhost' WHERE LID='s1'; +UPDATE b_lang SET SERVER_NAME='` + firstDomain.LocalDomain + `' WHERE LID='s1'; UPDATE b_lang SET b_lang.DOC_ROOT='' WHERE 1=(SELECT DOC_ROOT FROM (SELECT COUNT(LID) FROM b_lang) as cnt); -INSERT IGNORE INTO b_lang_domain VALUES ('s1', '` + local + `'); -INSERT IGNORE INTO b_lang_domain VALUES ('s1', '` + nip + `');"` +` + domainInserts.String() + `"` commandUpdate := "echo " + strSQL + " | " + docker + " exec -i " + siteDB + " /usr/bin/mysql --user=" + mysqlUser + " --password=" + mysqlPassword + " --host=db " + mysqlDB + "" logrus.Infof("Run command: %s", commandUpdate) diff --git a/project/env.go b/project/env.go index c5e7646..f6b85f8 100644 --- a/project/env.go +++ b/project/env.go @@ -17,6 +17,9 @@ import ( // Env Project variables var Env *viper.Viper +// DomainMappings holds parsed domain-to-document-root mappings +var DomainMappings []DomainMapping + var phpImagesVersion = map[string]string{ "7.3-apache": "1.1.3", "7.3-fpm": "1.0.3", @@ -55,6 +58,13 @@ func LoadEnv() { } setDefaultEnv() + + if err := validateDomains(); err != nil { + pterm.FgRed.Println(err) + os.Exit(1) + } + parseDomainMappings() + setComposeFiles() } @@ -218,6 +228,162 @@ func IsEnvExampleFileExists() bool { return err == nil } +func validateDomains() error { + domains := Env.GetString("DOMAINS") + domainMap := Env.GetString("DOMAIN_MAP") + + // Rule 1: DOMAINS and DOMAIN_MAP are mutually exclusive + if len(domains) > 0 && len(domainMap) > 0 { + return fmt.Errorf("DOMAINS and DOMAIN_MAP are mutually exclusive. Use DOMAINS for multiple domains with one document root, or DOMAIN_MAP for different document roots per domain.") + } + + domainNameRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]*$`) + seen := make(map[string]bool) + + if len(domainMap) > 0 { + entries := strings.Split(domainMap, ",") + for _, entry := range entries { + entry = strings.TrimSpace(entry) + + // Rule 3: must contain ':' + if !strings.Contains(entry, ":") { + return fmt.Errorf("Invalid DOMAIN_MAP format for entry \"%s\". Expected format: name:/path/to/docroot", entry) + } + + parts := strings.SplitN(entry, ":", 2) + name := strings.TrimSpace(parts[0]) + path := strings.TrimSpace(parts[1]) + + // Rule 4: empty name + if len(name) == 0 { + return fmt.Errorf("Empty domain name in DOMAIN_MAP entry: \"%s\"", entry) + } + + // Rule 5: empty path + if len(path) == 0 { + return fmt.Errorf("Empty document root in DOMAIN_MAP entry for domain \"%s\"", name) + } + + // Rule 6: path must be absolute + if !filepath.IsAbs(path) { + return fmt.Errorf("Document root must be an absolute path for domain \"%s\": got \"%s\"", name, path) + } + + nameLower := strings.ToLower(name) + + // Rule 8: invalid domain name chars + if !domainNameRegex.MatchString(nameLower) { + return fmt.Errorf("Invalid domain name \"%s\": only alphanumeric characters, hyphens, and dots are allowed.", nameLower) + } + + // Rule 9: duplicate domain + if seen[nameLower] { + return fmt.Errorf("Duplicate domain name \"%s\" in DOMAIN_MAP.", nameLower) + } + seen[nameLower] = true + } + } + + if len(domains) > 0 { + // Rule 2: DOMAINS requires DOCUMENT_ROOT + if len(Env.GetString("DOCUMENT_ROOT")) == 0 { + return fmt.Errorf("DOCUMENT_ROOT is required when using DOMAINS.") + } + + entries := strings.Split(domains, ",") + for _, entry := range entries { + name := strings.TrimSpace(entry) + + // Rule 7: empty entry + if len(name) == 0 { + return fmt.Errorf("Empty domain name in DOMAINS. Check for trailing commas.") + } + + nameLower := strings.ToLower(name) + + // Rule 8: invalid domain name chars + if !domainNameRegex.MatchString(nameLower) { + return fmt.Errorf("Invalid domain name \"%s\": only alphanumeric characters, hyphens, and dots are allowed.", nameLower) + } + + // Rule 9: duplicate domain + if seen[nameLower] { + return fmt.Errorf("Duplicate domain name \"%s\" in DOMAINS.", nameLower) + } + seen[nameLower] = true + } + } + + return nil +} + +func parseDomainMappings() { + DomainMappings = nil + localIP := Env.GetString("LOCAL_IP") + domainMap := Env.GetString("DOMAIN_MAP") + domains := Env.GetString("DOMAINS") + + if len(domainMap) > 0 { + entries := strings.Split(domainMap, ",") + for _, entry := range entries { + entry = strings.TrimSpace(entry) + parts := strings.SplitN(entry, ":", 2) + name := strings.ToLower(strings.TrimSpace(parts[0])) + docRoot := strings.TrimSpace(parts[1]) + + DomainMappings = append(DomainMappings, DomainMapping{ + Name: name, + DocumentRoot: docRoot, + LocalDomain: fmt.Sprintf("%s.localhost", name), + NipDomain: fmt.Sprintf("%s.%s.nip.io", name, localIP), + }) + } + logrus.Infof("Parsed %d domain mappings from DOMAIN_MAP", len(DomainMappings)) + generateTraefikRule() + return + } + + if len(domains) > 0 { + docRoot := Env.GetString("DOCUMENT_ROOT") + entries := strings.Split(domains, ",") + for _, entry := range entries { + name := strings.ToLower(strings.TrimSpace(entry)) + + DomainMappings = append(DomainMappings, DomainMapping{ + Name: name, + DocumentRoot: docRoot, + LocalDomain: fmt.Sprintf("%s.localhost", name), + NipDomain: fmt.Sprintf("%s.%s.nip.io", name, localIP), + }) + } + logrus.Infof("Parsed %d domain mappings from DOMAINS", len(DomainMappings)) + generateTraefikRule() + return + } + + // Backward compatibility: single mapping from HOST_NAME + DOCUMENT_ROOT + hostName := strings.ToLower(Env.GetString("HOST_NAME")) + DomainMappings = append(DomainMappings, DomainMapping{ + Name: hostName, + DocumentRoot: Env.GetString("DOCUMENT_ROOT"), + LocalDomain: fmt.Sprintf("%s.localhost", hostName), + NipDomain: fmt.Sprintf("%s.%s.nip.io", hostName, localIP), + }) + logrus.Infof("Using single domain mapping from HOST_NAME: %s", hostName) + generateTraefikRule() +} + +func generateTraefikRule() { + var rules []string + for _, m := range DomainMappings { + rules = append(rules, fmt.Sprintf("Host(`%s`)", m.LocalDomain)) + rules = append(rules, fmt.Sprintf("HostRegexp(`^.+\\.%s$`)", m.LocalDomain)) + rules = append(rules, fmt.Sprintf("HostRegexp(`^%s\\..+\\.nip\\.io$`)", m.Name)) + rules = append(rules, fmt.Sprintf("HostRegexp(`^.+\\.%s\\..+\\.nip\\.io$`)", m.Name)) + } + Env.Set("TRAEFIK_RULE", strings.Join(rules, " || ")) +} + func getLocalIP() string { addrs, err := net.InterfaceAddrs() if err != nil { diff --git a/project/nginx.go b/project/nginx.go new file mode 100644 index 0000000..4ed700b --- /dev/null +++ b/project/nginx.go @@ -0,0 +1,86 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/local-deploy/dl/utils" +) + +// GenerateNginxConfig builds a complete nginx config from DomainMappings. +// One server block is generated per DomainMapping entry. +func GenerateNginxConfig() string { + hostName := Env.GetString("HOST_NAME") + var b strings.Builder + + for i, mapping := range DomainMappings { + if i > 0 { + b.WriteString("\n") + } + + tryFiles := "try_files $uri $uri/ /index.php?$args;" + if utils.BitrixCheck(mapping.DocumentRoot) { + tryFiles = "try_files $uri $uri/ /index.php?$args /bitrix/urlrewrite.php?$args /bitrix/routing_index.php?$args;" + } + + b.WriteString("server {\n") + b.WriteString(" listen 80;\n") + b.WriteString(" listen 443;\n") + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" server_name %s %s;\n", mapping.LocalDomain, mapping.NipDomain)) + b.WriteString(" add_header Strict-Transport-Security \"max-age=31536000\" always;\n") + b.WriteString(" client_max_body_size 200M;\n") + b.WriteString("\n") + b.WriteString(" charset utf-8;\n") + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" set $root_path %s;\n", mapping.DocumentRoot)) + b.WriteString(" root $root_path;\n") + b.WriteString("\n") + b.WriteString(" location / {\n") + b.WriteString(" root $root_path;\n") + b.WriteString(" index index.php index.html;\n") + b.WriteString(fmt.Sprintf(" %s\n", tryFiles)) + b.WriteString(" }\n") + b.WriteString("\n") + b.WriteString(" location ~ \\.php$ {\n") + b.WriteString(fmt.Sprintf(" fastcgi_pass %s_php:9000;\n", hostName)) + b.WriteString(" fastcgi_index index.php;\n") + b.WriteString(" fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n") + b.WriteString(" include /etc/nginx/fastcgi_params;\n") + b.WriteString(" }\n") + b.WriteString("\n") + b.WriteString(" location ~* ^.+\\.(jpg|jpeg|gif|png|svg|js|css|mp3|ogg|mpeg|avi|zip|gz|bz2|rar|swf|ico|7z|doc|docx|map|ogg|otf|pdf|tff|tif|txt|wav|webp|woff|woff2|xls|xlsx|xml)$ {\n") + b.WriteString(" expires 365d;\n") + b.WriteString(" try_files $uri $uri/ 404 = @fallback;\n") + b.WriteString(" }\n") + b.WriteString("\n") + b.WriteString(" location @fallback {\n") + b.WriteString(fmt.Sprintf(" return 302 https://%s/$uri;\n", mapping.LocalDomain)) + b.WriteString(" }\n") + b.WriteString("}\n") + } + + return b.String() +} + +// WriteNginxConfig writes the generated nginx config to .docker/nginx/default.conf +// in the project directory. Creates the directory structure if it does not exist. +func WriteNginxConfig() error { + pwd := Env.GetString("PWD") + dir := filepath.Join(pwd, ".docker", "nginx") + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create nginx config directory: %w", err) + } + + confPath := filepath.Join(dir, "default.conf") + config := GenerateNginxConfig() + + if err := os.WriteFile(confPath, []byte(config), 0644); err != nil { + return fmt.Errorf("failed to write nginx config: %w", err) + } + + return nil +} diff --git a/project/ssl.go b/project/ssl.go index 2c6687d..0f8de53 100644 --- a/project/ssl.go +++ b/project/ssl.go @@ -49,10 +49,11 @@ func CreateCert() { certDir := filepath.Join(utils.CertDir(), Env.GetString("NETWORK_NAME")) _ = utils.CreateDirectory(certDir) - err = c.MakeCert([]string{ - Env.GetString("LOCAL_DOMAIN"), - Env.GetString("NIP_DOMAIN"), - }, Env.GetString("NETWORK_NAME")) + var domains []string + for _, m := range DomainMappings { + domains = append(domains, m.LocalDomain, m.NipDomain) + } + err = c.MakeCert(domains, Env.GetString("NETWORK_NAME")) if err != nil { pterm.FgRed.Printfln("Error: %s", err) } diff --git a/project/types.go b/project/types.go index 3b9f80a..45f76be 100644 --- a/project/types.go +++ b/project/types.go @@ -15,3 +15,11 @@ type DBSettings struct { Host, DataBase, Login, Password, Port string ExcludedTables []string } + +// DomainMapping represents a domain-to-document-root mapping +type DomainMapping struct { + Name string // domain name (e.g., "site1") + DocumentRoot string // absolute path (e.g., "/var/www/html/site1") + LocalDomain string // auto-generated: "site1.localhost" + NipDomain string // auto-generated: "site1.192.168.1.100.nip.io" +} diff --git a/templates/.env.example b/templates/.env.example index b0bd3cf..2e2f118 100644 --- a/templates/.env.example +++ b/templates/.env.example @@ -6,6 +6,11 @@ SERVER=127.0.0.1 ## Local container config ## DOCUMENT_ROOT=/var/www/html +## Multi-domain config (optional, mutually exclusive) ## +## Multiple domains, one document root (comma-separated): +# DOMAINS=site1,site2,api +## Multiple domains with different document roots: +# DOMAIN_MAP=site1:/var/www/html/site1,site2:/var/www/html/site2 ## Avalible fpm versions: 7.3-fpm 7.4-fpm 8.0-fpm 8.1-fpm 8.2-fpm 8.3-fpm 8.4-fpm ## ## Avalible apache versions: 7.3-apache 7.4-apache 8.0-apache 8.1-apache 8.2-apache 8.3-apache 8.4-apache ## PHP_VERSION=8.4-fpm diff --git a/templates/.env.example-bitrix b/templates/.env.example-bitrix index 3b94694..7b9c9da 100644 --- a/templates/.env.example-bitrix +++ b/templates/.env.example-bitrix @@ -6,6 +6,11 @@ SERVER=127.0.0.1 ## Local container config ## DOCUMENT_ROOT=/var/www/html +## Multi-domain config (optional, mutually exclusive) ## +## Multiple domains, one document root (comma-separated): +# DOMAINS=site1,site2,api +## Multiple domains with different document roots: +# DOMAIN_MAP=site1:/var/www/html/site1,site2:/var/www/html/site2 ## Avalible fpm versions: 7.3-fpm 7.4-fpm 8.0-fpm 8.1-fpm 8.2-fpm 8.3-fpm 8.4-fpm ## ## Avalible apache versions: 7.3-apache 7.4-apache 8.0-apache 8.1-apache 8.2-apache 8.3-apache 8.4-apache ## PHP_VERSION=8.4-fpm diff --git a/templates/docker-compose-apache.yaml b/templates/docker-compose-apache.yaml index bae8f53..578b461 100644 --- a/templates/docker-compose-apache.yaml +++ b/templates/docker-compose-apache.yaml @@ -26,10 +26,10 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.${NETWORK_NAME}.entrypoints=web" - - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`^.+\\.${HOST_NAME}\\.localhost$`) || HostRegexp(`^${HOST_NAME}\\..+\\.nip\\.io$`) || HostRegexp(`^.+\\.${HOST_NAME}\\..+\\.nip\\.io$`)" + - "traefik.http.routers.${NETWORK_NAME}.rule=${TRAEFIK_RULE}" - "traefik.http.routers.${NETWORK_NAME}.middlewares=site-compress" - "traefik.http.routers.${NETWORK_NAME}_ssl.entrypoints=websecure" - - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`^.+\\.${HOST_NAME}\\.localhost$`) || HostRegexp(`^${HOST_NAME}\\..+\\.nip\\.io$`) || HostRegexp(`^.+\\.${HOST_NAME}\\..+\\.nip\\.io$`)" + - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=${TRAEFIK_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.middlewares=site-compress" - "traefik.http.routers.${NETWORK_NAME}_ssl.tls=true" - "traefik.docker.network=dl_default" diff --git a/templates/docker-compose-fpm.yaml b/templates/docker-compose-fpm.yaml index cbf13a0..9f28aec 100644 --- a/templates/docker-compose-fpm.yaml +++ b/templates/docker-compose-fpm.yaml @@ -36,9 +36,9 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.${NETWORK_NAME}.entrypoints=web" - - "traefik.http.routers.${NETWORK_NAME}.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`^.+\\.${HOST_NAME}\\.localhost$`) || HostRegexp(`^${HOST_NAME}\\..+\\.nip\\.io$`) || HostRegexp(`^.+\\.${HOST_NAME}\\..+\\.nip\\.io$`)" + - "traefik.http.routers.${NETWORK_NAME}.rule=${TRAEFIK_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.entrypoints=websecure" - - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=Host(`${HOST_NAME}.localhost`) || HostRegexp(`^.+\\.${HOST_NAME}\\.localhost$`) || HostRegexp(`^${HOST_NAME}\\..+\\.nip\\.io$`) || HostRegexp(`^.+\\.${HOST_NAME}\\..+\\.nip\\.io$`)" + - "traefik.http.routers.${NETWORK_NAME}_ssl.rule=${TRAEFIK_RULE}" - "traefik.http.routers.${NETWORK_NAME}_ssl.tls=true" - "traefik.docker.network=dl_default" environment: From ffa2e89768f4da4ff93cc8ccdb7f90ecd7f4f157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5?= Date: Thu, 19 Feb 2026 15:10:31 +0500 Subject: [PATCH 2/5] fix: template migration, nginx config mounting, and config dir isolation - Fix NGINX_CONF not pointing to generated multi-domain config - Add hash-based caching for nginx/apache config regeneration - Add templateVersion migration to auto-update stale templates - Derive config directory from binary name for safe side-by-side testing Co-Authored-By: Claude Opus 4.6 --- command/up.go | 9 +++++-- main.go | 19 ++++++++++++++ project/apache.go | 54 +++++++++++++++++++++++--------------- project/nginx.go | 67 ++++++++++++++++++++++++++++++++++++++++++----- utils/path.go | 11 ++++++-- 5 files changed, 129 insertions(+), 31 deletions(-) diff --git a/command/up.go b/command/up.go index 1bf6e7f..32693ab 100644 --- a/command/up.go +++ b/command/up.go @@ -66,15 +66,20 @@ func upRun() { if len(project.DomainMappings) > 1 { phpVersion := project.Env.GetString("PHP_VERSION") if strings.Contains(phpVersion, "fpm") { - if err := project.WriteNginxConfig(); err != nil { + confPath, err := project.WriteNginxConfig() + if err != nil { pterm.FgRed.Printfln("Failed to generate nginx config: %s", err) return } + project.Env.Set("NGINX_CONF", confPath) + pterm.FgGreen.Printfln("Generated nginx config: %s", confPath) } else if strings.Contains(phpVersion, "apache") { - if err := project.WriteApacheConfig(); err != nil { + confPath, err := project.WriteApacheConfig() + if err != nil { pterm.FgRed.Printfln("Failed to generate apache config: %s", err) return } + _ = confPath // apache config mounted separately } } diff --git a/main.go b/main.go index efd361f..e8749c8 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,10 @@ import ( var version = "dev" +// templateVersion must be incremented whenever files in the templates/ directory change. +// This triggers a full re-extraction of embedded templates to ~/.config/dl/templates/. +const templateVersion = "1" + //go:embed templates/* var templates embed.FS @@ -79,6 +83,21 @@ func initConfig() { } } + // Re-extract templates when templateVersion changes + if viper.GetString("template_version") != templateVersion { + err = utils.CreateTemplates(true) + if err != nil { + pterm.FgRed.Printfln("Error updating templates: %s \n", err) + os.Exit(1) + } + viper.Set("template_version", templateVersion) + err = viper.WriteConfig() + if err != nil { + pterm.FgRed.Printfln("Error config file: %s \n", err) + os.Exit(1) + } + } + viper.AutomaticEnv() } diff --git a/project/apache.go b/project/apache.go index 603b5f7..02da518 100644 --- a/project/apache.go +++ b/project/apache.go @@ -4,47 +4,59 @@ import ( "fmt" "os" "path/filepath" - "strings" + + "github.com/sirupsen/logrus" ) // GenerateApacheConfig builds Apache vhost configuration from DomainMappings func GenerateApacheConfig() string { - var b strings.Builder + var b []byte for i, dm := range DomainMappings { if i > 0 { - b.WriteString("\n") + b = append(b, '\n') } - fmt.Fprintf(&b, "\n") - fmt.Fprintf(&b, " ServerName %s\n", dm.LocalDomain) - fmt.Fprintf(&b, " ServerAlias %s\n", dm.NipDomain) - fmt.Fprintf(&b, " DocumentRoot %s\n", dm.DocumentRoot) - fmt.Fprintf(&b, "\n") - fmt.Fprintf(&b, " \n", dm.DocumentRoot) - fmt.Fprintf(&b, " AllowOverride All\n") - fmt.Fprintf(&b, " Require all granted\n") - fmt.Fprintf(&b, " \n") - fmt.Fprintf(&b, "\n") + b = fmt.Appendf(b, "\n") + b = fmt.Appendf(b, " ServerName %s\n", dm.LocalDomain) + b = fmt.Appendf(b, " ServerAlias %s\n", dm.NipDomain) + b = fmt.Appendf(b, " DocumentRoot %s\n", dm.DocumentRoot) + b = fmt.Appendf(b, "\n") + b = fmt.Appendf(b, " \n", dm.DocumentRoot) + b = fmt.Appendf(b, " AllowOverride All\n") + b = fmt.Appendf(b, " Require all granted\n") + b = fmt.Appendf(b, " \n") + b = fmt.Appendf(b, "\n") } - return b.String() + return string(b) } -// WriteApacheConfig writes the generated Apache vhost config to .docker/apache/vhosts.conf -func WriteApacheConfig() error { +// WriteApacheConfig writes the generated Apache vhost config to .docker/apache/vhosts.conf. +// Skips regeneration if .env and project folder haven't changed. +// Returns the absolute path to the config file. +func WriteApacheConfig() (string, error) { pwd := Env.GetString("PWD") dir := filepath.Join(pwd, ".docker", "apache") + confPath := filepath.Join(dir, "vhosts.conf") + hashPath := filepath.Join(dir, ".confhash") + + if !configNeedsUpdate(hashPath) { + logrus.Info("Apache config is up to date, skipping regeneration") + return confPath, nil + } if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) } config := GenerateApacheConfig() - configPath := filepath.Join(dir, "vhosts.conf") - if err := os.WriteFile(configPath, []byte(config), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", configPath, err) + if err := os.WriteFile(confPath, []byte(config), 0644); err != nil { + return "", fmt.Errorf("failed to write %s: %w", confPath, err) } - return nil + saveConfigHash(hashPath) + logrus.Info("Apache config regenerated") + + return confPath, nil } diff --git a/project/nginx.go b/project/nginx.go index 4ed700b..d277848 100644 --- a/project/nginx.go +++ b/project/nginx.go @@ -1,14 +1,59 @@ package project import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" "strings" "github.com/local-deploy/dl/utils" + "github.com/sirupsen/logrus" ) +// configNeedsUpdate checks whether the generated web server config is stale +// by comparing a SHA-256 hash of .env contents + project folder name against a stored hash. +func configNeedsUpdate(hashFilePath string) bool { + pwd := Env.GetString("PWD") + envPath := filepath.Join(pwd, ".env") + + envContent, err := os.ReadFile(envPath) + if err != nil { + return true + } + + h := sha256.New() + h.Write(envContent) + h.Write([]byte(filepath.Base(pwd))) + currentHash := hex.EncodeToString(h.Sum(nil)) + + storedHash, err := os.ReadFile(hashFilePath) + if err != nil { + return true + } + + return strings.TrimSpace(string(storedHash)) != currentHash +} + +// saveConfigHash persists the current hash so subsequent runs can skip regeneration. +func saveConfigHash(hashFilePath string) { + pwd := Env.GetString("PWD") + envPath := filepath.Join(pwd, ".env") + + envContent, err := os.ReadFile(envPath) + if err != nil { + return + } + + h := sha256.New() + h.Write(envContent) + h.Write([]byte(filepath.Base(pwd))) + currentHash := hex.EncodeToString(h.Sum(nil)) + + _ = os.WriteFile(hashFilePath, []byte(currentHash), 0644) +} + // GenerateNginxConfig builds a complete nginx config from DomainMappings. // One server block is generated per DomainMapping entry. func GenerateNginxConfig() string { @@ -66,21 +111,31 @@ func GenerateNginxConfig() string { } // WriteNginxConfig writes the generated nginx config to .docker/nginx/default.conf -// in the project directory. Creates the directory structure if it does not exist. -func WriteNginxConfig() error { +// in the project directory. Skips regeneration if .env and project folder haven't changed. +// Returns the absolute path to the config file. +func WriteNginxConfig() (string, error) { pwd := Env.GetString("PWD") dir := filepath.Join(pwd, ".docker", "nginx") + confPath := filepath.Join(dir, "default.conf") + hashPath := filepath.Join(dir, ".confhash") + + if !configNeedsUpdate(hashPath) { + logrus.Info("Nginx config is up to date, skipping regeneration") + return confPath, nil + } if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create nginx config directory: %w", err) + return "", fmt.Errorf("failed to create nginx config directory: %w", err) } - confPath := filepath.Join(dir, "default.conf") config := GenerateNginxConfig() if err := os.WriteFile(confPath, []byte(config), 0644); err != nil { - return fmt.Errorf("failed to write nginx config: %w", err) + return "", fmt.Errorf("failed to write nginx config: %w", err) } - return nil + saveConfigHash(hashPath) + logrus.Info("Nginx config regenerated") + + return confPath, nil } diff --git a/utils/path.go b/utils/path.go index 4f17058..f0e8693 100644 --- a/utils/path.go +++ b/utils/path.go @@ -18,7 +18,9 @@ func HomeDir() (string, error) { return os.UserHomeDir() } -// ConfigDir config directory (~/.config/dl) +// ConfigDir config directory (~/.config/) +// The directory name is derived from the executable name, +// allowing multiple installations (e.g. dl and dl-test) to coexist. func ConfigDir() string { conf, err := os.UserConfigDir() if err != nil { @@ -26,7 +28,12 @@ func ConfigDir() string { os.Exit(1) } - return filepath.Join(conf, "dl") + name := "dl" + if exe, err := os.Executable(); err == nil { + name = filepath.Base(exe) + } + + return filepath.Join(conf, name) } // TemplateDir template directory (~/.config/dl/templates) From 2b4e34f36e5f61a20290669fa934a6c4bcac000f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5?= Date: Thu, 19 Feb 2026 15:29:59 +0500 Subject: [PATCH 3/5] fix: move generated configs to /tmp/dl/ to avoid permission issues The .docker/ directory is often owned by root (created by MySQL container), preventing the user from creating nginx/apache config subdirectories. Co-Authored-By: Claude Opus 4.6 --- project/apache.go | 6 +++--- project/nginx.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/project/apache.go b/project/apache.go index 02da518..cae025a 100644 --- a/project/apache.go +++ b/project/apache.go @@ -31,12 +31,12 @@ func GenerateApacheConfig() string { return string(b) } -// WriteApacheConfig writes the generated Apache vhost config to .docker/apache/vhosts.conf. +// WriteApacheConfig writes the generated Apache vhost config to /tmp/dl//apache/. // Skips regeneration if .env and project folder haven't changed. // Returns the absolute path to the config file. func WriteApacheConfig() (string, error) { - pwd := Env.GetString("PWD") - dir := filepath.Join(pwd, ".docker", "apache") + networkName := Env.GetString("NETWORK_NAME") + dir := filepath.Join(os.TempDir(), "dl", networkName, "apache") confPath := filepath.Join(dir, "vhosts.conf") hashPath := filepath.Join(dir, ".confhash") diff --git a/project/nginx.go b/project/nginx.go index d277848..f371e93 100644 --- a/project/nginx.go +++ b/project/nginx.go @@ -110,12 +110,12 @@ func GenerateNginxConfig() string { return b.String() } -// WriteNginxConfig writes the generated nginx config to .docker/nginx/default.conf -// in the project directory. Skips regeneration if .env and project folder haven't changed. +// WriteNginxConfig writes the generated nginx config to /tmp/dl//nginx/. +// Skips regeneration if .env and project folder haven't changed. // Returns the absolute path to the config file. func WriteNginxConfig() (string, error) { - pwd := Env.GetString("PWD") - dir := filepath.Join(pwd, ".docker", "nginx") + networkName := Env.GetString("NETWORK_NAME") + dir := filepath.Join(os.TempDir(), "dl", networkName, "nginx") confPath := filepath.Join(dir, "default.conf") hashPath := filepath.Join(dir, ".confhash") From bcb4b677d8e6b0ff6a59f50d2c7b907ef6d90d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5?= Date: Thu, 19 Feb 2026 16:16:37 +0500 Subject: [PATCH 4/5] fix: mount generated Apache vhosts config and fix DOMAIN_MAP routing - Mount generated vhosts.conf as 000-default.conf in Apache container - Set APACHE_CONF env var for docker-compose volume substitution - Always generate Apache config (single and multi-domain) - Add fastcgi_param DOCUMENT_ROOT for correct $_SERVER value in FPM - Bump templateVersion to trigger template re-extraction Co-Authored-By: Claude Opus 4.6 --- command/up.go | 33 +++++++++++++--------------- main.go | 2 +- project/env.go | 2 ++ project/nginx.go | 1 + templates/docker-compose-apache.yaml | 1 + 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/command/up.go b/command/up.go index 32693ab..82ded69 100644 --- a/command/up.go +++ b/command/up.go @@ -62,25 +62,22 @@ func upRun() { project.CreateCert() } - // Generate web server config for multi-domain - if len(project.DomainMappings) > 1 { - phpVersion := project.Env.GetString("PHP_VERSION") - if strings.Contains(phpVersion, "fpm") { - confPath, err := project.WriteNginxConfig() - if err != nil { - pterm.FgRed.Printfln("Failed to generate nginx config: %s", err) - return - } - project.Env.Set("NGINX_CONF", confPath) - pterm.FgGreen.Printfln("Generated nginx config: %s", confPath) - } else if strings.Contains(phpVersion, "apache") { - confPath, err := project.WriteApacheConfig() - if err != nil { - pterm.FgRed.Printfln("Failed to generate apache config: %s", err) - return - } - _ = confPath // apache config mounted separately + // Generate web server config + phpVersion := project.Env.GetString("PHP_VERSION") + if strings.Contains(phpVersion, "apache") { + confPath, err := project.WriteApacheConfig() + if err != nil { + pterm.FgRed.Printfln("Failed to generate apache config: %s", err) + return + } + project.Env.Set("APACHE_CONF", confPath) + } else if strings.Contains(phpVersion, "fpm") && len(project.DomainMappings) > 1 { + confPath, err := project.WriteNginxConfig() + if err != nil { + pterm.FgRed.Printfln("Failed to generate nginx config: %s", err) + return } + project.Env.Set("NGINX_CONF", confPath) } bin, option := utils.GetCompose() diff --git a/main.go b/main.go index e8749c8..d84c1b7 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ var version = "dev" // templateVersion must be incremented whenever files in the templates/ directory change. // This triggers a full re-extraction of embedded templates to ~/.config/dl/templates/. -const templateVersion = "1" +const templateVersion = "2" //go:embed templates/* var templates embed.FS diff --git a/project/env.go b/project/env.go index f6b85f8..7f26345 100644 --- a/project/env.go +++ b/project/env.go @@ -101,6 +101,8 @@ func setDefaultEnv() { Env.Set("NGINX_CONF", getNginxConf()) } + Env.SetDefault("APACHE_CONF", "/dev/null") + Env.SetDefault("REDIS", false) Env.SetDefault("REDIS_PASSWORD", "pass") Env.SetDefault("MEMCACHED", false) diff --git a/project/nginx.go b/project/nginx.go index f371e93..8af3c40 100644 --- a/project/nginx.go +++ b/project/nginx.go @@ -93,6 +93,7 @@ func GenerateNginxConfig() string { b.WriteString(fmt.Sprintf(" fastcgi_pass %s_php:9000;\n", hostName)) b.WriteString(" fastcgi_index index.php;\n") b.WriteString(" fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n") + b.WriteString(" fastcgi_param DOCUMENT_ROOT $realpath_root;\n") b.WriteString(" include /etc/nginx/fastcgi_params;\n") b.WriteString(" }\n") b.WriteString("\n") diff --git a/templates/docker-compose-apache.yaml b/templates/docker-compose-apache.yaml index 578b461..28b0a07 100644 --- a/templates/docker-compose-apache.yaml +++ b/templates/docker-compose-apache.yaml @@ -21,6 +21,7 @@ services: volumes: - "${PWD}/:/var/www/html/" - "${PHP_INI_SOURCE:-/dev/null}:/usr/local/etc/php/conf.custom.d/custom.ini:ro" + - "${APACHE_CONF}:/etc/apache2/sites-enabled/000-default.conf:ro" - "~/.ssh/${SSH_KEY:-id_rsa}:/var/www/.ssh/id_rsa:ro" - "~/.ssh/known_hosts:/var/www/.ssh/known_hosts" labels: From 1d8a4f993b6dd000976a36c18b955b52385e1ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A5?= Date: Thu, 19 Feb 2026 16:29:12 +0500 Subject: [PATCH 5/5] fix: update domain mapping checks for nginx config generation --- command/up.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/command/up.go b/command/up.go index 82ded69..04c3419 100644 --- a/command/up.go +++ b/command/up.go @@ -71,7 +71,7 @@ func upRun() { return } project.Env.Set("APACHE_CONF", confPath) - } else if strings.Contains(phpVersion, "fpm") && len(project.DomainMappings) > 1 { + } else if strings.Contains(phpVersion, "fpm") && (len(project.Env.GetString("DOMAIN_MAP")) > 0 || len(project.Env.GetString("DOMAINS")) > 0) { confPath, err := project.WriteNginxConfig() if err != nil { pterm.FgRed.Printfln("Failed to generate nginx config: %s", err) @@ -142,7 +142,7 @@ func showProjectInfo() { pterm.FgCyan.Println() - if len(project.DomainMappings) > 1 { + if len(project.Env.GetString("DOMAIN_MAP")) > 0 || len(project.Env.GetString("DOMAINS")) > 0 { tableData := pterm.TableData{{"Domain", "Document Root"}} for _, m := range project.DomainMappings { tableData = append(tableData, []string{schema + "://" + m.LocalDomain + "/", m.DocumentRoot})