Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ca6496e
fix: use bridge network for Windows Docker compatibility
ricky-chaoju Jan 16, 2026
5f188a9
fix: bind ports to all interfaces for LAN access
ricky-chaoju Jan 16, 2026
f3948ab
docs: add Windows Docker Desktop LAN access instructions
ricky-chaoju Jan 16, 2026
eb257e0
fix: use single-line docker command for Windows compatibility
ricky-chaoju Jan 16, 2026
0245894
feat: add Windows local docker command option for same-machine worker
ricky-chaoju Jan 16, 2026
c1ab9d4
fix: prevent progress reset during image extraction phase
ricky-chaoju Jan 16, 2026
6479e1a
fix: infinite wait for model/app loading, show patience message after…
ricky-chaoju Jan 16, 2026
1782711
fix: add auto-refresh toggle for logs, change Exit to Minimize
ricky-chaoju Jan 16, 2026
a0517be
feat: add Docker network support for Windows compatibility
ricky-chaoju Jan 16, 2026
a1f135c
fix: use container name for Docker internal network communication (Wi…
ricky-chaoju Jan 16, 2026
309df31
feat: add auto-restart, deployment sync, and chat proxy for Windows c…
ricky-chaoju Jan 17, 2026
07bb66e
fix: add credentials to chat proxy request
ricky-chaoju Jan 17, 2026
81ffa82
fix: add Authorization header to chat proxy request
ricky-chaoju Jan 17, 2026
fbddc24
fix: handle missing container_id in app start/stop
ricky-chaoju Jan 17, 2026
1290f8e
docs: simplify Windows firewall instructions, show port in DeployApps
ricky-chaoju Jan 17, 2026
ffc007a
fix: verify image exists before container creation in worker
ricky-chaoju Jan 17, 2026
5b8f653
fix: proxy option based on worker labels, prevent progress bar regres…
ricky-chaoju Jan 17, 2026
72856f0
fix: detect local workers via token.is_local instead of IP
ricky-chaoju Jan 17, 2026
5572874
fix: add database migration for is_local column
ricky-chaoju Jan 17, 2026
cf22501
fix: worker auto-detects local via BACKEND_URL, use indeterminate pro…
ricky-chaoju Jan 17, 2026
012d5db
fix: use browser hostname for apps with internal worker IP
ricky-chaoju Jan 17, 2026
d1df7a9
fix: handle stale image tags pointing to pruned SHA
ricky-chaoju Jan 17, 2026
847db2d
fix: prevent progress bar regression during image pull
ricky-chaoju Jan 17, 2026
63703df
fix: use Docker bridge gateway IP for container API access
ricky-chaoju Jan 18, 2026
8158906
feat: use host.docker.internal for apps, reset Open WebUI config on s…
ricky-chaoju Jan 18, 2026
34f5890
feat: add app status sync and fix stale image SHA error
ricky-chaoju Jan 18, 2026
6d8a678
fix: update Docker CLI to 27.x and mount docker.sock for local worker
ricky-chaoju Jan 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ docker compose -f docker-compose.deploy.yml up -d
- Frontend: http://localhost:3000
- Backend API: http://localhost:52000

### Windows Docker Desktop - LAN Access

Windows Firewall blocks LAN access by default. Choose one of the following options:

**Option 1: Disable Firewall (Simplest)**

```powershell
# Run in PowerShell (Administrator)
Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False
```

**Option 2: Add Firewall Rules (More Secure)**

```powershell
# Run in PowerShell (Administrator)
# Base ports (Frontend + Backend API)
New-NetFirewallRule -DisplayName "LMStack" -Direction Inbound -LocalPort 3000,52000 -Protocol TCP -Action Allow

# Model deployment ports (add ports as needed, e.g., 40000-40100)
New-NetFirewallRule -DisplayName "LMStack Models" -Direction Inbound -LocalPort 40000-40100 -Protocol TCP -Action Allow

# App ports (e.g., Open WebUI on 46488)
New-NetFirewallRule -DisplayName "LMStack Apps" -Direction Inbound -LocalPort 46000-46500 -Protocol TCP -Action Allow
```

> **Note**: When you deploy models or apps, check the assigned port in the UI and ensure it's allowed through the firewall.

### Usage

1. Login with `admin` / `admin` (change password after first login)
Expand Down
27 changes: 27 additions & 0 deletions README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ docker compose -f docker-compose.deploy.yml up -d
- 前端: http://localhost:3000
- 後端 API: http://localhost:52000

### Windows Docker Desktop - 區域網路存取

Windows 防火牆預設會阻擋區域網路存取。請選擇以下其中一種方式:

**方式一:關閉防火牆(最簡單)**

```powershell
# 在 PowerShell(系統管理員)中執行
Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False
```

**方式二:新增防火牆規則(較安全)**

```powershell
# 在 PowerShell(系統管理員)中執行
# 基本端口(前端 + 後端 API)
New-NetFirewallRule -DisplayName "LMStack" -Direction Inbound -LocalPort 3000,52000 -Protocol TCP -Action Allow

# 模型部署端口(依需求新增,例如 40000-40100)
New-NetFirewallRule -DisplayName "LMStack Models" -Direction Inbound -LocalPort 40000-40100 -Protocol TCP -Action Allow

# App 端口(例如 Open WebUI 使用 46488)
New-NetFirewallRule -DisplayName "LMStack Apps" -Direction Inbound -LocalPort 46000-46500 -Protocol TCP -Action Allow
```

> **注意**:部署模型或 App 時,請在 UI 中查看分配的端口,並確保該端口已在防火牆中開放。

### 使用方式

1. 使用 `admin` / `admin` 登入(首次登入後請更改密碼)
Expand Down
3 changes: 2 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ FROM python:3.11-slim
WORKDIR /app

# Install docker CLI for local worker spawn feature
# Using Docker 27.x for API version 1.47 compatibility
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.4.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \
&& rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
Expand Down
79 changes: 50 additions & 29 deletions backend/app/api/apps/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ async def pull_image_with_progress(
)

# Poll for progress while waiting
# Track last known progress to avoid regression when status is "unknown"
last_known_progress = 0

while not pull_task.done():
try:
progress_resp = await client.get(progress_url, timeout=5.0)
Expand All @@ -105,19 +108,23 @@ async def pull_image_with_progress(
progress = progress_data.get("progress", 0)

if status == "pulling":
set_deployment_progress(
app_id,
"pulling",
progress,
f"Pulling image {image}... ({progress}%)",
)
# Only update if progress is moving forward (avoid regression)
if progress >= last_known_progress:
last_known_progress = progress
set_deployment_progress(
app_id,
"pulling",
progress,
f"Pulling image {image}... ({progress}%)",
)
elif status == "completed":
set_deployment_progress(
app_id,
"pulling",
100,
"Image pulled successfully",
)
# Ignore "unknown" status - keep showing last known progress
except Exception:
pass # Progress polling is best-effort

Expand Down Expand Up @@ -145,7 +152,6 @@ async def wait_for_container_healthy(
container_id: str,
app_id: int,
port: int,
max_wait: int = 600,
poll_interval: int = 2,
) -> bool:
"""Wait for container to become healthy.
Expand All @@ -155,21 +161,22 @@ async def wait_for_container_healthy(
container_id: Container ID to check
app_id: App ID for progress tracking
port: App port for HTTP health check
max_wait: Maximum wait time in seconds
poll_interval: Time between checks in seconds

Returns:
True if healthy, False if timeout
True if healthy (waits indefinitely until healthy or error)
"""
waited = 0
consecutive_failures = 0
max_consecutive_failures = 10 # Fail after 20 seconds of no connection
slow_threshold = 1800 # 30 minutes before showing "check" message
shown_slow_message = False

worker_host = worker_address.split(":")[0]
app_url = f"http://{worker_host}:{port}"

async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
while waited < max_wait:
while True: # Wait indefinitely
try:
response = await client.get(f"http://{worker_address}/containers/{container_id}")

Expand Down Expand Up @@ -205,13 +212,34 @@ async def wait_for_container_healthy(

elif "health:" in status or "starting)" in status:
# Health check still running
progress_pct = min(50 + int(waited / max_wait * 40), 90)
set_deployment_progress(
app_id,
"starting",
progress_pct,
f"App is initializing ({waited}s, please wait)...",
)
mins = waited // 60
secs = waited % 60
time_str = f"{mins}m {secs}s" if mins > 0 else f"{secs}s"

if waited >= slow_threshold and not shown_slow_message:
set_deployment_progress(
app_id,
"starting",
80,
f"App is initializing ({time_str}) - Taking longer than expected. "
"Please check container logs for issues.",
)
shown_slow_message = True
elif shown_slow_message:
set_deployment_progress(
app_id,
"starting",
80,
f"App is initializing ({time_str}) - Please check logs if needed.",
)
else:
progress_pct = min(50 + int(waited / 600 * 40), 90)
set_deployment_progress(
app_id,
"starting",
progress_pct,
f"App is initializing ({time_str}, please wait)...",
)

elif "health" not in status:
# No health check defined, verify HTTP access directly
Expand Down Expand Up @@ -243,8 +271,6 @@ async def wait_for_container_healthy(
await asyncio.sleep(poll_interval)
waited += poll_interval

return False


async def _verify_http_access(
client: httpx.AsyncClient,
Expand Down Expand Up @@ -354,28 +380,21 @@ async def deploy_app_background(
app.port = port
await db.commit()

# Phase 3: Wait for health
# Phase 3: Wait for health (waits indefinitely until healthy or error)
set_deployment_progress(
app_id,
"starting",
50,
"Waiting for app to start (this may take 1-3 minutes)...",
"Waiting for app to start...",
)

is_healthy = await wait_for_container_healthy(
await wait_for_container_healthy(
worker_address=worker_address,
container_id=container_id,
app_id=app_id,
port=port,
)

if not is_healthy:
app.status = AppStatus.ERROR.value
app.status_message = "Container health check timed out after 10 minutes"
await db.commit()
set_deployment_progress(app_id, "error", 0, "Container health check timed out")
return

# Phase 4: Setup proxy
if use_proxy:
await _setup_nginx_proxy(app_id, app_type, worker_address, port)
Expand Down Expand Up @@ -451,6 +470,8 @@ async def _create_container(
"lmstack.app.type": app_type.value,
"lmstack.app.id": str(app_id),
},
# Add host.docker.internal mapping for container to access host services
"extra_hosts": {"host.docker.internal": "host-gateway"},
}

# Add Linux capabilities if specified (e.g., SYS_ADMIN for AnythingLLM)
Expand Down
12 changes: 12 additions & 0 deletions backend/app/api/apps/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ async def stop_app(
if app.status != AppStatus.RUNNING.value:
raise HTTPException(status_code=400, detail="App is not running")

if not app.container_id:
# No container to stop, just mark as stopped
app.status = AppStatus.STOPPED.value
await db.commit()
return app_to_response(app, request)

await db.refresh(app, ["worker"])
worker = app.worker

Expand Down Expand Up @@ -92,6 +98,12 @@ async def start_app(
if app.status not in [AppStatus.STOPPED.value, AppStatus.ERROR.value]:
raise HTTPException(status_code=400, detail="App is not stopped")

if not app.container_id:
raise HTTPException(
status_code=400,
detail="Container not found. Please delete and redeploy the app.",
)

await db.refresh(app, ["worker"])
worker = app.worker

Expand Down
10 changes: 5 additions & 5 deletions backend/app/api/apps/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,8 @@ async def deploy_app(
# Initialize progress
set_deployment_progress(app.id, "pending", 0, "Deployment queued...")

# Extract lmstack_port for background task
lmstack_host = request.headers.get("host", "localhost:52000")
lmstack_port = lmstack_host.split(":")[-1] if ":" in lmstack_host else "8000"
# Always use backend API port (52000)
lmstack_port = "52000"

# Start background deployment
background_tasks.add_task(
Expand Down Expand Up @@ -296,8 +295,9 @@ async def _build_env_vars(
db: AsyncSession,
) -> dict:
"""Build environment variables for the app container."""
lmstack_host = request.headers.get("host", "localhost:52000")
lmstack_port = lmstack_host.split(":")[-1] if ":" in lmstack_host else "8000"
# Always use backend API port (52000), not the frontend port from request
# The request may come from frontend (port 3000) but API is on 52000
lmstack_port = "52000"

host_ip = get_host_ip(request, worker)

Expand Down
43 changes: 27 additions & 16 deletions backend/app/api/apps/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,34 +141,45 @@ async def call_worker_api(
def get_host_ip(request: Request, worker: Worker) -> str:
"""Determine the host IP that the container can use to reach LMStack.

For Docker containers to reach the host, we use host.docker.internal which
is mapped via extra_hosts to host-gateway when creating the container.

Args:
request: FastAPI request object
worker: Worker where app is deployed

Returns:
Host IP address string
"""
import socket
# Check if worker is local (on same machine as LMStack)
worker_ip = worker.address.split(":")[0]
worker_labels = worker.labels or {}
is_local_worker = (
worker_ip in ("localhost", "127.0.0.1") or worker_labels.get("type") == "local"
)

if is_local_worker:
# For local workers, use host.docker.internal which is mapped to
# host-gateway via extra_hosts when creating the container.
# This works on all platforms (Linux, Windows, Mac).
return "host.docker.internal"

# For remote workers, use the LMStack host IP that the worker can reach
lmstack_host = request.headers.get("host", "localhost:52000")
host_ip = lmstack_host.split(":")[0] if ":" in lmstack_host else lmstack_host

# If host is localhost, try alternatives
# If host is localhost, try to find our external IP
if host_ip in ("localhost", "127.0.0.1"):
forwarded_host = request.headers.get("x-forwarded-host")
if forwarded_host:
host_ip = forwarded_host.split(":")[0]
else:
# Try to get our IP on the same network as the worker
try:
worker_ip = worker.address.split(":")[0]
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((worker_ip, 80))
host_ip = s.getsockname()[0]
s.close()
except OSError as e:
logger.warning(f"Could not determine host IP for worker {worker_ip}: {e}")
host_ip = "host.docker.internal" # Fallback for Docker Desktop
import socket

try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((worker_ip, 80))
host_ip = s.getsockname()[0]
s.close()
except OSError as e:
logger.warning(f"Could not determine host IP for worker {worker_ip}: {e}")
host_ip = "host.docker.internal" # Fallback

return host_ip

Expand Down
Loading
Loading