feat: NPM remote mode for cross-machine deployments

- Add npm_remote setting: when enabled, proxy forwards to server_ip with
  published host ports instead of Docker container names
- Deployer looks up assigned host port via InspectContainerPort in remote mode
- Auto-remove stale containers with same name before creating new ones
- Add Remote NPM toggle with warning on NPM settings page
- DB migration + schema for npm_remote column
This commit is contained in:
2026-04-05 02:18:06 +03:00
parent f71f2275a2
commit 195ef3e7e5
10 changed files with 77 additions and 12 deletions
+5
View File
@@ -36,6 +36,7 @@ type settingsRequest struct {
DNSProvider *string `json:"dns_provider,omitempty"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
NpmRemote *bool `json:"npm_remote,omitempty"`
ProxyProvider *string `json:"proxy_provider,omitempty"`
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
@@ -64,6 +65,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"npm_remote": settings.NpmRemote,
"polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays,
@@ -187,6 +189,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
}
updated.ProxyProvider = prov
}
if req.NpmRemote != nil {
updated.NpmRemote = *req.NpmRemote
}
// Traefik provider settings.
if req.TraefikEntrypoint != nil {
+1 -1
View File
@@ -162,7 +162,7 @@ func (d *Deployer) blueGreenDeploy(
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain)
if err != nil {
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
}
+28 -3
View File
@@ -306,6 +306,10 @@ func (d *Deployer) executeDeploy(
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
// Remove any stale container with the same name (e.g., from a previous failed deploy).
_ = d.docker.RemoveContainer(ctx, containerName, true)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
@@ -385,7 +389,7 @@ func (d *Deployer) executeDeploy(
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain)
if err != nil {
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
}
@@ -431,19 +435,40 @@ func (d *Deployer) executeDeploy(
// configureProxy creates or updates a proxy route for the deployed container.
// Uses the configured proxy.Provider (NPM, Traefik, or None).
// In NPM remote mode, uses server_ip + published host port instead of container name.
// Returns the proxy route ID string.
func (d *Deployer) configureProxy(
ctx context.Context,
deployID string,
settings store.Settings,
containerID string,
containerName string,
containerPort int,
subdomain string,
) (string, error) {
fqdn := subdomain + "." + settings.Domain
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, containerName, containerPort), "info")
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, containerName, containerPort, proxy.RouteOptions{
forwardHost := containerName
forwardPort := containerPort
// In NPM remote mode, use server_ip and the published host port.
if settings.NpmRemote && settings.ProxyProvider == "npm" {
if settings.ServerIP == "" {
return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings")
}
forwardHost = settings.ServerIP
hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort))
if err != nil {
return "", fmt.Errorf("look up host port for remote NPM: %w", err)
}
forwardPort = int(hostPort)
d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info")
}
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info")
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
})
if err != nil {
+1
View File
@@ -63,6 +63,7 @@ type Settings struct {
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"`
ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"`
+10 -5
View File
@@ -7,14 +7,14 @@ import (
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
var wildcardDNS, backupEnabled int
var wildcardDNS, npmRemote, backupEnabled int
err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, wildcard_dns, dns_provider,
cloudflare_api_token, cloudflare_zone_id,
proxy_provider,
npm_remote, proxy_provider,
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
backup_enabled, backup_interval_hours, backup_retention_count,
updated_at
@@ -24,7 +24,7 @@ func (s *Store) GetSettings() (Settings, error) {
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
&st.CloudflareAPIToken, &st.CloudflareZoneID,
&st.ProxyProvider,
&npmRemote, &st.ProxyProvider,
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
&st.UpdatedAt)
@@ -32,6 +32,7 @@ func (s *Store) GetSettings() (Settings, error) {
return Settings{}, fmt.Errorf("query settings: %w", err)
}
st.WildcardDNS = wildcardDNS != 0
st.NpmRemote = npmRemote != 0
st.BackupEnabled = backupEnabled != 0
return st, nil
}
@@ -43,6 +44,10 @@ func (s *Store) UpdateSettings(st Settings) error {
if st.WildcardDNS {
wildcardDNS = 1
}
npmRemote := 0
if st.NpmRemote {
npmRemote = 1
}
backupEnabled := 0
if st.BackupEnabled {
backupEnabled = 1
@@ -54,7 +59,7 @@ func (s *Store) UpdateSettings(st Settings) error {
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
cloudflare_api_token=?, cloudflare_zone_id=?,
proxy_provider=?,
npm_remote=?, proxy_provider=?,
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
updated_at=?
@@ -64,7 +69,7 @@ func (s *Store) UpdateSettings(st Settings) error {
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
st.CloudflareAPIToken, st.CloudflareZoneID,
st.ProxyProvider,
npmRemote, st.ProxyProvider,
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
st.UpdatedAt,
+3
View File
@@ -111,6 +111,8 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
// Set default network for existing databases with empty network.
`UPDATE settings SET network = 'docker-watcher' WHERE network = ''`,
// NPM remote mode: forward to server_ip instead of container name.
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
}
for _, m := range migrations {
@@ -210,6 +212,7 @@ CREATE TABLE IF NOT EXISTS settings (
polling_interval TEXT NOT NULL DEFAULT '5m',
base_volume_path TEXT NOT NULL DEFAULT '',
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
npm_remote INTEGER NOT NULL DEFAULT 0,
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
traefik_network TEXT NOT NULL DEFAULT '',