husk serve is a single Node process that exposes the control plane, mounts every
http, webhook and cron trigger your husks declare, and serves the console at /.
This guide puts it on a machine you own and keeps it there.
What you will have at the end: a control plane reachable over TLS at a hostname you control, protected by a bearer token, restarted automatically on reboot, with its state in a directory you can back up.
Prerequisites
- A Linux box you can
sshinto, with Node 20.10 or newer. - A DNS name pointing at it, if you want TLS.
- 15 minutes.
Step 1: Install and check
npm install -g @husk-ai/cli
husk doctorhusk doctor is the honest inventory. Read the COMPUTERS section before you go
further: on a fresh VPS with no Docker you will get the local provider, which is
guardrails and not a sandbox. If this box will run
anything you did not write, install Docker first.
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # log out and back in
husk doctor # docker should now be ✓ isolatedStep 2: The token
This API can execute shell commands. Husk will not let you bind it to a public interface without authentication:
Generate one and keep it out of your shell history:
umask 077
mkdir -p /etc/husk
openssl rand -hex 32 > /etc/husk/token
printf 'HUSK_TOKEN=%s\n' "$(cat /etc/husk/token)" > /etc/husk/husk.env
chmod 600 /etc/husk/husk.envEvery request outside /health then needs the token:
curl -s -H "Authorization: Bearer $(cat /etc/husk/token)" localhost:7377/v1/doctor | jq .selectionComparison is constant-time. /health stays open so a load balancer can probe it.
Step 3: Decide how it is exposed
You have two sane options and one bad one.
| Loopback + SSH tunnel | Simplest and safest. husk serve stays on 127.0.0.1, you tunnel in |
| Loopback + reverse proxy | What you want for a team. TLS terminates at the proxy |
Binding 0.0.0.0 directly | No TLS, so the bearer token crosses the network in clear text. Do not |
The tunnel, if it is just you
# on the server
husk serve
# on your laptop
ssh -N -L 7377:127.0.0.1:7377 user@yourbox
open http://127.0.0.1:7377Nothing else in this guide is needed. Skip to step 5.
The reverse proxy, if it is a team
Keep Husk on loopback and let the proxy own TLS:
husk serve --host 127.0.0.1 --port 7377Step 4: A reverse proxy that does not break the streams
Two things go wrong with a naive proxy config: SSE gets buffered, and WebSocket
upgrades get dropped. Husk sends X-Accel-Buffering: no for exactly the first problem,
but the rest is on you.
server {
listen 443 ssl http2;
server_name husk.example.com;
ssl_certificate /etc/letsencrypt/live/husk.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/husk.example.com/privkey.pem;
# An agent run can stream for minutes. The defaults are 60s.
proxy_read_timeout 1h;
proxy_send_timeout 1h;
location / {
proxy_pass http://127.0.0.1:7377;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket: /v1/events and /v1/computers/:id/terminal
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# SSE: do not buffer, do not gzip, do not chunk-collapse.
proxy_buffering off;
proxy_cache off;
gzip off;
}
}Caddy needs almost none of that:
husk.example.com {
reverse_proxy 127.0.0.1:7377 {
flush_interval -1 # never buffer; required for SSE
transport http {
read_timeout 1h
}
}
}CORS
CORS is off by default, and there is no husk serve flag to turn it on.
ServerConfig.corsOrigins exists and is only reachable by calling serve() from your
own Node process:
import { serve } from '@husk-ai/server';
await serve({
host: '127.0.0.1',
port: 7377,
corsOrigins: ['https://app.example.com'],
});If you are serving the bundled console from the same origin — which is what GET /
does — you do not need CORS at all.
Step 5: The systemd unit
[Unit]
Description=Husk control plane
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=husk
Group=husk
WorkingDirectory=/home/husk
# HUSK_TOKEN lives here, mode 0600, owned by husk.
EnvironmentFile=/etc/husk/husk.env
Environment=HUSK_HOME=/var/lib/husk
Environment=HUSK_HOST=127.0.0.1
Environment=HUSK_PORT=7377
Environment=HUSK_LOG_JSON=1
# Model keys, if this box runs agents rather than only computers.
# Environment=ANTHROPIC_API_KEY=...
ExecStart=/usr/bin/husk serve
Restart=on-failure
RestartSec=5
# Husk shuts down in order: triggers, scheduler, reaper, in-flight runs, store.
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/var/lib/husk
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo useradd --system --create-home --home-dir /home/husk husk
sudo usermod -aG docker husk # only if you want the docker provider
sudo install -d -o husk -g husk -m 700 /var/lib/husk
sudo systemctl daemon-reload
sudo systemctl enable --now husk
sudo systemctl status husk
journalctl -u husk -fHUSK_HOST and HUSK_PORT are read by the server, so the unit needs no flags.
HUSK_LOG_JSON=1 makes the log lines structured, which journalctl -o json and every
log shipper prefer.
Shutdown
On SIGTERM or SIGINT, once and only once however many signals arrive, Husk:
- stops the trigger host and the cron scheduler,
- stops the computer reaper,
- aborts every in-flight run — so no container is left spinning,
- closes the HTTP server,
- drains the store's write queue.
TimeoutStopSec=30 gives it room. A run cancelled this way ends with
stopReason: 'aborted' and is still readable at GET /v1/runs/:id.
Step 6: Verify
T=$(cat /etc/husk/token)
H="Authorization: Bearer $T"
curl -s https://husk.example.com/health
# {"ok":true,"version":"0.1.0","uptimeSec":41}
curl -s -H "$H" https://husk.example.com/v1/doctor | jq '.selection, .warnings'
curl -s -H "$H" https://husk.example.com/v1/computers \
-H 'content-type: application/json' \
-d '{"name":"smoke","flavor":"base"}' | jq -r .idThen check the streams actually stream, which is the thing a proxy breaks:
curl -N -H "$H" -H 'content-type: application/json' \
https://husk.example.com/v1/computers/cmp_x/exec/stream \
-d '{"cmd":"for i in 1 2 3; do echo $i; sleep 1; done"}'You should see three data: frames one second apart. All three arriving at once means
the proxy is buffering.
Operating it
State
Everything is under HUSK_HOME, mode 0700:
/var/lib/husk/
config.json settings, model aliases
husks/ registered husk.yaml files ← back this up
computers/ machine metadata, bindings.json
workspaces/ the local provider's filesystems
runs/ summaries and events.ndjson ← may contain secrets
transcripts/ imported chats ← may contain secrets
data/ cache/husks/ is the part with your work in it. runs/ and transcripts/ hold unredacted
tool output and raw conversations — see Secrets — so back
them up accordingly or prune them.
Limits
{
"maxComputers": 8,
"maxCostUsd": 5,
"model": "auto",
"provider": "auto",
"modelAliases": {}
}maxComputers is the hard cap on live machines; a ninth returns E_QUOTA with 429.
maxCostUsd is the router's per-call ceiling, independent of each husk's own
limits.maxCostUsd.
The reaper
husk serve starts a sweep every 60 seconds that destroys machines past their
idleTimeoutSec or maxLifetimeSec.
Upgrades
sudo -u husk npm install -g @husk-ai/cli@latest
sudo systemctl restart huskThe store is JSON files on disk with no migrations, so a restart is the whole upgrade. In-flight runs are aborted, not resumed.
Hardening checklist
-
HUSK_TOKENset, 32 bytes of entropy, mode0600, not in shell history - Bound to
127.0.0.1; TLS terminated at the proxy - Docker installed and the
localprovider not the selected one (husk doctor→SELECTION) -
HUSK_HOMEon a path the unit can write, and backed up - Every husk's
computer.network.modeset deliberately — the floor is not a firewall -
guardrails.approvalModereviewed per husk;askfails closed with no approver wired - Webhook triggers carry a
secret; an unsigned one is reported as UNSIGNED - Model keys scoped as narrowly as the provider allows
What this does not give you
There is no multi-tenancy. One husk serve is one user's control plane: one token, one
HUSK_HOME, one set of husks. There are no accounts, no per-user quotas and no
authorization beyond "has the token or does not".
If several people need this, give each of them their own process, their own
HUSK_HOME, their own port and their own token.