LM Studio na Internet

Saudações.

Nesse tutorial vou ensinar como colocar o um sistema que roda em seu servidor caseiro na Internet por meio de túnel particular entre ele e uma VPS.

Vou demonstrar meu caso particular, um Mac Mini rodando LM Studio com IA local.

1 – Meu ambiente

O Mac Mini (Apple M4 Pro, 24G de RAM compartilhada com GPU/NPU), está atrás no meu escritório atras de NAT (roteador Mikrotik hAP AX2, 2 links: fibra GPON e StarLink).

O LM Studio abre por padrão a porta 1234/TCP (HTTP) em localhost, o suficiente para que qualquer aplicativo rodando no mesmo Mac tenha acesso à API de inferência, usarei o modelo google/gemma-4-e4b para os testes.

1.1 – Abrir servidor HTTP do LM Studio

Precisamos ativar o servidor do LM Studio para que ele abra a porta 1234/TCP para escutar as chamadas HTTP.

Alem de ativar, é necessário duas operações críticas:

  • Crítico – Colocar chave de API, por padrão o LM Studio não exige chave e isso pode resultar em abusos por qualquer cliente que encontre sua URL;
  • O LM Studio deve iniciar automaticamente durante o boot do Mac, caso ele seja reiniciado;

Criando chave de API: Ative o “Require Authentication” e clique em “Manage Tokens“:

Criar chave de API: Na tela de gestão de tokens, clique em “Create new token“:

Coloque o nome da chave, bloqueio o acesso às tools/mcp do LM Studio:

Copie a chave da API:

Copie a chave: sk-lm-M6BHzAzQ:IkhpDtr6dZdmk8Cop1bV

Pronto, chave disponível para uso:

1.2 – Testando API do LM Studio

Agora vamos conferir o funcionamento da API localmente antes de continuar para a publicação remota.

Bash
# Chave de API
    LM_API_TOKEN="sk-lm-M6BHzAzQ:IkhpDtr6dZdmk8Cop1bV";

# Listar modelos disponiveis
    curl \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        -X GET \
        "http://127.0.0.1:1234/api/v1/models";

# Listar apenas nome dos modelos
    curl \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        -X GET \
        "http://127.0.0.1:1234/api/v1/models" | jq '.models.[].key';

Testar inferência com o modelo google/gemma-4-e4b:

Bash
# Chave de API
    LM_API_TOKEN="sk-lm-M6BHzAzQ:IkhpDtr6dZdmk8Cop1bV";

# Enviar requisicao ao modelo
    curl http://localhost:1234/v1/responses \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        -d '{
            "model": "google/gemma-4-e4b",
            "input": "Why sky is blue? Short answer."
        }';
        # Resposta:
        # {
        #   "id": "resp_04e9077a14631e04e9bd90822e30f578fd8a3666a9032bf3",
        #   ...
        #   "output": [
        #     {
        #       "id": "rs_li4z968kg1jwz0vf9kbfvl",
        #       "type": "reasoning",
        #       "status": "completed",
        #       "summary": [],
        #       "content": [
        #         {
        #           "type": "reasoning_text",
        #           "text": "(texto do pensamento aqui, muito grande)"
        #         }
        #       ]
        #     },
        #     {
        #       "id": "msg_2v0dqupfuqb0ookgv5j97r",
        #       "type": "message",
        #       "role": "assistant",
        #       "status": "completed",
        #       "content": [
        #         {
        #           "type": "output_text",
        #           "text": "... As sunlight enters our atmosphere ...",
        #           "annotations": [],
        #           "logprobs": []
        #         }
        #       ]
        #     }
        #   ],
        #   ...
        # }

Com o LM Studio pronto e escutando na loopback, vamos continuar.

1 – Túnel reverso

Para que nosso Mac Mini na LAN privada atras de NAT seja alcançado pela Internet, o primeiro passo é criar um software que rode no Mac e se conecte ao servidor, criando um túnel que possa trafegar dados entre o servidor (Linux na VPS/VM) e o cliente (Mac Mini).

Para isso podemos usar alguns sistemas:

  • FRP – Fast Reverse Proxy;
  • Rathole;
  • Chisel;
  • SSH;
  • Pangolin;
  • Bore;
  • Cloudflare Tunnel;
  • Tailscale Funnel;

