Cloud GPU Extension — Scaleway L40S¶
Seamless on-demand GPU (FLUX.1-dev, video, multi-user) via a Scaleway L40S instance. The instance auto-starts on first request and shuts down after 45 min idle. Everything personal stays on the NUC.
Pricing (current as of May 2026)¶
| Resource | Rate | Notes |
|---|---|---|
| L40S-1-48G compute | €1.40/hour | PAR-2, billed per minute |
| Block volume 200 GB | €16/month | SSD, keeps models across restarts |
| Flexible IP | €0.004/hour | Static IP for WireGuard endpoint |
| Snapshots | €0.000044/GB/h | Only needed for image backups |
Realistic monthly cost¶
| Usage pattern | Compute | Storage | Total |
|---|---|---|---|
| Weekend sessions (8 h/week) | €45 | €16 | ~€61/month |
| Daily 1–2 h | €63–126 | €16 | ~€79–142/month |
| Heavy (4 h/day) | €168 | €16 | ~€184/month |
| Always-on (don't) | €1,008 | €16 | €1,024/month |
The on-demand proxy below makes "daily 1–2 h" the natural default — you just click generate, it starts automatically.
Architecture¶
NUC (home, always-on) Scaleway PAR-2 (on demand)
───────────────────────────── ───────────────────────────────
LobeChat ──▶ gpu-proxy:8190 ─── wg1 ──▶ ComfyUI :8188
comfyui-mcp ──▶ (Docker) Ollama :11434 (optional)
│
├─ start/stop via Scaleway API
└─ idle watchdog (45 min → stop)
gpu-proxy is a small Docker service that:
1. Forwards requests to the GPU instance
2. Auto-starts the Scaleway instance if it's stopped (cold-start ~90 s)
3. Shuts it down after 45 min with no traffic
Prerequisites¶
# Scaleway CLI
curl -s https://raw.githubusercontent.com/scaleway/scaleway-cli/master/scripts/get.sh | sh
scw init # enter API key + project ID
Step 1 — Persistent block volume (models live here)¶
# Create 200 GB SSD volume in PAR-2
scw block volume create name=nuclide-gpu-models size=200GB zone=fr-par-2
# Note the volume ID: vol-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Models are kept on this volume. The compute instance can be deleted and recreated freely.
Step 2 — Create the L40S instance with cloud-init¶
Save as /opt/stacks/scripts/gpu-cloud-init.yaml:
#cloud-config
package_update: true
packages:
- wireguard-tools
- docker.io
- docker-compose-v2
- nvidia-driver-545
- nvidia-container-toolkit
write_files:
- path: /etc/wireguard/wg0.conf
permissions: '0600'
content: |
[Interface]
Address = 10.200.0.1/24
ListenPort = 51820
PrivateKey = SCALEWAY_WG_PRIVKEY
[Peer]
# NUC
PublicKey = NUC_WG_PUBKEY
AllowedIPs = 10.200.0.2/32
PersistentKeepalive = 25
- path: /opt/gpu/docker-compose.yml
content: |
services:
comfyui:
image: yanwk/comfyui-boot:cu124
container_name: comfyui
ports:
- "10.200.0.1:8188:8188"
volumes:
- /mnt/models:/root/ComfyUI/models
- comfyui_config:/root/ComfyUI
environment:
- CLI_ARGS=--listen 0.0.0.0
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: ollama
ports:
- "10.200.0.1:11434:11434"
volumes:
- /mnt/models/ollama:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
volumes:
comfyui_config:
runcmd:
# Mount the models volume (will be /dev/sdb or similar)
- mkdir -p /mnt/models
- |
DISK=$(lsblk -ndo NAME,SIZE | awk '$2=="200G"{print "/dev/"$1}' | head -1)
if [ -n "$DISK" ]; then
blkid "$DISK" || mkfs.ext4 "$DISK"
echo "$DISK /mnt/models ext4 defaults 0 2" >> /etc/fstab
mount "$DISK" /mnt/models
fi
- mkdir -p /mnt/models/ollama
# Enable WireGuard
- systemctl enable --now wg-quick@wg0
# Configure nvidia-container-toolkit
- nvidia-ctk runtime configure --runtime=docker
- systemctl restart docker
# Start GPU services
- cd /opt/gpu && docker compose up -d
Before using this file, replace:
- SCALEWAY_WG_PRIVKEY → output of wg genkey (run on Scaleway instance side first)
- NUC_WG_PUBKEY → output of cat /etc/wireguard/nuc_wg_pub (see Step 3)
Launch the instance:
# Generate WireGuard keys first (do this on the NUC)
wg genkey | tee /etc/wireguard/scaleway_wg_priv | wg pubkey > /etc/wireguard/scaleway_wg_pub
wg genkey | tee /etc/wireguard/nuc_wg_priv | wg pubkey > /etc/wireguard/nuc_wg_pub
# Fill in cloud-init.yaml with the keys, then:
INSTANCE_ID=$(scw instance server create \
type=L40S-1-48G \
image=ubuntu_jammy_gpu \
zone=fr-par-2 \
name=nuclide-gpu \
cloud-init=@/opt/stacks/scripts/gpu-cloud-init.yaml \
output=json | jq -r '.id')
echo "Instance ID: $INSTANCE_ID"
# Attach the models volume
scw instance server attach-volume \
server-id=$INSTANCE_ID \
volume-id=vol-xxxxxxxx \
zone=fr-par-2
# Allocate a Flexible IP (static IP that survives instance restarts)
FLEXIP_ID=$(scw instance ip create zone=fr-par-2 output=json | jq -r '.id')
scw instance server attach-flexible-ip \
server-id=$INSTANCE_ID \
ip-id=$FLEXIP_ID \
zone=fr-par-2
FLEXIP=$(scw instance ip get $FLEXIP_ID zone=fr-par-2 output=json | jq -r '.address')
echo "Scaleway public IP: $FLEXIP"
Step 3 — WireGuard on the NUC¶
# /etc/wireguard/wg1.conf (separate from any existing VPN tunnel)
cat > /etc/wireguard/wg1.conf << EOF
[Interface]
Address = 10.200.0.2/24
PrivateKey = $(cat /etc/wireguard/nuc_wg_priv)
[Peer]
# Scaleway GPU
PublicKey = $(cat /etc/wireguard/scaleway_wg_pub)
Endpoint = ${FLEXIP}:51820
AllowedIPs = 10.200.0.1/32
PersistentKeepalive = 25
EOF
chmod 600 /etc/wireguard/wg1.conf
systemctl enable --now wg-quick@wg1
Test when the instance is running:
Step 4 — gpu-proxy (seamless on-demand startup)¶
This Docker service runs on the NUC. It proxies to the GPU instance and auto-starts/stops it.
ai/gpu-proxy/docker-compose.yml¶
services:
gpu-proxy:
build: .
container_name: gpu-proxy
environment:
- SCW_SECRET_KEY=${SCW_SECRET_KEY}
- SCW_PROJECT_ID=${SCW_PROJECT_ID}
- SCW_INSTANCE_ID=${SCW_INSTANCE_ID}
- SCW_ZONE=fr-par-2
- GPU_HOST=10.200.0.1
- COMFYUI_PORT=8188
- OLLAMA_PORT=11434
- IDLE_TIMEOUT=2700 # 45 min
ports:
- "8190:8190" # ComfyUI proxy
- "8191:8191" # Ollama proxy
restart: unless-stopped
networks:
- shared_backend
networks:
shared_backend:
external: true
ai/gpu-proxy/proxy.py¶
"""
On-demand GPU proxy. Auto-starts the Scaleway instance on first request,
shuts it down after IDLE_TIMEOUT seconds of inactivity.
"""
import asyncio, os, time, httpx, subprocess
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, JSONResponse
app = FastAPI()
SCW_KEY = os.environ["SCW_SECRET_KEY"]
SCW_PROJECT = os.environ["SCW_PROJECT_ID"]
INSTANCE_ID = os.environ["SCW_INSTANCE_ID"]
ZONE = os.environ.get("SCW_ZONE", "fr-par-2")
GPU_HOST = os.environ.get("GPU_HOST", "10.200.0.1")
COMFYUI_PORT = int(os.environ.get("COMFYUI_PORT", 8188))
OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", 11434))
IDLE_TIMEOUT = int(os.environ.get("IDLE_TIMEOUT", 2700))
SCW_API = f"https://api.scaleway.com/instance/v1/zones/{ZONE}"
HEADERS = {"X-Auth-Token": SCW_KEY, "Content-Type": "application/json"}
_state = {"last_activity": 0.0, "starting": False, "up": False}
_lock = asyncio.Lock()
async def _scw(method: str, path: str, **kwargs):
async with httpx.AsyncClient() as c:
r = await c.request(method, f"{SCW_API}{path}", headers=HEADERS, **kwargs)
r.raise_for_status()
return r.json()
async def _instance_state() -> str:
d = await _scw("GET", f"/servers/{INSTANCE_ID}")
return d["server"]["state"] # running | stopped | stopping | starting
async def _start_instance():
await _scw("POST", f"/servers/{INSTANCE_ID}/action", json={"action": "poweron"})
async def _stop_instance():
await _scw("POST", f"/servers/{INSTANCE_ID}/action", json={"action": "poweroff"})
async def _wait_ready(host: str, port: int, timeout=180) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
async with httpx.AsyncClient(timeout=3) as c:
await c.get(f"http://{host}:{port}/")
return True
except Exception:
await asyncio.sleep(5)
return False
async def ensure_up(port: int) -> bool:
async with _lock:
if _state["up"]:
_state["last_activity"] = time.time()
return True
if _state["starting"]:
return False # caller will retry
_state["starting"] = True
try:
state = await _instance_state()
if state != "running":
print(f"[gpu-proxy] instance {state} → starting")
await _start_instance()
# wait for API to report running
for _ in range(60):
await asyncio.sleep(5)
if await _instance_state() == "running":
break
# wait for service to respond
if await _wait_ready(GPU_HOST, port):
async with _lock:
_state["up"] = True
_state["last_activity"] = time.time()
print("[gpu-proxy] GPU instance ready")
return True
return False
finally:
async with _lock:
_state["starting"] = False
async def idle_watchdog():
while True:
await asyncio.sleep(60)
async with _lock:
if not _state["up"]:
continue
idle = time.time() - _state["last_activity"]
if idle > IDLE_TIMEOUT:
print(f"[gpu-proxy] idle {idle:.0f}s → stopping instance")
try:
await _stop_instance()
async with _lock:
_state["up"] = False
_state["last_activity"] = 0.0
except Exception as e:
print(f"[gpu-proxy] stop error: {e}")
@app.on_event("startup")
async def startup():
asyncio.create_task(idle_watchdog())
# Probe: is the instance already running from a previous session?
try:
if await _instance_state() == "running":
if await _wait_ready(GPU_HOST, COMFYUI_PORT, timeout=10):
async with _lock:
_state["up"] = True
_state["last_activity"] = time.time()
print("[gpu-proxy] GPU already up on startup")
except Exception:
pass
async def _proxy(request: Request, host: str, port: int):
_state["last_activity"] = time.time()
ready = await ensure_up(port)
if not ready:
# Starting up — keep trying for up to 3 min
for _ in range(36):
await asyncio.sleep(5)
if _state["up"]:
break
else:
return JSONResponse({"error": "GPU instance failed to start"}, 503)
url = f"http://{host}:{port}{request.url.path}"
if request.url.query:
url += f"?{request.url.query}"
body = await request.body()
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=10))
req = client.build_request(request.method, url,
headers={k: v for k, v in request.headers.items()
if k.lower() not in {"host", "content-length"}},
content=body)
try:
resp = await client.send(req, stream=True)
except httpx.ConnectError:
await client.aclose()
return JSONResponse({"error": "GPU not reachable"}, 502)
async def stream():
async for chunk in resp.aiter_raw():
yield chunk
await resp.aclose()
await client.aclose()
return StreamingResponse(stream(), status_code=resp.status_code,
headers=dict(resp.headers),
media_type=resp.headers.get("content-type"))
@app.api_route("/status", methods=["GET"])
async def status():
try:
scw_state = await _instance_state()
except Exception as e:
scw_state = f"error: {e}"
return {"instance": scw_state, "proxy_up": _state["up"],
"idle_s": int(time.time() - _state["last_activity"]) if _state["up"] else None}
@app.api_route("/{path:path}", methods=["GET","POST","PUT","DELETE","OPTIONS","PATCH"])
async def comfyui_proxy(request: Request, path: str):
return await _proxy(request, GPU_HOST, COMFYUI_PORT)
ai/gpu-proxy/Dockerfile¶
FROM python:3.12-slim
RUN pip install fastapi uvicorn httpx
COPY proxy.py /app/proxy.py
WORKDIR /app
EXPOSE 8190 8191
CMD ["uvicorn", "proxy:app", "--host", "0.0.0.0", "--port", "8190"]
Step 5 — Wire up NUC services¶
Add to ai/.env:
SCW_SECRET_KEY=<your-scaleway-api-key>
SCW_PROJECT_ID=<your-project-id>
SCW_INSTANCE_ID=<instance-id-from-step-2>
In ai/lobehub.yml:
In ai/mcp-gateway/server.py (SERVERS dict):
For Immich ML (optional), add to immich/docker-compose.yml:
Step 6 — Deploy¶
# Deploy the proxy
cd /opt/stacks/ai/gpu-proxy
docker compose up -d
# Restart LobeChat and MCP gateway to pick up new env
cd /opt/stacks/ai
docker compose -f lobehub.yml up -d --force-recreate lobe
cd mcp-gateway && docker compose up -d --force-recreate mcp-gateway
What the user experience looks like¶
- Open LobeChat, generate an image → first request triggers auto-start
- "Starting GPU…" — ComfyUI returns a brief wait (up to 90 s cold start)
- Images generate normally; the proxy keeps the instance alive
- After 45 min with no requests → instance stops automatically
/statusendpoint on port 8190:{"instance":"stopped","proxy_up":false}
Quick-start / stop scripts¶
# /usr/local/bin/nuclide-gpu (NUC helper)
#!/bin/bash
ZONE=fr-par-2
ID=$(docker exec gpu-proxy env | grep SCW_INSTANCE_ID | cut -d= -f2)
case "$1" in
start) scw instance server action action=poweron server-id=$ID zone=$ZONE ;;
stop) scw instance server action action=poweroff server-id=$ID zone=$ZONE ;;
status) curl -s http://localhost:8190/status | python3 -m json.tool ;;
*) echo "Usage: nuclide-gpu start|stop|status" ;;
esac
Scaleway firewall (security groups)¶
On the Scaleway instance, only expose WireGuard. All services bind to 10.200.0.1 (WireGuard IP):
ufw default deny incoming
ufw allow 51820/udp # WireGuard from anywhere
ufw allow from 10.200.0.0/24 # full access over tunnel
ufw enable
Model storage layout (on the 200 GB volume)¶
/mnt/models/
├── checkpoints/ # FLUX.1-dev, SDXL, etc.
├── vae/
├── loras/
├── controlnet/
├── ollama/ # Ollama model blobs
└── upscale_models/
FLUX.1-dev (FP16): ~24 GB
FLUX.1-schnell (NF4): ~8 GB
Ollama llama3:70b (Q4): ~40 GB — fits alongside FLUX on 200 GB with room for LoRAs.
To pre-download models after first boot: