This guide covers deploying hamroh to a VPS (Contabo, Hetzner, DigitalOcean, etc.) using Docker, and setting up a continuous deployment workflow.
- A VPS with SSH access
- A GitHub repo with your hamroh code
- A Telegram bot token (from @BotFather)
- A Claude Code account (for API authentication)
# SSH into your server
ssh root@your-server-ip
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install Node.js + Claude Code CLI and authenticate
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
npm install -g @anthropic-ai/claude-code
claude # interactive login — creates ~/.claude/
# Clone your private repo (SSH auth — add server's public key to GitHub first)
# On server: ssh-keygen -t ed25519 (if no key exists)
# Copy ~/.ssh/id_ed25519.pub → GitHub Settings → SSH keys
git clone git@github.com:your-user/hamroh.git ~/hamroh
cd ~/hamroh
# Configure
cp .env.example .env
vim .env # set TELEGRAM_BOT_TOKEN, HAMROH_OWNER_ID, etc.
cp prompts/project.md.example prompts/project.md
vim prompts/project.md # customize identity, integrations, team info
# Build and start
docker compose up -d --build
# Verify it's running
docker compose ps
docker compose logs -f # should see "hamroh is live"DM your bot on Telegram to confirm it replies.
The bot ships with a tight default surface — Telegram messaging, memory tools, reminders, and read-only web access only. Shell, code-editing, subagents, and any external MCPs are all off by default.
Toggles live in plugins.json at the repo root. Copy the shipped
template once on first setup:
cp plugins.json.example plugins.jsonThen edit:
plugins.json is gitignored, so different deployments can carry
different toggles without fighting over the file. External MCPs are
declared in plugins.json but their credentials live in .env,
referenced as ${VAR}. An MCP whose ${VAR} references aren't
satisfied is silently skipped at boot. The shipped example carries
sample Jira / GitLab / GitHub entries to copy from — they're not
first-class, just convenient starting points.
For the per-tool list, the schema, "How to add a new MCP", and how
to disable individual built-in tools (e.g. telegram_create_poll,
render_latex) or skills, see tools.md. Restart the
container after editing either file: docker compose up -d --force-recreate.
Every time you push changes to GitHub:
ssh root@your-server-ip 'cd ~/hamroh && git pull && docker compose up -d --build'Or step by step:
ssh root@your-server-ip
cd ~/hamroh
git pull
docker compose up -d --build
docker compose logs -f # verify it started correctlyCreate .github/workflows/deploy.yml in your repo:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ~/hamroh
git pull
docker compose up -d --buildThen add these secrets to your GitHub repo (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
SERVER_IP |
Your VPS IP address |
SSH_PRIVATE_KEY |
Contents of ~/.ssh/id_ed25519 (generate with ssh-keygen -t ed25519 and add the public key to the server's ~/.ssh/authorized_keys) |
Every push to main will automatically deploy to your server.
Note: Since the repo is private, the server needs SSH access to
GitHub for git pull to work. Make sure the server's SSH key
(~/.ssh/id_ed25519.pub) is added as either:
- A deploy key on the repo (Settings → Deploy keys) — scoped to this repo only, recommended
- Or an SSH key on your GitHub account (Settings → SSH keys) — grants access to all your repos
The data/ directory is created automatically on first run. It contains:
data/memories/— bot's runtime memory files (the only part ofdata/worth migrating between deployments). Durable memories you want to keep permanently can instead live in the git-trackedmemories/folder at the repo root, which travels with the repo and needs no migration — see below.data/hamroh.db— SQLite database (messages, users, reminders, tool call logs) — starts fresh on new serversdata/session_id— Claude Code session ID for conversation continuitydata/attachments/— inbound photos/docs the dispatcher saveddata/renders/— outbound PNGs fromrender_htmldata/cc_logs/— raw Claude Code subprocess logs
Headless Chromium for render_html is pre-installed in the Docker
image (playwright install --with-deps chromium) — no per-host
provisioning step needed.
First deployment: nothing to do — the bot creates everything.
Migrating from another server: only copy memories:
scp -r old-server:~/hamroh/data root@new-server:~/hamroh/dataDon't copy session_id or hamroh.db to a new server — stale
session IDs cause CC subprocess crashes, and the database will rebuild
naturally from new messages.
The gitignored runtime store (data/memories/) only lives on the server, so
use the included sync script to keep it and your project config in sync between
your local machine and the server:
# Pull latest memories from server
./scripts/sync-memories.sh pull root@your-server-ip
# Push updated project.md to server
./scripts/sync-memories.sh push root@your-server-ip
# Both (pull memories, then push project.md)
./scripts/sync-memories.sh sync root@your-server-ipThe committed memories/ folder (repo root) needs none of this — it's
tracked in git, so git pull / git push move it like any other code. Use it
for durable memories you want versioned and backed up; let the bot's ephemeral
day-to-day notes stay in the runtime store.
After pushing project.md, restart for changes to take effect:
ssh root@your-server-ip 'cd ~/hamroh && docker compose restart'# View live logs
ssh root@your-server-ip 'cd ~/hamroh && docker compose logs -f'
# Shell into the container
ssh root@your-server-ip 'cd ~/hamroh && docker compose exec hamroh bash'
# Restart without rebuilding
ssh root@your-server-ip 'cd ~/hamroh && docker compose restart'
# Stop the bot
ssh root@your-server-ip 'cd ~/hamroh && docker compose down'
# Check status
ssh root@your-server-ip 'cd ~/hamroh && docker compose ps'Conflict: terminated by other getUpdates request
Another instance is polling the same bot token. Make sure only one is
running — check both local (pkill -f 'python -m hamroh') and
Docker (docker compose down).
Common causes:
- Stale session ID — delete
data/session_idand restart. This happens after renaming the project folder or moving to a new server. - MCP server not reachable — the
--strict-mcp-configflag makes Claude exit if any MCP server in the config fails to connect. Check thatuvxandnpxare available inside the container.
SSH into the server and re-authenticate:
ssh root@your-server-ip
claude # follow the login flow
cd ~/hamroh && docker compose restartIf you run hamroh in Docker on macOS and see Not logged in · Please run /login even though claude login succeeded on the host, that's
because macOS stores the OAuth token in the Keychain — the container's
bind mount of ~/.claude doesn't carry it. (Linux hosts write
~/.claude/.credentials.json natively, so they don't hit this.)
Easiest fix: don't use Docker on macOS. Run with uv instead — the
subprocess inherits your shell's Keychain access:
uv sync --extra dev
uv run python -m hamrohIf you really need Docker on macOS: export the Keychain entry to a file once, then bring up the stack:
security find-generic-password -s "Claude Code-credentials" -w \
> ~/.claude/.credentials.json
chmod 600 ~/.claude/.credentials.json
docker compose up -dThe OAuth token rotates periodically. When it does, the file goes
stale and the same error returns — re-run the export to refresh. There
is no automated refresh, which is why the uv path is recommended for
day-to-day macOS use.
{ "tool_groups": { "bash": true, "code": true, "subagents": false }, "mcps": [ /* sample Jira / GitLab / GitHub entries — keep, edit, or delete */ ], "skills_disabled": [], "builtin_tools_disabled": [] }