Cada solução acima requer um estudo para entender o paradigma que ela opera.

O mais simples e rápido é o SSH, ele permite rodar o cliente SSH no Mac conectando-se ao servidor SSH, ordenando ao servidor a abertura de uma porta (porta no servidor) que conduz toda conexão que chega até ela até um IP e porta no lado cliente, esse modo “Proxy TCP” do SSH permite publicar instantaneamente qualquer serviço.

Vou usar o FRP nesse tutorial.

1.1 – Usando FRP

O frp (Fast Reverse Proxy) tem duas peças:

  • O frps é o servidor, na ponta com IP público;
  • O frpc é o cliente, na ponta presa atrás do NAT. Ele inicia a conexão do Mac até o servidor e o servidor entra por esse túnel para alcançar o cliente.

Diagrama base:

Cliente se conecta na porta especifica do software no lado do servidor (7701) que fará o gerenciamento do túnel, abrindo uma porta (7700) para conduzir a entrada até o cliente por dentro do túnel.

As portas envolvidas no lado servidor (Linux, Docker) são:

  • 7700: vhostHTTPPort, ao ser acessada a conexão entra no servidor e sai no cliente;
  • 7701: bindPort do frps, publicada na Internet para o frpc montar o tunel;
  • 7500: dashboard do frps;

Configuração do nosso container no lado servidor, o container “frp-lmstudio“:

Bash
# Nome do container
    NAME="frp-lmstudio";
    
# URL para acesso global
    # Crie o nome no DNS apontando para o IP publico do servidor
    SERVER_FQDN="lmstudio.dominio.com";
    
# Imagem do container
    IMAGE="fatedier/frps:v0.71.1";
    
# Segredos
    # - Gere com o comando: openssl rand -hex 32
    FRP_TOKEN="a34923f8aff02ca4d0b4b79148352f69a73743006c37a753704bff87d444f9ae";

    # - Gere com o comando: openssl rand -hex 16
    DASH_PASS="f7f2606c6599d25d828c1949e5344838";


# Volume
    # Pasta de configuracao para ser o volume do container
    DATADIR="/storage/$NAME";

    # Criar diretorio no host, somente o root le esse arquivo
    mkdir -p  $DATADIR;
    touch     $DATADIR/frps.toml;
    chmod 600 $DATADIR/frps.toml;


# Configuracao do servidor frp
    (
        # escuta clientes frpc
        echo 'bindAddr = "0.0.0.0"';
        echo 'bindPort = 7701';
        echo;
        # por de entrada que sera entregue ao frpc via tunel
        echo 'vhostHTTPPort = 7700';
        echo 'vhostHTTPTimeout = 600';
        echo;
        # controle de acesso/autenticacao
        echo 'auth.method = "token"';
        echo "auth.token = \"$FRP_TOKEN\"";
        echo 'transport.tls.force = true';
        echo;
        # servidor de controle para manutencao e monitoramento
        echo 'webServer.addr = "0.0.0.0"';
        echo 'webServer.port = 7500';
        echo 'webServer.user = "admin"';
        echo "webServer.password = \"$DASH_PASS\"";
        echo;
        # log de atividades
        echo 'log.to = "console"';
        echo 'log.level = "info"';
        echo;
    ) > $DATADIR/frps.toml;


# Obter imagem
    docker image pull $IMAGE;


# Renovar/criar/rodar
    docker container rm -f frp-lmstudio 2>/dev/null;
    docker container run \
        --name                 frp-lmstudio \
        --hostname             frp-lmstudio.intranet.br \
        --restart              unless-stopped \
        --network              network_public \
        --detach  \
        --read-only  \
        \
        --publish 7701:7701/tcp \
        --publish 127.0.0.1:7500:7500/tcp \
        \
        --volume $DATADIR/frps.toml:/etc/frp/frps.toml:ro \
        \
        --label "traefik.enable=true" \
        \
        --label "traefik.http.routers.lmstudio.rule=Host(\`${SERVER_FQDN}\`)" \
        --label "traefik.http.routers.lmstudio.entrypoints=websecure" \
        --label "traefik.http.routers.lmstudio.service=lmstudio" \
        --label "traefik.http.routers.lmstudio.tls=true" \
        --label "traefik.http.routers.lmstudio.tls.certresolver=letsencrypt" \
        \
        --label "traefik.http.services.lmstudio.loadbalancer.server.port=7700" \
        \
        $IMAGE \
            -c /etc/frp/frps.toml;

# Acesso:
    echo;
    echo "URL de acesso publico: https://$SERVER_FQDN";
    echo;
    echo "Endereco para configuracao do FRP Cliente:";
    echo "    Endereco do servidor: $SERVER_FQDN";
    echo "    Porta TCP frps......: 7001";
    echo;

Agora vamos configurar o cliente frpc

Configurações importantes no cliente:

  • Endereço lmstudio.dominio.com: serverAddr, servidor (DNS ou IP);
  • Porta 7701: serverPort porta que o servidor abriu para atender clientes frpc;
  • localIP e localPort aponta para a loopback (127.0.0.1) e porta do LM STudio (1234);
  • loginFailExit: com false o cliente insiste em reconectar em vez de morrer na primeira falha;
  • customDomains: o domínio que o frps usa para achar esse túnel, precisa ser igual ao da label do Traefik;
  • hostHeaderRewrite: faz o LM Studio receber o Host como se o acesso fosse local.

No Mac OS vamos precisar do Brew (https://brew.sh/), com o comando brew instalaremos o frpc:

Bash
# Atualizar primeiro, sempre
brew  update;
brew  upgrade;

# Instalar FRP Cliente
brew  install  frpc;

Agora vamos criar o arquivo de configuração do frpc:

Bash
# Segredo, mesmo TOKEN do servidor
    FRP_TOKEN="a34923f8aff02ca4d0b4b79148352f69a73743006c37a753704bff87d444f9ae";

# Endereco do servidor
    SERVER_FQDN="lmstudio.dominio.com";

# Diretorio de configuracao
    mkdir  -p   ~/.config/frpc;

    # Somente o seu usuario le esse arquivo
    touch       ~/.config/frpc/frpc.toml
    chmod  600  ~/.config/frpc/frpc.toml;


# Configuracao do cliente FRP    
    (
        echo;
        echo "serverAddr = \"$SERVER_FQDN\"";
        echo 'serverPort = 7701';
        echo 'loginFailExit = false';
        echo;
        echo 'auth.method = "token"';
        echo "auth.token = \"$FRP_TOKEN\"";
        echo 'transport.tls.enable = true';
        echo;
        echo 'log.to = "console"';
        echo 'log.level = "info"';
        echo;
        echo '[[proxies]]';
        echo 'name = "lmstudio"';
        echo 'type = "http"';
        echo 'localIP = "127.0.0.1"';
        echo 'localPort = 1234';
        echo "customDomains = [\"$SERVER_FQDN\"]";
        echo 'hostHeaderRewrite = "127.0.0.1"';
        echo;
    ) > ~/.config/frpc/frpc.toml;

# Visualizar
    cat  ~/.config/frpc/frpc.toml;

Testar se está tudo correto:

Bash
# Validar sintaxe
frpc  verify  -c  ~/.config/frpc/frpc.toml;
    # frpc:
    #   the configuration file
    #   /Users/patrickbrandao/.config/frpc/frpc.toml syntax is ok

Rodar frpc para publicar o LM Studio via servidor:

Bash
# Conectar (CTRL+C para sair)
frpc  -c  ~/.config/frpc/frpc.toml;
    # [I] [client/service.go:355] login to server success, get run id [...]
    # [I] [proxy/proxy_manager.go:183] proxy added: [lmstudio]
    # [I] [client/control.go:174] [lmstudio] start proxy success

Agora vamos testar o LM Studio pela URL pública:

Bash
# Chave de API
    LM_API_TOKEN="sk-lm-M6BHzAzQ:IkhpDtr6dZdmk8Cop1bV";
        
# Endereco do servidor
    SERVER_FQDN="lmstudio.dominio.com";

# Listar modelos disponiveis
    curl \
        -X GET \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        "https://$SERVER_FQDN/api/v1/models";

# Listar apenas nome dos modelos
    curl \
        -X GET \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        "https://$SERVER_FQDN/api/v1/models" | jq '.models.[].key';
        # Resposta (modelos):
        #    "google/gemma-4-e4b"
        #    "qwen/qwen3.5-9b"
        #    "laya-multilingual"
        #    "mys/laya-multilingual"
        #    "fr0stbit3/laya-multilingual"
        #    "qwen/qwen3.8-27b"
        #    "vibethinker-3b"
        #    "google/gemma-4-26b-a4b"
        #    "google/gemma-4-12b"
        #    "stable-diffusion-3.5-large"
        #    "nvidia/nemotron-3-nano-4b"
        #    "text-embedding-nomic-embed-text-v1.5"
        #    "mistralai/ministral-3-3b"
        #    "mistralai/ministral-3-14b-reasoning"
        #    "microsoft_fara-7b"
        #    "qwen/qwen3-vl-8b"
        #    "qwen/qwen3-vl-4b"
        #    "codegemma-1.1-7b-it"
        #    "openai/gpt-oss-20b"

# Chat com streaming
    curl \
        -X POST \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        "https://$SERVER_FQDN/v1/chat/completions" \
        -d '{
            "model": "google/gemma-4-e4b",
            "stream": true,
            "messages": [ { "role": "user", "content": "Quem foi Patolino?" } ]
        }';
        # data: {"choices":[{"delta":{"content":"Pat"}}], ...}
        # data: {"choices":[{"delta":{"content":"olino"}}], ...}
        # data: [DONE]

