diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go
index 180c2c8..f57707b 100644
--- a/app/backend/internal/api/deployments.go
+++ b/app/backend/internal/api/deployments.go
@@ -401,6 +401,12 @@ func runDeploymentAndSyncWithImage(
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
+ maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
+ maintURL := ""
+ if maintenanceMode {
+ maintURL = maintenanceURL(db)
+ }
+
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
@@ -417,10 +423,14 @@ func runDeploymentAndSyncWithImage(
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID),
+ Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU),
},
+ Maintenance: maintenanceMode,
+ MaintenanceURL: maintURL,
+ BasicAuthUsers: basicAuthUsers,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
diff --git a/app/backend/internal/api/domains.go b/app/backend/internal/api/domains.go
new file mode 100644
index 0000000..cb63360
--- /dev/null
+++ b/app/backend/internal/api/domains.go
@@ -0,0 +1,375 @@
+package api
+
+import (
+ "context"
+ "database/sql"
+ "net"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "containr/internal/database"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// ServiceDomain is one hostname routed to a service. services.domain stays
+// the derived default; this table is the source of truth for the full set.
+type ServiceDomain struct {
+ ID uuid.UUID `json:"id"`
+ ServiceID uuid.UUID `json:"service_id"`
+ Domain string `json:"domain"`
+ IsDefault bool `json:"is_default"`
+ CertType string `json:"cert_type"`
+ CertStatus string `json:"cert_status"`
+ LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
+type BasicAuthCred struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
+
+func validHostname(d string) bool {
+ return len(d) <= 253 && hostnameRE.MatchString(d)
+}
+
+func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
+ rows, err := db.Query(
+ `SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
+ FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ out := []ServiceDomain{}
+ for rows.Next() {
+ var d ServiceDomain
+ if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
+ domains := loadServiceDomains(db, serviceID)
+ out := make([]string, 0, len(domains))
+ for _, d := range domains {
+ out = append(out, d.Domain)
+ }
+ if len(out) == 0 && fallback != "" {
+ out = []string{fallback}
+ }
+ return out
+}
+
+// syncDefaultDomain writes the is_default row back to services.domain so
+// legacy reads (CLI tables, public_url) keep working.
+func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
+ var domain string
+ err := db.QueryRow(
+ `SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
+ if err == sql.ErrNoRows {
+ err = db.QueryRow(
+ `SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
+ }
+ if err == sql.ErrNoRows {
+ domain = ""
+ }
+ _, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
+}
+
+// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
+func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
+ _ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
+ Scan(&maintenance, &basicAuth)
+ return maintenance, basicAuth
+}
+
+// maintenanceURL is the redirect target for maintenance mode — the public
+// base URL of this API plus the maintenance page path.
+func maintenanceURL(db *database.DB) string {
+ base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
+ if base == "" {
+ return ""
+ }
+ return base + "/api/v1/maintenance"
+}
+
+// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
+// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
+func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
+ pairs := make([]string, 0, len(creds))
+ for _, cred := range creds {
+ if cred.Username == "" || cred.Password == "" {
+ continue
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ pairs = append(pairs, cred.Username+":"+string(hash))
+ }
+ return strings.Join(pairs, ","), nil
+}
+
+// basicAuthUsernames returns just the usernames for API responses — never
+// the hashes.
+func basicAuthUsernames(encoded string) []string {
+ var out []string
+ for _, pair := range strings.Split(encoded, ",") {
+ if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
+ out = append(out, u)
+ }
+ }
+ return out
+}
+
+// serviceWriteAccess mirrors the service update handler's access check.
+func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
+ userID, _ := c.Get("user_id")
+ var ok bool
+ err := db.QueryRow(
+ `SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
+ WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
+ serviceID, userID, contextIsAdmin(c)).Scan(&ok)
+ return err == nil && ok
+}
+
+func handleListServiceDomains(c *gin.Context) {
+ db, exists := c.Get("db")
+ if !exists {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
+ return
+ }
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
+}
+
+func handleAddServiceDomain(c *gin.Context) {
+ db, exists := c.Get("db")
+ if !exists {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
+ return
+ }
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
+ return
+ }
+ if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
+ respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
+ return
+ }
+ var req struct {
+ Domain string `json:"domain" binding:"required"`
+ IsDefault bool `json:"is_default"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
+ return
+ }
+ domain := strings.ToLower(strings.TrimSpace(req.Domain))
+ if !validHostname(domain) {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
+ return
+ }
+
+ // First domain on a service becomes the default automatically.
+ var count int
+ _ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
+ makeDefault := req.IsDefault || count == 0
+
+ tx, err := db.(*database.DB).Begin()
+ if err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
+ return
+ }
+ defer tx.Rollback()
+ if makeDefault {
+ if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
+ return
+ }
+ }
+ var inserted ServiceDomain
+ err = tx.QueryRow(
+ `INSERT INTO service_domains (service_id, domain, is_default)
+ VALUES ($1, $2, $3)
+ RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
+ serviceID, domain, makeDefault).
+ Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
+ if err != nil {
+ if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
+ respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
+ return
+ }
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
+ return
+ }
+ syncDefaultDomain(db.(*database.DB), serviceID)
+ c.JSON(http.StatusCreated, gin.H{"domain": inserted})
+}
+
+func handleDeleteServiceDomain(c *gin.Context) {
+ db, exists := c.Get("db")
+ if !exists {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
+ return
+ }
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
+ return
+ }
+ if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
+ respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
+ return
+ }
+ domainID, err := uuid.Parse(c.Param("domain_id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
+ return
+ }
+ res, err := db.(*database.DB).Exec(
+ `DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
+ affected, _ := res.RowsAffected()
+ if err != nil || affected == 0 {
+ respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
+ return
+ }
+ syncDefaultDomain(db.(*database.DB), serviceID)
+ c.JSON(http.StatusOK, gin.H{"deleted": domainID})
+}
+
+func handleSetDefaultDomain(c *gin.Context) {
+ db, exists := c.Get("db")
+ if !exists {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
+ return
+ }
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
+ return
+ }
+ if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
+ respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
+ return
+ }
+ domainID, err := uuid.Parse(c.Param("domain_id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
+ return
+ }
+ res, err := db.(*database.DB).Exec(
+ `UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
+ domainID, serviceID)
+ if err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
+ return
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
+ return
+ }
+ syncDefaultDomain(db.(*database.DB), serviceID)
+ c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
+}
+
+// handleCheckServiceDomains resolves each domain's DNS and compares it to
+// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
+// base_url host). Statuses: ok | wrong-target | pending.
+func handleCheckServiceDomains(c *gin.Context) {
+ db, exists := c.Get("db")
+ if !exists {
+ respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
+ return
+ }
+ dbh := db.(*database.DB)
+ serviceID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
+ return
+ }
+
+ expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
+ expectedHost := ""
+ if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
+ if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
+ expectedHost, _, _ = strings.Cut(u, "/")
+ expectedHost, _, _ = strings.Cut(expectedHost, ":")
+ }
+ }
+
+ type result struct {
+ Domain string `json:"domain"`
+ Status string `json:"status"` // ok | wrong-target | pending
+ Resolved []string `json:"resolved"`
+ }
+ results := []result{}
+ resolver := net.DefaultResolver
+ for _, d := range loadServiceDomains(dbh, serviceID) {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
+ addrs, _ := resolver.LookupHost(ctx, d.Domain)
+ cname, _ := resolver.LookupCNAME(ctx, d.Domain)
+ cancel()
+
+ r := result{Domain: d.Domain, Resolved: addrs}
+ cname = strings.TrimSuffix(cname, ".")
+ switch {
+ case len(addrs) == 0:
+ r.Status = "pending"
+ case expectedIP != "":
+ r.Status = "wrong-target"
+ for _, a := range addrs {
+ if a == expectedIP {
+ r.Status = "ok"
+ }
+ }
+ case expectedHost != "":
+ r.Status = "ok" // resolves; CNAME match is advisory only
+ if cname != "" && cname != d.Domain && cname != expectedHost {
+ r.Status = "wrong-target"
+ }
+ default:
+ r.Status = "ok"
+ }
+ results = append(results, r)
+ _, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
+ }
+ c.JSON(http.StatusOK, gin.H{"domains": results})
+}
+
+// handleMaintenancePage serves the maintenance-mode landing page. Public:
+// browsers hit it via the Traefik redirect before any auth.
+func handleMaintenancePage(c *gin.Context) {
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`
+
+Maintenance
+
This service is under maintenance
+
Please check back shortly.
+
Containr
+
`))
+}
diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go
index e0e9bdc..a9eb177 100644
--- a/app/backend/internal/api/routes.go
+++ b/app/backend/internal/api/routes.go
@@ -248,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
+ public.GET("/maintenance", handleMaintenancePage)
}
// Read routes — public browsing. OptionalAuth resolves a session when
@@ -268,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments)
+ read.GET("/services/:id/domains", handleListServiceDomains)
+ read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
@@ -326,6 +329,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService)
+ authed.POST("/services/:id/domains", handleAddServiceDomain)
+ authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
+ authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go
index e74e144..d25cab7 100644
--- a/app/backend/internal/api/runtime.go
+++ b/app/backend/internal/api/runtime.go
@@ -54,6 +54,11 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.Volumes = loadServiceVolumes(db, service.ID)
+ spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
+ spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
+ if spec.Maintenance {
+ spec.MaintenanceURL = maintenanceURL(db)
+ }
if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port)
}
diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go
index ccf1ac5..07a6704 100644
--- a/app/backend/internal/api/services.go
+++ b/app/backend/internal/api/services.go
@@ -38,6 +38,9 @@ type Service struct {
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
+ Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
+ MaintenanceMode bool `json:"maintenance_mode" db:"-"`
+ BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -112,6 +115,8 @@ type CreateServiceRequest struct {
HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes []ServiceVolume `json:"volumes"`
+ MaintenanceMode bool `json:"maintenance_mode"`
+ BasicAuth []BasicAuthCred `json:"basic_auth"`
}
// UpdateServiceRequest represents a request to update a service
@@ -132,6 +137,8 @@ type UpdateServiceRequest struct {
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
+ MaintenanceMode *bool `json:"maintenance_mode"`
+ BasicAuth *[]BasicAuthCred `json:"basic_auth"`
}
// handleGetServices retrieves all services for a project
@@ -359,6 +366,26 @@ func handleCreateService(c *gin.Context) {
return
}
+ // Domains live in service_domains; services.domain is the derived default.
+ if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
+ _, _ = db.(*database.DB).Exec(
+ `INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
+ ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
+ service.ID, strings.ToLower(req.Domain))
+ }
+ if req.MaintenanceMode || len(req.BasicAuth) > 0 {
+ basicAuthUsers := ""
+ if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
+ basicAuthUsers = encoded
+ }
+ _, _ = db.(*database.DB).Exec(
+ `UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
+ req.MaintenanceMode, basicAuthUsers, service.ID)
+ service.MaintenanceMode = req.MaintenanceMode
+ service.BasicAuth = basicAuthUsernames(basicAuthUsers)
+ }
+ service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
+
c.JSON(http.StatusCreated, gin.H{"service": service})
}
@@ -441,6 +468,10 @@ func handleGetService(c *gin.Context) {
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
})
}
+ service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
+ maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
+ service.MaintenanceMode = maintenance
+ service.BasicAuth = basicAuthUsernames(basicAuth)
c.JSON(http.StatusOK, gin.H{"service": service})
}
@@ -555,14 +586,17 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy
}
- var volumesJSON []byte
+ // lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
+ // so the COALESCE fallback needs an untyped nil when volumes aren't sent.
+ var volumesArg interface{}
if req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
existingService.Volumes = *req.Volumes
- volumesJSON, _ = json.Marshal(*req.Volumes)
+ volumesJSON, _ := json.Marshal(*req.Volumes)
+ volumesArg = volumesJSON
}
existingService.UpdatedAt = time.Now()
@@ -573,14 +607,14 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
- restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16
+ restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy,
- existingService.UpdatedAt, volumesJSON, existingService.ID,
+ existingService.UpdatedAt, volumesArg, existingService.ID,
)
if err != nil {
@@ -588,6 +622,48 @@ func handleUpdateService(c *gin.Context) {
return
}
+ // Domain writes route through service_domains — services.domain is the
+ // derived default and gets rewritten by syncDefaultDomain.
+ if req.Domain != nil {
+ domain := strings.ToLower(strings.TrimSpace(*req.Domain))
+ if domain == "" {
+ _, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
+ } else if validHostname(domain) {
+ _, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
+ _, _ = db.(*database.DB).Exec(
+ `INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
+ ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
+ serviceID, domain)
+ } else {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
+ return
+ }
+ syncDefaultDomain(db.(*database.DB), serviceID)
+ _ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
+ }
+
+ if req.MaintenanceMode != nil || req.BasicAuth != nil {
+ maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
+ if req.MaintenanceMode != nil {
+ maintenance = *req.MaintenanceMode
+ }
+ if req.BasicAuth != nil {
+ encoded, encErr := encodeBasicAuth(*req.BasicAuth)
+ if encErr != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
+ return
+ }
+ basicAuth = encoded
+ }
+ _, _ = db.(*database.DB).Exec(
+ `UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
+ maintenance, basicAuth, serviceID)
+ existingService.MaintenanceMode = maintenance
+ }
+ existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
+ _, storedAuth := serviceAccess(db.(*database.DB), serviceID)
+ existingService.BasicAuth = basicAuthUsernames(storedAuth)
+
// A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build.
diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go
index d85f353..5f68f2c 100644
--- a/app/backend/internal/cli/commands/services.go
+++ b/app/backend/internal/cli/commands/services.go
@@ -179,6 +179,21 @@ var servicesUpdateCmd = &cobra.Command{
}
body["volumes"] = volumes
}
+ if cmd.Flags().Changed("maintenance") {
+ v, _ := cmd.Flags().GetString("maintenance")
+ body["maintenance_mode"] = v == "on" || v == "true"
+ }
+ if cmd.Flags().Changed("basic-auth") {
+ creds := []map[string]interface{}{}
+ pairs, _ := cmd.Flags().GetStringArray("basic-auth")
+ for _, p := range pairs {
+ user, pass, ok := strings.Cut(p, ":")
+ if ok {
+ creds = append(creds, map[string]interface{}{"username": user, "password": pass})
+ }
+ }
+ body["basic_auth"] = creds
+ }
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
@@ -220,6 +235,141 @@ func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interfac
return out
}
+var servicesDomainsCmd = &cobra.Command{
+ Use: "domains",
+ Short: "Manage service domains",
+}
+
+var domainsListCmd = &cobra.Command{
+ Use: "list ",
+ Short: "List domains attached to a service",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
+ if err != nil {
+ return err
+ }
+ items, err := unwrapList(data, "domains")
+ if err != nil {
+ return err
+ }
+ rows := make([][]string, 0, len(items))
+ for _, d := range items {
+ def := ""
+ if b, ok := d["is_default"].(bool); ok && b {
+ def = "*"
+ }
+ rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
+ }
+ printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
+ return nil
+ },
+}
+
+var domainsAddCmd = &cobra.Command{
+ Use: "add ",
+ Short: "Attach a domain to a service",
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ def, _ := cmd.Flags().GetBool("default")
+ data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
+ "domain": args[1], "is_default": def,
+ })
+ if err != nil {
+ return err
+ }
+ if JSONMode() {
+ PrintRaw(data)
+ return nil
+ }
+ fmt.Printf("Added domain %s\n", args[1])
+ return nil
+ },
+}
+
+var domainsRemoveCmd = &cobra.Command{
+ Use: "remove ",
+ Short: "Detach a domain",
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
+ return err
+ }
+ if JSONMode() {
+ return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
+ }
+ fmt.Printf("Removed domain %s\n", args[1])
+ return nil
+ },
+}
+
+var domainsDefaultCmd = &cobra.Command{
+ Use: "default ",
+ Short: "Set the default domain",
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
+ if err != nil {
+ return err
+ }
+ if JSONMode() {
+ PrintRaw(data)
+ return nil
+ }
+ fmt.Printf("Default domain updated\n")
+ return nil
+ },
+}
+
+var domainsCheckCmd = &cobra.Command{
+ Use: "check ",
+ Short: "DNS preflight for all service domains",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
+ if err != nil {
+ return err
+ }
+ items, err := unwrapList(data, "domains")
+ if err != nil {
+ return err
+ }
+ rows := make([][]string, 0, len(items))
+ for _, d := range items {
+ resolved, _ := d["resolved"].([]interface{})
+ addrs := make([]string, 0, len(resolved))
+ for _, a := range resolved {
+ if s, ok := a.(string); ok {
+ addrs = append(addrs, s)
+ }
+ }
+ rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
+ }
+ printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
+ return nil
+ },
+}
+
// serviceAction returns a RunE for the simple POST action endpoints.
func serviceAction(verb string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
@@ -271,6 +421,8 @@ func init() {
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
uf.Bool("clear-volumes", false, "remove all volume mounts")
+ uf.String("maintenance", "", "maintenance mode: on|off")
+ uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
verb := a
@@ -281,5 +433,7 @@ func init() {
RunE: serviceAction(verb),
})
}
- ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd)
+ domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
+ servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
+ ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
}
diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go
index 9f0b1ef..585c59c 100644
--- a/app/backend/internal/database/sqlcdb/models.go
+++ b/app/backend/internal/database/sqlcdb/models.go
@@ -202,6 +202,19 @@ type Service struct {
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
+ MaintenanceMode bool `json:"maintenance_mode"`
+ BasicAuthUsers string `json:"basic_auth_users"`
+}
+
+type ServiceDomain struct {
+ ID uuid.UUID `json:"id"`
+ ServiceID uuid.UUID `json:"service_id"`
+ Domain string `json:"domain"`
+ IsDefault bool `json:"is_default"`
+ CertType string `json:"cert_type"`
+ CertStatus string `json:"cert_status"`
+ LastCheckedAt sql.NullTime `json:"last_checked_at"`
+ CreatedAt time.Time `json:"created_at"`
}
type ServiceTemplate struct {
diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go
index 7dfdbc1..4f1d3df 100644
--- a/app/backend/internal/deployment/engine.go
+++ b/app/backend/internal/deployment/engine.go
@@ -57,6 +57,11 @@ type ServiceConfig struct {
PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"`
+ // Domains is the full hostname set (includes Domain when set).
+ Domains []string `json:"domains,omitempty"`
+ Maintenance bool `json:"maintenance,omitempty"`
+ MaintenanceURL string `json:"maintenance_url,omitempty"`
+ BasicAuthUsers string `json:"basic_auth_users,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0
@@ -312,21 +317,25 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
cfg := deployment.Config
spec := RuntimeSpec{
- ProjectID: deployment.ProjectID,
- ServiceID: deployment.ServiceID,
- Name: cfg.Name,
- Image: deployment.ImageName,
- Command: cfg.Command,
- Env: cfg.Environment,
- Replicas: cfg.Replicas,
- Port: cfg.PublicPort,
- PublishedPort: cfg.PublishedPort,
- Domain: cfg.Domain,
- HealthPath: cfg.HealthPath,
- RestartPolicy: cfg.RestartPolicy,
- MemoryBytes: cfg.Resources.MemoryBytes,
- NanoCPUs: cfg.Resources.CPUQuota,
- Volumes: cfg.VolumeMounts,
+ ProjectID: deployment.ProjectID,
+ ServiceID: deployment.ServiceID,
+ Name: cfg.Name,
+ Image: deployment.ImageName,
+ Command: cfg.Command,
+ Env: cfg.Environment,
+ Replicas: cfg.Replicas,
+ Port: cfg.PublicPort,
+ PublishedPort: cfg.PublishedPort,
+ Domain: cfg.Domain,
+ HealthPath: cfg.HealthPath,
+ RestartPolicy: cfg.RestartPolicy,
+ MemoryBytes: cfg.Resources.MemoryBytes,
+ NanoCPUs: cfg.Resources.CPUQuota,
+ Volumes: cfg.VolumeMounts,
+ Domains: cfg.Domains,
+ Maintenance: cfg.Maintenance,
+ MaintenanceURL: cfg.MaintenanceURL,
+ BasicAuthUsers: cfg.BasicAuthUsers,
}
for _, pm := range cfg.PortMappings {
if spec.Port == 0 {
diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go
index 6bd0819..3373c79 100644
--- a/app/backend/internal/deployment/runtime.go
+++ b/app/backend/internal/deployment/runtime.go
@@ -34,21 +34,25 @@ const (
// RuntimeSpec is everything needed to run a service's containers.
type RuntimeSpec struct {
- ProjectID string
- ServiceID string
- Name string // DNS alias on the project network
- Image string
- Command []string
- Env map[string]string // already resolved
- Replicas int
- Port int32 // container port to expose publicly; 0 = none
- PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
- Domain string // public hostname routed via Traefik; empty = none
- HealthPath string // http path probed on Port for container healthcheck
- RestartPolicy string
- MemoryBytes int64
- NanoCPUs int64
- Volumes []VolumeMount // applied to every replica
+ ProjectID string
+ ServiceID string
+ Name string // DNS alias on the project network
+ Image string
+ Command []string
+ Env map[string]string // already resolved
+ Replicas int
+ Port int32 // container port to expose publicly; 0 = none
+ PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
+ Domain string // public hostname routed via Traefik; empty = none
+ HealthPath string // http path probed on Port for container healthcheck
+ RestartPolicy string
+ MemoryBytes int64
+ NanoCPUs int64
+ Volumes []VolumeMount // applied to every replica
+ Domains []string // all public hostnames; Domain is the default
+ Maintenance bool // redirect traffic to MaintenanceURL
+ MaintenanceURL string // absolute URL; empty = serve empty response
+ BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
}
// RuntimeContainer describes one live replica.
@@ -227,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
+ router := "svc-" + spec.ServiceID[:8]
+ domains := spec.Domains
+ if len(domains) == 0 && spec.Domain != "" {
+ domains = []string{spec.Domain}
+ }
+ sort.Strings(domains)
+ rules := make([]string, 0, len(domains))
+ for _, d := range domains {
+ rules = append(rules, "Host(`"+d+"`)")
+ }
endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet
- labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
- labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
- labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
+ labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
+ labels["traefik.http.routers."+router+".entrypoints"] = "web"
+ labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
+
+ var middlewares []string
+ if spec.Maintenance {
+ if spec.MaintenanceURL != "" {
+ mw := router + "-maint"
+ labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
+ labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
+ labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
+ middlewares = append(middlewares, mw)
+ } else {
+ // No redirect target configured — take the site offline with
+ // empty responses instead of leaking traffic to the app.
+ labels["traefik.http.routers."+router+".service"] = "noop@internal"
+ }
+ }
+ if spec.BasicAuthUsers != "" {
+ mw := router + "-auth"
+ labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
+ middlewares = append(middlewares, mw)
+ }
+ if len(middlewares) > 0 {
+ labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
+ }
}
cfg := docker.ContainerConfig{
@@ -490,8 +527,12 @@ func specHash(spec RuntimeSpec) string {
Port int32
MemoryBytes, NanoCPUs int64
Volumes []VolumeMount
+ Domains []string
+ Maintenance bool
+ BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
- spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes})
+ spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
+ spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8])
}
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index b7797d8..54b1979 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -61,7 +61,14 @@ func init() {
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
- register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear.", "PUT", "/services/{id}", []string{"id"}, nil, false})
+ register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears.", "PUT", "/services/{id}", []string{"id"}, nil, false})
+
+ // Domains
+ register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
+ register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
+ register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
+ register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
+ register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
diff --git a/app/backend/migrations_goose/20261010000000_service_domains.sql b/app/backend/migrations_goose/20261010000000_service_domains.sql
new file mode 100644
index 0000000..16289c9
--- /dev/null
+++ b/app/backend/migrations_goose/20261010000000_service_domains.sql
@@ -0,0 +1,30 @@
+-- +goose Up
+-- Multi-domain support. services.domain stays as the derived default for
+-- compatibility; service_domains is the source of truth for the full
+-- hostname set. Backfills existing services.domain rows as defaults.
+CREATE TABLE IF NOT EXISTS service_domains (
+ id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
+ service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ domain varchar(255) NOT NULL,
+ is_default boolean NOT NULL DEFAULT false,
+ cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
+ cert_status varchar(32) NOT NULL DEFAULT 'pending',
+ last_checked_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (service_id, domain)
+);
+CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
+
+INSERT INTO service_domains (service_id, domain, is_default)
+ SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
+ ON CONFLICT (service_id, domain) DO NOTHING;
+
+-- Access control: maintenance mode redirects to a platform page; basic auth
+-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
+ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
+ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
+
+-- +goose Down
+ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
+ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
+DROP TABLE IF EXISTS service_domains;
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index d07b9e2..fd208cc 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -59,7 +59,21 @@ CREATE TABLE services (
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
published_port INTEGER NOT NULL DEFAULT 0,
- volumes JSONB NOT NULL DEFAULT '[]'::jsonb
+ volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
+ maintenance_mode BOOLEAN NOT NULL DEFAULT false,
+ basic_auth_users TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE service_domains (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ domain VARCHAR(255) NOT NULL,
+ is_default BOOLEAN NOT NULL DEFAULT false,
+ cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt',
+ cert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
+ last_checked_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (service_id, domain)
);
CREATE TABLE deployments (
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
index 8259ed3..d6422df 100644
--- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
+++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
@@ -36,6 +36,13 @@ import {
getServiceRuntime,
type CronJobEntity,
type ServiceVolume,
+ type ServiceDomain,
+ type DomainCheckResult,
+ listServiceDomains,
+ addServiceDomain,
+ removeServiceDomain,
+ setDefaultServiceDomain,
+ checkServiceDomains,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
@@ -438,6 +445,23 @@ export function ServiceDetailPage() {
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null);
const [volumesForm, setVolumesForm] = useState(null);
+ const [domainInput, setDomainInput] = useState('');
+ const [domainChecks, setDomainChecks] = useState | null>(null);
+ const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
+
+ const domainsQuery = useQuery({
+ queryKey: ['service-domains', serviceId],
+ queryFn: () => listServiceDomains(serviceId),
+ enabled: Boolean(serviceId) && !isDemoMode,
+ });
+
+ const domainMutation = useMutation({
+ mutationFn: (fn: () => Promise) => fn(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
+ invalidateService();
+ },
+ });
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -2194,6 +2218,174 @@ export function ServiceDetailPage() {
)}
+ {!isDemoMode && (
+
+
+
Domains
+
+
+
+
+ {(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
+
No domains attached.
+ )}
+ {(domainsQuery.data ?? []).map((d: ServiceDomain) => {
+ const check = domainChecks?.[d.domain];
+ return (
+
+ {d.domain}
+ {d.is_default && default}
+ {check && (
+
+ {check.status}
+
+ )}
+
+ {!d.is_default && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+ setDomainInput(e.target.value)}
+ placeholder="app.example.com"
+ className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
+ />
+
+
+ {domainMutation.isError && (
+
{(domainMutation.error as Error).message}
+ )}
+
Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.
+
+ )}
+
+ {!isDemoMode && (
+
+
+
Access
+ {accessForm === null && (
+
+ )}
+
+
+ {accessForm === null ? (
+
+
+
Maintenance mode
+
{service.maintenanceMode ? 'on' : 'off'}
+
+
+
Basic auth
+
+ {(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
Applies on next deploy or redeploy
+
+
+ )}
+
+ )}
+
{!isDemoMode && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts
index 53b37e8..108b2bf 100644
--- a/app/frontend/src/generated/api-types.ts
+++ b/app/frontend/src/generated/api-types.ts
@@ -971,6 +971,245 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/services/{id}/domains": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List service domains
+ * @description All hostnames attached to the service; the default is mirrored to services.domain
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Domain list */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ domains?: components["schemas"]["ServiceDomain"][];
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ /**
+ * Attach a domain
+ * @description Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ domain: string;
+ is_default?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description Domain attached */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ domain?: components["schemas"]["ServiceDomain"];
+ };
+ };
+ };
+ /** @description Domain already attached */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/services/{id}/domains/check": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * DNS preflight
+ * @description Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Per-domain DNS status */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ domains?: components["schemas"]["DomainCheckResult"][];
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/services/{id}/domains/{domain_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Detach a domain */
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ domain_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Domain removed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/services/{id}/domains/{domain_id}/default": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Set the default domain
+ * @description Marks the domain default and mirrors it to services.domain
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ domain_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Updated domain list */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ domains?: components["schemas"]["ServiceDomain"][];
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/maintenance": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Maintenance page
+ * @description Public HTML page Traefik redirects to while a service is in maintenance mode
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: never;
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/services/{id}/deployments": {
parameters: {
query?: never;
@@ -7805,6 +8044,12 @@ export interface components {
restart_policy?: string;
/** @description Volume/bind mounts applied to every replica */
volumes?: components["schemas"]["ServiceVolume"][];
+ /** @description All attached hostnames; `domain` is the derived default */
+ domains?: components["schemas"]["ServiceDomain"][];
+ /** @description Redirect all traffic to the platform maintenance page */
+ maintenance_mode?: boolean;
+ /** @description Basic-auth usernames gating the route (hashes never returned) */
+ basic_auth?: string[];
/** @description Computed public URL (published port or domain) */
public_url?: string;
/**
@@ -7902,6 +8147,40 @@ export interface components {
restart_policy?: string;
/** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */
volumes?: components["schemas"]["ServiceVolume"][];
+ /** @description Redirect all traffic to the platform maintenance page */
+ maintenance_mode?: boolean;
+ /** @description Replaces all basic-auth credentials; [] clears the gate */
+ basic_auth?: components["schemas"]["BasicAuthCred"][];
+ };
+ ServiceDomain: {
+ /** Format: uuid */
+ id?: string;
+ /** Format: uuid */
+ service_id?: string;
+ domain?: string;
+ is_default?: boolean;
+ cert_type?: string;
+ /** @enum {string} */
+ cert_status?: "pending" | "issued" | "failed";
+ /** Format: date-time */
+ last_checked_at?: string;
+ /** Format: date-time */
+ created_at?: string;
+ };
+ DomainCheckResult: {
+ domain?: string;
+ /**
+ * @description ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
+ * @enum {string}
+ */
+ status?: "ok" | "wrong-target" | "pending";
+ /** @description A/AAAA records resolved */
+ resolved?: string[];
+ };
+ BasicAuthCred: {
+ username: string;
+ /** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
+ password: string;
};
ServiceVolume: {
/**
diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts
index 6aa2769..437fccb 100644
--- a/app/frontend/src/generated/docs-snapshot.ts
+++ b/app/frontend/src/generated/docs-snapshot.ts
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
- "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
+ "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index 14da15d..1c26179 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -55,9 +55,29 @@ export type ServiceEntity = {
healthcheckPath?: string;
restartPolicy?: string;
volumes?: ServiceVolume[];
+ domains?: ServiceDomain[];
+ maintenanceMode?: boolean;
+ basicAuth?: string[];
publicUrl?: string;
};
+export type ServiceDomain = {
+ id: string;
+ service_id: string;
+ domain: string;
+ is_default: boolean;
+ cert_type?: string;
+ cert_status?: string;
+ last_checked_at?: string;
+ created_at?: string;
+};
+
+export type DomainCheckResult = {
+ domain: string;
+ status: 'ok' | 'wrong-target' | 'pending';
+ resolved?: string[];
+};
+
export type ServiceVolume = {
type?: 'volume' | 'bind';
source: string;
@@ -561,6 +581,9 @@ function normalizeService(service: RawService): ServiceEntity | null {
healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy,
volumes: service.volumes as ServiceVolume[] | undefined,
+ domains: service.domains as ServiceDomain[] | undefined,
+ maintenanceMode: service.maintenance_mode,
+ basicAuth: service.basic_auth as string[] | undefined,
publicUrl: service.public_url,
};
}
@@ -1100,6 +1123,36 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed;
}
+export async function listServiceDomains(serviceId: string): Promise {
+ const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
+ return payload.domains ?? [];
+}
+
+export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise {
+ const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
+ method: 'POST',
+ body: JSON.stringify({ domain, is_default: isDefault }),
+ });
+ return payload.domain;
+}
+
+export async function removeServiceDomain(serviceId: string, domainId: string): Promise {
+ await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
+}
+
+export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise {
+ const payload = await requestJson<{ domains?: ServiceDomain[] }>(
+ `/services/${serviceId}/domains/${domainId}/default`,
+ { method: 'POST' },
+ );
+ return payload.domains ?? [];
+}
+
+export async function checkServiceDomains(serviceId: string): Promise {
+ const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
+ return payload.domains ?? [];
+}
+
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise {
const payload = await requestJson(`/services/${serviceId}`, {
method: 'PUT',
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d3b2afb..6303f0f 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -667,6 +667,163 @@ paths:
'200':
description: Reconciled runtime state
+ /services/{id}/domains:
+ get:
+ tags:
+ - Services
+ summary: List service domains
+ description: All hostnames attached to the service; the default is mirrored to services.domain
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Domain list
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ domains:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceDomain'
+ post:
+ tags:
+ - Services
+ summary: Attach a domain
+ description: Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - domain
+ properties:
+ domain:
+ type: string
+ is_default:
+ type: boolean
+ responses:
+ '201':
+ description: Domain attached
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ domain:
+ $ref: '#/components/schemas/ServiceDomain'
+ '409':
+ description: Domain already attached
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /services/{id}/domains/check:
+ get:
+ tags:
+ - Services
+ summary: DNS preflight
+ description: Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Per-domain DNS status
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ domains:
+ type: array
+ items:
+ $ref: '#/components/schemas/DomainCheckResult'
+
+ /services/{id}/domains/{domain_id}:
+ delete:
+ tags:
+ - Services
+ summary: Detach a domain
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: domain_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Domain removed
+
+ /services/{id}/domains/{domain_id}/default:
+ post:
+ tags:
+ - Services
+ summary: Set the default domain
+ description: Marks the domain default and mirrors it to services.domain
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: domain_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Updated domain list
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ domains:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceDomain'
+
+ /maintenance:
+ get:
+ tags:
+ - Services
+ summary: Maintenance page
+ description: Public HTML page Traefik redirects to while a service is in maintenance mode
+
/services/{id}/deployments:
get:
tags:
@@ -5003,6 +5160,19 @@ components:
items:
$ref: '#/components/schemas/ServiceVolume'
description: Volume/bind mounts applied to every replica
+ domains:
+ type: array
+ items:
+ $ref: '#/components/schemas/ServiceDomain'
+ description: All attached hostnames; `domain` is the derived default
+ maintenance_mode:
+ type: boolean
+ description: Redirect all traffic to the platform maintenance page
+ basic_auth:
+ type: array
+ items:
+ type: string
+ description: Basic-auth usernames gating the route (hashes never returned)
public_url:
type: string
description: Computed public URL (published port or domain)
@@ -5134,6 +5304,66 @@ components:
items:
$ref: '#/components/schemas/ServiceVolume'
description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile.
+ maintenance_mode:
+ type: boolean
+ description: Redirect all traffic to the platform maintenance page
+ basic_auth:
+ type: array
+ items:
+ $ref: '#/components/schemas/BasicAuthCred'
+ description: Replaces all basic-auth credentials; [] clears the gate
+
+ ServiceDomain:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ service_id:
+ type: string
+ format: uuid
+ domain:
+ type: string
+ is_default:
+ type: boolean
+ cert_type:
+ type: string
+ cert_status:
+ type: string
+ enum: [pending, issued, failed]
+ last_checked_at:
+ type: string
+ format: date-time
+ created_at:
+ type: string
+ format: date-time
+
+ DomainCheckResult:
+ type: object
+ properties:
+ domain:
+ type: string
+ status:
+ type: string
+ enum: [ok, wrong-target, pending]
+ description: ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
+ resolved:
+ type: array
+ items:
+ type: string
+ description: A/AAAA records resolved
+
+ BasicAuthCred:
+ type: object
+ required:
+ - username
+ - password
+ properties:
+ username:
+ type: string
+ password:
+ type: string
+ description: Plaintext on write — stored as bcrypt htpasswd; never returned
ServiceVolume:
type: object
diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md
index 200180b..feaedec 100644
--- a/docs/guides/CLI.md
+++ b/docs/guides/CLI.md
@@ -38,6 +38,7 @@ auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens
projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy
+services domains list|add|remove|default|check multi-domain + DNS preflight
volumes list|delete docker volume inventory + cleanup (admin)
deploy trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel
@@ -87,6 +88,33 @@ redeploy — `containr deploy ` to apply immediately.
`volumes list` shows the node's docker volume inventory with in-use
flags; `volumes delete ` removes an unused volume (admin scope).
+## Service domains and access
+
+A service can carry multiple hostnames; the default is mirrored to
+`services.domain`:
+
+```bash
+containr services domains add app.example.com --default
+containr services domains check # DNS preflight per domain
+containr services domains remove
+```
+
+DNS check compares each domain to the expected target — set
+`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
+meaningful; without it any resolvable domain reports `ok`.
+
+Access gates apply via Traefik middlewares (no extra proxy):
+
+```bash
+containr services update --maintenance on
+containr services update --basic-auth admin:secret --basic-auth ops:pw2
+```
+
+Maintenance mode redirects all traffic to `/api/v1/maintenance`
+— set `app_settings.base_url` or `BASE_URL`; without it the service is
+taken offline with empty responses. Basic-auth credentials are stored
+bcrypt-hashed (htpasswd) and gate every request.
+
## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
diff --git a/llms.txt b/llms.txt
index 9e68cc7..b63268b 100644
--- a/llms.txt
+++ b/llms.txt
@@ -17,6 +17,7 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
+- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects