DeepSeek R1 — Servidor headless + UI local¶
Arquitectura¶
Servidor Ubuntu: solo Ollama con DeepSeek R1 32B. Sin UI, sin Docker.
Tu máquina local: Open WebUI en Docker, conectada al servidor por red.
Control total: scripts para encender/apagar cada parte de forma independiente.
Instalación en el servidor (una sola vez)¶
# 1. Instalar Ollama
curl -fsSL https://ollama.com/install.sh | sh
# 2. Copiar el script al servidor
scp ai-server.sh usuario@IP-SERVIDOR:~/
ssh usuario@IP-SERVIDOR "chmod +x ~/ai-server.sh"
# 3. Descargar el modelo (primera vez, ~19 GB, puede tardar)
ssh usuario@IP-SERVIDOR "ollama pull deepseek-r1:32b"
# 4. Verificar que NVIDIA está bien configurado
ssh usuario@IP-SERVIDOR "nvidia-smi"
Instalación en tu máquina local (una sola vez)¶
# 1. Instalar Docker Desktop (Mac/Windows) o Docker Engine (Linux)
# https://docs.docker.com/get-docker/
# 2. Editar ai-client.sh y cambiar SERVER_IP por la IP de tu servidor
nano ai-client.sh
# 3. Primera vez: bajará la imagen de Open WebUI (~1 GB)
./ai-client.sh start
Uso diario¶
Cuando quieres usar DeepSeek¶
# En el servidor:
./ai-server.sh start
# En tu máquina local:
./ai-client.sh start
# → Abre http://localhost:3000
Cuando necesitas el servidor libre¶
# En tu máquina local (opcional):
./ai-client.sh stop
# En el servidor:
./ai-server.sh stop
# → GPU y RAM completamente liberadas
Ver estado en cualquier momento¶
./ai-server.sh status # en el servidor
./ai-client.sh status # en tu máquina local
Firewall en el servidor (recomendado)¶
Abre el puerto de Ollama solo para tu red local:
# Permitir solo desde tu red local (ejemplo: 192.168.1.0/24)
sudo ufw allow from 192.168.1.0/24 to any port 11434
# Verificar
sudo ufw status
Si usas SSH tunnel (más seguro, sin abrir puertos)¶
En tu máquina local:
ssh -L 11434:localhost:11434 usuario@IP-SERVIDOR -N &
Luego en ai-client.sh cambia:
SERVER_IP="127.0.0.1"
y ya no necesitas abrir el firewall.
Resolución de problemas¶
Problema |
Solución |
|---|---|
«No se puede conectar al servidor» |
Verificar |
Open WebUI tarda en cargar |
Normal al inicio, espera ~10 seg |
GPU no se libera después de |
Esperar 5-10 seg o verificar con |
Modelo muy lento |
Asegurarse de que el modelo está en VRAM con |
Servidor:¶
ai-server.sh
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# ai-server.sh — Control de Ollama/DeepSeek en el servidor
# Uso: ./ai-server.sh [start|stop|restart|status]
# Ejecutar EN EL SERVIDOR Ubuntu
# ─────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${BLUE}[AI]${NC} $1"; }
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[!!]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
gpu_info() {
nvidia-smi --query-gpu=name,memory.used,memory.free,utilization.gpu \
--format=csv,noheader 2>/dev/null \
| awk -F', ' '{printf " GPU: %s | VRAM: %s usada / %s libre | Uso: %s\n",$1,$2,$3,$4}'
}
ram_info() {
free -h | awk '/^Mem/{printf " RAM: %s total | %s usado | %s libre\n",$2,$3,$4}'
}
is_active() {
systemctl is-active --quiet ollama
}
start() {
if is_active; then
warn "Ollama ya está activo."
status; return 0
fi
log "Iniciando Ollama (DeepSeek R1 32B)..."
sudo systemctl start ollama
sleep 3
if is_active; then
ok "Ollama activo en localhost:11434"
echo ""
gpu_info
ram_info
echo ""
ok "Abre el túnel SSH desde tu máquina local y conecta la UI."
else
error "No pudo iniciar. Revisa: sudo journalctl -u ollama -n 30"
fi
}
stop() {
if ! is_active; then
warn "Ollama no está corriendo."
return 0
fi
log "Deteniendo Ollama..."
sudo systemctl stop ollama
sleep 2
if ! is_active; then
ok "Ollama detenido. Recursos liberados:"
echo ""
gpu_info
ram_info
else
error "No se pudo detener. Intenta: sudo systemctl kill ollama"
fi
}
status() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Estado del servidor DeepSeek R1"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if is_active; then
ok "Ollama: ACTIVO"
echo ""
echo " Modelos en memoria:"
ollama ps 2>/dev/null | tail -n +2 \
| awk '{printf " • %s (procesador: %s/%s)\n",$1,$5,$6}' \
|| echo " (ninguno cargado aún — se carga al primer prompt)"
else
warn "Ollama: INACTIVO → GPU y RAM completamente libres"
fi
echo ""
gpu_info
ram_info
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
restart) stop && sleep 2 && start ;;
status) status ;;
*) echo "Uso: $0 [start|stop|restart|status]"; exit 1 ;;
esac
Cliente Ubuntu:¶
ai-server.sh
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# ai-client.sh — Túnel SSH + Open WebUI (Ubuntu local)
# Uso: ./ai-client.sh [start|stop|status]
# Ejecutar EN TU MÁQUINA LOCAL Ubuntu
# ─────────────────────────────────────────────────────────────
# ── CONFIGURACIÓN ────────────────────────────────────────────
SERVER_USER="prodisys1"
SERVER_IP="190.25.173.180" # <-- reemplaza con tu IP completa
SERVER_SSH_PORT="22"
OLLAMA_PORT="11434"
WEBUI_PORT="3000"
CONTAINER_NAME="open-webui"
DOCKER_HOST_IP="172.17.0.1" # IP del host vista desde Docker en Linux
TUNNEL_PIDFILE="$HOME/.ai-tunnel.pid"
# ─────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${BLUE}[UI]${NC} $1"; }
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[!!]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
is_tunnel_alive() {
if [ -f "$TUNNEL_PIDFILE" ]; then
local pid=$(cat "$TUNNEL_PIDFILE")
kill -0 "$pid" 2>/dev/null && return 0
fi
# Verificar también si hay un proceso ssh con el puerto aunque no haya pidfile
pgrep -f "ssh.*${OLLAMA_PORT}:localhost:${OLLAMA_PORT}" > /dev/null 2>&1
}
is_ollama_reachable() {
curl -s --connect-timeout 3 "http://localhost:${OLLAMA_PORT}/api/tags" > /dev/null 2>&1
}
is_webui_running() {
docker ps --filter "name=${CONTAINER_NAME}" --filter "status=running" -q 2>/dev/null | grep -q .
}
start_tunnel() {
if is_tunnel_alive && is_ollama_reachable; then
ok "Túnel SSH ya activo."
return 0
fi
# Matar túneles viejos si los hay
pkill -f "ssh.*${OLLAMA_PORT}:localhost:${OLLAMA_PORT}" 2>/dev/null
sleep 1
log "Abriendo túnel SSH hacia ${SERVER_IP}..."
# 0.0.0.0 para que Docker (172.17.0.1) también pueda alcanzar el puerto
ssh -f -N -p "${SERVER_SSH_PORT}" \
-L "0.0.0.0:${OLLAMA_PORT}:localhost:${OLLAMA_PORT}" \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
"${SERVER_USER}@${SERVER_IP}"
# Guardar PID
pgrep -f "ssh.*${OLLAMA_PORT}:localhost:${OLLAMA_PORT}" > "$TUNNEL_PIDFILE"
sleep 2
if is_ollama_reachable; then
ok "Túnel activo — Ollama accesible en localhost:${OLLAMA_PORT}"
else
error "Túnel abierto pero Ollama no responde."
echo " Asegúrate de que en el servidor hayas ejecutado:"
echo " ./ai-server.sh start"
return 1
fi
}
stop_tunnel() {
pkill -f "ssh.*${OLLAMA_PORT}:localhost:${OLLAMA_PORT}" 2>/dev/null
rm -f "$TUNNEL_PIDFILE"
ok "Túnel SSH cerrado."
}
start_webui() {
if is_webui_running; then
ok "Open WebUI ya está corriendo."
return 0
fi
log "Iniciando Open WebUI..."
# Abrir puerto en firewall para que Docker acceda al túnel
sudo ufw allow from 172.17.0.0/16 to any port "${OLLAMA_PORT}" > /dev/null 2>&1
if docker ps -a --filter "name=${CONTAINER_NAME}" -q 2>/dev/null | grep -q .; then
# Contenedor existe — solo iniciarlo
docker start "${CONTAINER_NAME}" > /dev/null
else
# Primera vez — crear el contenedor
log "Primera vez — descargando imagen Open WebUI (~1 GB)..."
docker run -d \
--name "${CONTAINER_NAME}" \
--restart unless-stopped \
-p "${WEBUI_PORT}:8080" \
-e OLLAMA_BASE_URL="http://${DOCKER_HOST_IP}:${OLLAMA_PORT}" \
-v open-webui:/app/backend/data \
ghcr.io/open-webui/open-webui:main > /dev/null
fi
sleep 6
if is_webui_running; then
ok "Open WebUI activo"
else
error "Open WebUI no pudo iniciar. Revisa: docker logs ${CONTAINER_NAME}"
return 1
fi
}
start() {
echo ""
log "=== Iniciando sistema AI ==="
echo ""
start_tunnel || return 1
start_webui || return 1
echo ""
echo " ┌─────────────────────────────────────┐"
echo " │ Abre en tu navegador: │"
echo " │ http://localhost:${WEBUI_PORT} │"
echo " └─────────────────────────────────────┘"
echo ""
}
stop() {
echo ""
log "=== Deteniendo sistema AI ==="
echo ""
if is_webui_running; then
docker stop "${CONTAINER_NAME}" > /dev/null
ok "Open WebUI detenido."
else
warn "Open WebUI no estaba corriendo."
fi
stop_tunnel
echo ""
warn "Recuerda detener Ollama en el servidor si ya no lo necesitas:"
echo " ssh ${SERVER_USER}@${SERVER_IP} './ai-server.sh stop'"
echo ""
}
status() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Estado del cliente AI"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -n " Túnel SSH: "
if is_tunnel_alive; then echo -e "${GREEN}ACTIVO${NC}"
else echo -e "${YELLOW}INACTIVO${NC}"; fi
echo -n " Ollama: "
if is_ollama_reachable; then
echo -e "${GREEN}ACCESIBLE${NC} en localhost:${OLLAMA_PORT}"
echo " Modelos disponibles:"
curl -s "http://localhost:${OLLAMA_PORT}/api/tags" 2>/dev/null \
| grep -o '"name":"[^"]*"' | cut -d'"' -f4 \
| while read -r m; do echo " • $m"; done
else
echo -e "${YELLOW}NO DISPONIBLE${NC}"
fi
echo -n " Open WebUI: "
if is_webui_running; then
echo -e "${GREEN}ACTIVO${NC} → http://localhost:${WEBUI_PORT}"
else
echo -e "${YELLOW}INACTIVO${NC}"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
}
case "${1:-status}" in
start) start ;;
stop) stop ;;
status) status ;;
*) echo "Uso: $0 [start|stop|status]"; exit 1 ;;
esac
Cliente Windows:¶
ai-server.sh
# ─────────────────────────────────────────────────────────────
# ai-client.ps1 — Túnel SSH + Open WebUI (Windows local)
# Uso: .\ai-client.ps1 [start|stop|status]
# Ejecutar EN TU MÁQUINA Windows (PowerShell)
#
# Requisitos:
# - OpenSSH instalado (viene con Windows 10/11)
# - Docker Desktop instalado y corriendo
# - Llave SSH en %USERPROFILE%\.ssh\
# ─────────────────────────────────────────────────────────────
# ── CONFIGURACIÓN ────────────────────────────────────────────
$SERVER_USER = "prodisys1"
$SERVER_IP = "TU_IP_AQUI" # <-- reemplaza con tu IP completa
$SERVER_PORT = "22"
$OLLAMA_PORT = "11434"
$WEBUI_PORT = "3000"
$CONTAINER_NAME = "open-webui"
# En Windows Docker Desktop usa host.docker.internal en lugar de 172.17.0.1
$DOCKER_HOST = "host.docker.internal"
$TUNNEL_PIDFILE = "$env:USERPROFILE\.ai-tunnel.pid"
# ─────────────────────────────────────────────────────────────
param([string]$Action = "status")
function Write-Log { param($m) Write-Host "[AI] $m" -ForegroundColor Cyan }
function Write-Ok { param($m) Write-Host "[OK] $m" -ForegroundColor Green }
function Write-Warn { param($m) Write-Host "[!!] $m" -ForegroundColor Yellow }
function Write-Err { param($m) Write-Host "[ERROR] $m" -ForegroundColor Red }
function Is-TunnelAlive {
$procs = Get-Process -Name "ssh" -ErrorAction SilentlyContinue
if ($procs) { return $true }
if (Test-Path $TUNNEL_PIDFILE) {
$pid = Get-Content $TUNNEL_PIDFILE
return (Get-Process -Id $pid -ErrorAction SilentlyContinue) -ne $null
}
return $false
}
function Is-OllamaReachable {
try {
$r = Invoke-WebRequest "http://localhost:${OLLAMA_PORT}/api/tags" `
-TimeoutSec 3 -UseBasicParsing
return $r.StatusCode -eq 200
} catch { return $false }
}
function Is-WebUIRunning {
$r = docker ps --filter "name=${CONTAINER_NAME}" --filter "status=running" -q 2>$null
return ($r -ne $null -and $r -ne "")
}
function Start-Tunnel {
if ((Is-TunnelAlive) -and (Is-OllamaReachable)) {
Write-Ok "Túnel SSH ya activo."
return $true
}
# Cerrar túneles viejos
Get-Process -Name "ssh" -ErrorAction SilentlyContinue | Stop-Process -Force
Write-Log "Abriendo túnel SSH hacia ${SERVER_IP}..."
# En Windows el túnel escucha en localhost (Docker Desktop lo resuelve via host.docker.internal)
$proc = Start-Process -FilePath "ssh" `
-ArgumentList "-N -p $SERVER_PORT -L ${OLLAMA_PORT}:localhost:${OLLAMA_PORT} -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 ${SERVER_USER}@${SERVER_IP}" `
-PassThru -WindowStyle Hidden
$proc.Id | Out-File $TUNNEL_PIDFILE
Start-Sleep 3
if (Is-OllamaReachable) {
Write-Ok "Túnel activo — Ollama accesible en localhost:${OLLAMA_PORT}"
return $true
} else {
Write-Err "Túnel abierto pero Ollama no responde."
Write-Host " Ejecuta en el servidor: ./ai-server.sh start"
return $false
}
}
function Stop-Tunnel {
if (Test-Path $TUNNEL_PIDFILE) {
$pid = Get-Content $TUNNEL_PIDFILE
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
Remove-Item $TUNNEL_PIDFILE -Force -ErrorAction SilentlyContinue
}
Get-Process -Name "ssh" -ErrorAction SilentlyContinue | Stop-Process -Force
Write-Ok "Túnel SSH cerrado."
}
function Start-WebUI {
if (Is-WebUIRunning) {
Write-Ok "Open WebUI ya está corriendo."
return $true
}
Write-Log "Iniciando Open WebUI..."
$exists = docker ps -a --filter "name=${CONTAINER_NAME}" -q 2>$null
if ($exists) {
docker start $CONTAINER_NAME | Out-Null
} else {
Write-Log "Primera vez — descargando imagen Open WebUI (~1 GB)..."
# En Windows Docker Desktop, host.docker.internal resuelve al host correctamente
docker run -d `
--name $CONTAINER_NAME `
--restart unless-stopped `
-p "${WEBUI_PORT}:8080" `
-e OLLAMA_BASE_URL="http://${DOCKER_HOST}:${OLLAMA_PORT}" `
-v open-webui:/app/backend/data `
ghcr.io/open-webui/open-webui:main | Out-Null
}
Start-Sleep 6
if (Is-WebUIRunning) {
Write-Ok "Open WebUI activo"
return $true
} else {
Write-Err "Open WebUI no pudo iniciar. Revisa: docker logs $CONTAINER_NAME"
return $false
}
}
function Start-All {
Write-Host ""
Write-Log "=== Iniciando sistema AI ==="
Write-Host ""
if (-not (Start-Tunnel)) { return }
if (-not (Start-WebUI)) { return }
Write-Host ""
Write-Host " ┌─────────────────────────────────────┐"
Write-Host " │ Abre en tu navegador: │"
Write-Host " │ http://localhost:$WEBUI_PORT │"
Write-Host " └─────────────────────────────────────┘"
Write-Host ""
Start-Process "http://localhost:$WEBUI_PORT"
}
function Stop-All {
Write-Host ""
Write-Log "=== Deteniendo sistema AI ==="
Write-Host ""
if (Is-WebUIRunning) {
docker stop $CONTAINER_NAME | Out-Null
Write-Ok "Open WebUI detenido."
} else {
Write-Warn "Open WebUI no estaba corriendo."
}
Stop-Tunnel
Write-Host ""
Write-Warn "Recuerda detener Ollama en el servidor si ya no lo necesitas:"
Write-Host " ssh ${SERVER_USER}@${SERVER_IP} './ai-server.sh stop'"
Write-Host ""
}
function Show-Status {
Write-Host ""
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Write-Host " Estado del cliente AI"
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
$tunnelStatus = if (Is-TunnelAlive) { "ACTIVO" } else { "INACTIVO" }
Write-Host " Túnel SSH: $tunnelStatus"
if (Is-OllamaReachable) {
Write-Host " Ollama: ACCESIBLE en localhost:${OLLAMA_PORT}"
try {
$resp = Invoke-WebRequest "http://localhost:${OLLAMA_PORT}/api/tags" -UseBasicParsing
$models = [regex]::Matches($resp.Content, '"name":"([^"]+)"') `
| ForEach-Object { $_.Groups[1].Value }
$models | ForEach-Object { Write-Host " • $_" }
} catch {}
} else {
Write-Host " Ollama: NO DISPONIBLE"
}
$webuiStatus = if (Is-WebUIRunning) { "ACTIVO → http://localhost:${WEBUI_PORT}" } else { "INACTIVO" }
Write-Host " Open WebUI: $webuiStatus"
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Write-Host ""
}
switch ($Action.ToLower()) {
"start" { Start-All }
"stop" { Stop-All }
"status" { Show-Status }
default { Write-Host "Uso: .\ai-client.ps1 [start|stop|status]" }
}