# Testar modelo system-one
    # modelo: ggml-org/Laya-GGUF, type: 'choice' | 'score' | 'noul'
    JSON_QUESTIONS='
    {
        "model": "ggml-org/Laya-GGUF",
        "state": "Hi, we were billed twice for March. Please refund the duplicate today or we will cancel our plan.",
        "questions": {
            "department": {
                "type": "choice",
                "instructions": "Which department should handle this?",
                "criteria": {
                    "billing": "invoices, payments, refunds",
                    "technical": "bugs, outages, system errors",
                    "other": "everything else"
                }
            },
            "urgency": {
                "type": "score",
                "instructions": "How urgent is this?",
                "criteria": [
                    "not urgent",
                    "soon",
                    "blocking"
                ]
            },
            "churn_risk": {
                "type": "noul",
                "instructions": "Does the user threaten to cancel or leave?"
            }
        }
    }';
    JSON_PAYLOAD=$(echo $JSON_QUESTIONS | jq -c);
    curl \
        -X POST \
        -H "Content-type: application/json" \
        -H "Authorization: Bearer $LM_API_TOKEN" \
        "https://$SERVER_FQDN/v1/systemone" \
        -d "$JSON_PAYLOAD";
        #
        # Retorno:
        #
        #    {
        #        "model": "laya",
        #        "answers": {
        #            "department": {
        #                "type": "choice",
        #                "choice": "billing",
        #                "probabilities": {
        #                    "billing": 0.9870735189679403,
        #                    "technical": 0.007643838803052039,
        #                    "other": 0.005282642229007625
        #                },
        #                "confidence": 0.9806102784519104
        #            },
        #            "urgency": {
        #                "type": "score",
        #                "score": 1.7765817804244373,
        #                "legend": {
        #                    "0": "not urgent",
        #                    "1": "soon",
        #                    "2": "blocking"
        #                },
        #                "probabilities": {
        #                    "0": 0.03936197723519105,
        #                    "1": 0.1446942651051806,
        #                    "2": 0.8159437576596283
        #                },
        #                "confidence": 0.6648726706366559
        #            },
        #            "churn_risk": {
        #                "type": "noul",
        #                "noul": 0.8768382231230579
        #            }
        #        },
        #        "usage": {
        #            "input_tokens": 164,
        #            "output_tokens": 0
        #        }
        #    }

Nossa API está online, agora temos que garantir que o frpc seja iniciado como serviço persistência no Mac.

Bash
# Pasta de agentes do usuario
mkdir -p ~/Library/LaunchAgents;

# Servico do frpc
(
    echo '<?xml version="1.0" encoding="UTF-8"?>';
    echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">';
    echo '<plist version="1.0">';
    echo '<dict>';
    echo '    <key>Label</key><string>local.frpc</string>';
    echo '    <key>ProgramArguments</key>';
    echo '    <array>';
    echo "        <string>$HOME/frp/frpc</string>";
    echo '        <string>-c</string>';
    echo "        <string>$HOME/frp/frpc.toml</string>";
    echo '    </array>';
    echo '    <key>RunAtLoad</key><true/>';
    echo '    <key>KeepAlive</key><true/>';
    echo '    <key>ThrottleInterval</key><integer>10</integer>';
    echo "    <key>StandardOutPath</key><string>$HOME/frp/frpc.log</string>";
    echo "    <key>StandardErrorPath</key><string>$HOME/frp/frpc.log</string>";
    echo '</dict>';
    echo '</plist>';
) > ~/Library/LaunchAgents/local.frpc.plist;

