feat(docker): support XUI_PORT runtime override (#5240)

* feat(docker): support XUI_PORT runtime override

Allow deployments to select the panel listener port without mutating the persisted webPort setting. Invalid values fall back to the database-backed port and are covered by parser boundary tests.

* docs: describe XUI_PORT deployment usage

Add commented local and Compose examples, explain runtime precedence, and call out matching Docker bridge port mappings.
This commit is contained in:
Pavel
2026-06-15 00:15:08 +05:00
committed by GitHub
parent a133282fc3
commit 7f34c306d7
6 changed files with 99 additions and 1 deletions

View File

@ -3,3 +3,4 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080

View File

@ -73,6 +73,7 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
```
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
@ -256,9 +257,16 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
| `XUI_PORT` | persisted `webPort` | Runtime-only web panel listener port override (`1` through `65535`) |
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
A valid `XUI_PORT` takes precedence over the database-backed `webPort` for the
current process without changing the stored setting. Unset, empty, whitespace-only,
malformed, or out-of-range values fall back to `webPort`; invalid configured values
also produce a warning. With Docker bridge networking, the published container port
must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080"]`.
## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)

View File

@ -19,6 +19,7 @@ services:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# XUI_INIT_WEB_BASE_PATH: "/"
# XUI_PORT: "8080"
# To use PostgreSQL instead of the default SQLite, run:
# docker compose --profile postgres up -d
# and uncomment the two lines below.
@ -26,6 +27,7 @@ services:
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
tty: true
ports:
# When XUI_PORT is set, publish the same container port (for example "8080:8080").
- "2053:2053"
restart: unless-stopped
@ -39,4 +41,4 @@ services:
POSTGRES_DB: xui
volumes:
- $PWD/pgdata/:/var/lib/postgresql/data
restart: unless-stopped
restart: unless-stopped

View File

@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
@ -63,6 +64,23 @@ func IsSkipHSTS() bool {
return os.Getenv("XUI_SKIP_HSTS") == "true"
}
func GetPortOverride() (port int, configured bool, err error) {
value, ok := os.LookupEnv("XUI_PORT")
if !ok || strings.TrimSpace(value) == "" {
return 0, false, nil
}
port, err = strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, true, fmt.Errorf("parse XUI_PORT: %w", err)
}
if port < 1 || port > 65535 {
return 0, true, fmt.Errorf("XUI_PORT must be between 1 and 65535")
}
return port, true, nil
}
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
func GetBinFolderPath() string {
binFolderPath := os.Getenv("XUI_BIN_FOLDER")

View File

@ -0,0 +1,61 @@
package config
import (
"os"
"testing"
)
func TestGetPortOverride(t *testing.T) {
tests := []struct {
name string
value string
set bool
wantPort int
configured bool
wantErr bool
}{
{name: "unset"},
{name: "empty", value: "", set: true},
{name: "whitespace", value: " ", set: true},
{name: "minimum", value: "1", set: true, wantPort: 1, configured: true},
{name: "default panel port", value: "2053", set: true, wantPort: 2053, configured: true},
{name: "surrounding whitespace", value: " 8080 ", set: true, wantPort: 8080, configured: true},
{name: "maximum", value: "65535", set: true, wantPort: 65535, configured: true},
{name: "zero", value: "0", set: true, configured: true, wantErr: true},
{name: "above maximum", value: "65536", set: true, configured: true, wantErr: true},
{name: "negative", value: "-1", set: true, configured: true, wantErr: true},
{name: "non-numeric", value: "abc", set: true, configured: true, wantErr: true},
{name: "decimal", value: "8080.0", set: true, configured: true, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.set {
t.Setenv("XUI_PORT", tt.value)
} else {
original, existed := os.LookupEnv("XUI_PORT")
if err := os.Unsetenv("XUI_PORT"); err != nil {
t.Fatalf("unset XUI_PORT: %v", err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv("XUI_PORT", original)
} else {
_ = os.Unsetenv("XUI_PORT")
}
})
}
port, configured, err := GetPortOverride()
if port != tt.wantPort {
t.Errorf("port = %d, want %d", port, tt.wantPort)
}
if configured != tt.configured {
t.Errorf("configured = %t, want %t", configured, tt.configured)
}
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %t", err, tt.wantErr)
}
})
}
}

View File

@ -407,6 +407,14 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
if err != nil {
return err
}
if envPort, configured, envErr := config.GetPortOverride(); configured {
if envErr != nil {
logger.Warning("Ignoring invalid XUI_PORT; using configured web port:", port, envErr)
} else {
port = envPort
logger.Info("Using XUI_PORT override for web panel port:", port)
}
}
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
listener, err := net.Listen("tcp", listenAddr)
if err != nil {