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 ./ai-server.sh status y el firewall

Open WebUI tarda en cargar

Normal al inicio, espera ~10 seg

GPU no se libera después de stop

Esperar 5-10 seg o verificar con nvidia-smi

Modelo muy lento

Asegurarse de que el modelo está en VRAM con ollama ps

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]" }
}