A step-by-step guide to deploying NousResearch Hermes Agent on a VPS using Dokploy, secured with Tailscale and a locked-down firewall.
- A VPS with Dokploy installed
- Dokploy API token (Settings → API Keys in Dokploy UI)
- A Telegram bot token from @BotFather
- An LLM API key (OpenAI-compatible endpoint)
The idea: block all inbound ports on the public IP (including SSH), and access everything through Tailscale. This means no attack surface on the public internet — the server is only reachable from your tailnet.
# On the VPS
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --sshThe --ssh flag enables Tailscale SSH, so you can SSH into the server using the Tailscale IP without managing SSH keys or port 22.
# Deny all incoming traffic by default
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow only HTTP/HTTPS if you're serving public websites via Traefik
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
sudo ufw statusKey point: SSH (port 22) is NOT opened. You access the server exclusively via Tailscale SSH (ssh <tailscale-ip> or ssh <hostname> if configured in ~/.ssh/config).
Add to your local ~/.ssh/config:
Host my-vps
HostName 100.x.x.x # Tailscale IP
User root
Now you can run commands with:
ssh my-vps "docker ps"
ssh my-vps "ufw status"| Port | Public IP | Tailscale IP |
|---|---|---|
| 22 (SSH) | Blocked | Open (Tailscale SSH) |
| 80 (HTTP) | Open | Open |
| 443 (HTTPS) | Open | Open |
| 3000 (Dokploy) | Blocked | Open |
| Everything else | Blocked | Open |
Dokploy's UI and API (port 3000) are only accessible from your tailnet. No one on the public internet can reach the admin panel, API, or SSH.
Since Tailscale creates a private network, you can call the Dokploy API directly from your laptop — no need to SSH in first:
TOKEN="your-dokploy-token"
BASE_URL="http://100.x.x.x:3000/api/trpc"
# This works from your local machine, not just the server
curl -s -H "x-api-key: $TOKEN" "$BASE_URL/project.all"This is actually preferred over SSH + localhost, since Tailscale SSH can occasionally require re-authentication via browser, making SSH commands flaky for scripting.
TOKEN="your-dokploy-token"
BASE_URL="http://100.x.x.x:3000/api/trpc"
# Create a project
curl -s -X POST "$BASE_URL/project.create" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"name":"hermes","description":"Hermes Agent"}}'
# Create an application (use projectId + environmentId from response above)
curl -s -X POST "$BASE_URL/application.create" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"name":"hermes-agent","projectId":"PROJECT_ID","environmentId":"ENV_ID"}}'
# Configure git source with Dockerfile build
curl -s -X POST "$BASE_URL/application.update" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID","sourceType":"git","customGitUrl":"https://github.com/nousresearch/hermes-agent.git","customGitBranch":"main","buildType":"dockerfile"}}'
# Enable submodules (required by hermes)
curl -s -X POST "$BASE_URL/application.update" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID","enableSubmodules":true}}'# Create volume so config/sessions survive redeploys
curl -s -X POST "$BASE_URL/mounts.create" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"type":"volume","mountPath":"/opt/data","serviceId":"APP_ID","serviceType":"application","volumeName":"hermes-data"}}'
# Set command to sleep (keeps container alive for interactive setup)
curl -s -X POST "$BASE_URL/application.update" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID","command":"sleep infinity"}}'
# Set env vars
curl -s -X POST "$BASE_URL/application.update" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID","env":"HERMES_HOME=/opt/data\nTERMINAL_ENV=local\nTERMINAL_TIMEOUT=60"}}'# Trigger deploy — builds Docker image (~3-5 min, large image with Playwright/Chromium)
curl -s -X POST "$BASE_URL/application.deploy" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID"}}'
# Check deployment status
curl -s -H "x-api-key: $TOKEN" \
--get "$BASE_URL/deployment.allByType" \
--data-urlencode 'input={"json":{"id":"APP_ID","type":"application"}}'Once the container is running with sleep infinity, SSH in via Tailscale:
# Find the container
ssh my-vps "docker ps --filter name=your-app-name"
# Run hermes setup (interactive wizard)
ssh my-vps "docker exec -it \$(docker ps -qf name=your-app-name) hermes setup"The setup wizard will ask you to configure:
- LLM provider: Choose your provider and set API key + base URL
- Model: Pick a model your provider supports
- Telegram: Paste your bot token, set allowed user IDs
- Personality: Choose a preset or use default
Config is saved to /opt/data/config.yaml and /opt/data/.env.
# Change command to run the Telegram gateway
curl -s -X POST "$BASE_URL/application.update" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID","command":"hermes gateway run"}}'
# Redeploy (reuses existing image, just restarts with new command)
curl -s -X POST "$BASE_URL/application.redeploy" \
-H "x-api-key: $TOKEN" -H 'Content-Type: application/json' \
-d '{"json":{"applicationId":"APP_ID"}}'# Check container is running
ssh my-vps "docker ps --filter name=your-app-name"
# Check logs
ssh my-vps "docker logs \$(docker ps -qf name=your-app-name) --tail 50 2>&1"- Dokploy API is tRPC-based: mutations use
POSTwith{"json":{...}}body, queries useGETwith URL-encodedinputparam. - Telegram group chats: Disable privacy mode via @BotFather (
/setprivacy→ Disable), then remove and re-add the bot to the group. - Config persists in the
hermes-datavolume at/opt/data/— survives redeploys. No need to re-run setup after redeploying. - The Docker image is large (Debian + Python + Node + Playwright/Chromium) — first build takes several minutes.
hermes setupis interactive — that's why we first deploy withsleep infinity, run setup, then switch to the real command.- Prefer API calls over SSH — Tailscale SSH can require periodic browser re-auth, making it unreliable for scripts. Use direct curl to the Tailscale IP for Dokploy API operations.
| Operation | Method | Endpoint |
|---|---|---|
| List projects | GET | project.all |
| Get app details | GET | application.one |
| Create app | POST | application.create |
| Update app | POST | application.update |
| Create volume | POST | mounts.create |
| Deploy | POST | application.deploy |
| Redeploy | POST | application.redeploy |
| Check deploys | GET | deployment.allByType |
All endpoints are prefixed with http://<tailscale-ip>:3000/api/trpc/ and require the x-api-key header.