# Validar o arquivo
plutil -lint ~/Library/LaunchAgents/local.frpc.plist;
    # /Users/voce/Library/LaunchAgents/local.frpc.plist: OK

# Ativar
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.frpc.plist;

# Conferir
tail -3 ~/frp/frpc.log;

# Reiniciar
launchctl kickstart -k gui/$(id -u)/local.frpc;

# Desativar
launchctl bootout gui/$(id -u)/local.frpc;

# Nunca dormir (a tela pode apagar normalmente)
sudo pmset -a sleep 0;

x

x

x

x

x

(parei aqui)

x

x

x

x

6.1 – O timeout de 60 segundos

O frps espera por padrão 60 segundos pelo cabeçalho de resposta. Depois disso, entrega 504. Em LLM isso acontece fácil: o modelo sendo carregado na memória somado a uma resposta longa sem streaming estoura esse tempo, e o cliente recebe erro enquanto o Mac continua fritando a GPU à toa.

Por isso coloquei vhostHTTPTimeout = 600 no capítulo 2.2. Ajuste para o seu pior caso.

6.2 – Não use httpUser e httpPassword

O frp oferece Basic Auth no proxy e parece uma camada extra de graça. Não é: ele usa o mesmo cabeçalho Authorization do token Bearer, e o resultado é 401 para todo cliente da API, mesmo com o token certo.

Se quiser outra camada, use o middleware ipAllowList do Traefik para restringir por IP de origem.

6.3 – 404 ou 504 com o Mac desligado?

Com o Mac fora do ar, o frps responde 404. O mesmo vale quando o frpc está conectado e o LM Studio está fechado. Para quem consome a API, esse código é enganoso: parece URL errada, não servidor ausente.

Para responder 504 Gateway Timeout, faça a gambiarra no Traefik: adicione estas labels ao container do capítulo 2.3, antes da imagem:

    --label "traefik.http.routers.lmstudio.middlewares=lmstudio-504" \
    --label "traefik.http.middlewares.lmstudio-504.errors.status=404" \
    --label "traefik.http.middlewares.lmstudio-504.errors.service=lmstudio" \
    --label "traefik.http.middlewares.lmstudio-504.errors.query=/" \
    --label "traefik.http.middlewares.lmstudio-504.errors.statusRewrites.404=504" \

CUIDADO: o Traefik não distingue a origem do 404. Uma rota inexistente no próprio LM Studio também vira 504. É gambiarra assumida; eu aceito o preço porque ninguém deveria chamar rota errada nessa API.

6.4 – Diagnóstico rápido

Procure o sintoma na tabela:

SintomaCausaConserto
404 na URL públicafrpc desconectado, LM Studio fechado ou domínio diferente em customDomainsConsulte ~/frp/frpc.log e confira o servidor do LM Studio
401 com token certohttpUser e httpPassword ativos no frpcRemova os dois parâmetros
504 após 60 segundosvhostHTTPTimeout no valor padrãoAumente no frps.toml e reinicie o container
Log: token in login doesn’t matchauth.token diferente nos dois ladosCopie o mesmo valor e reinicie o frpc
Log: connect to local service error: connection refusedServidor do LM Studio parado ou em outra portaLigue o servidor e confira localPort

Conclusão

Recomendo o frp para esse cenário: um único binário em cada ponta, roteamento por domínio e dashboard embutido. Quando precisar publicar outro serviço do Mac, basta um novo bloco [[proxies]] no frpc.toml e novas labels no Traefik.

Não esqueça que agora o seu Mac Mini é um servidor na Internet: use token forte, troque-o se vazar e acompanhe o uso pelo dashboard.

Tudo pronto, agora você pode abusar do seu LM Studio de qualquer lugar do mundo. Agora é contigo. Use sua criatividade.

Terminamos por hoje!

Patrick Brandão, patrickbrandao@gmail.com

[right] “Eu só posso lhe mostrar a porta. Você é quem tem que atravessá-la.“

Morpheus

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *