Skip to main content
Run Grind on any Linux VPS or server for 24/7 availability and always-on webhook delivery. Both the web app and gateway run as local processes — a reverse proxy handles TLS and external traffic.
Private by default. The web app has no built-in authentication layer. Keep it private via SSH tunnel or Tailscale unless you add an external auth gate (Cloudflare Access, HTTP basic auth, VPN). Only the gateway’s /hooks/* endpoints need to be publicly reachable — and only if you use Telegram, Discord, or WhatsApp integrations.

VPS requirements

MinimumRecommended
CPU1 vCPU1–2 vCPU
RAM512 MB1 GB+
Disk1 GB5 GB+
OSLinux x86_64 or arm64Ubuntu 22.04 LTS / 24.04 LTS
Any modern Linux distribution works. Ubuntu LTS is the most tested path.

Architecture

         Internet (HTTPS 443)

   ┌──────────────┴─────────────────────┐
   │  Reverse proxy (Caddy / Nginx / …) │
   │  hooks.example.com /hooks/* → :5174│  ← gateway (webhooks, public)
   │  app.example.com         → :3000   │  ← web app (private + auth gate)
   └────────────────────────────────────┘
              │                   │
      127.0.0.1:5174       127.0.0.1:3000
         Gateway               Web app
              └────────┬────────┘
                ~/.grind/vault.db
              (AES-encrypted at rest)
Both services bind to 127.0.0.1 by default. Only the reverse proxy is publicly reachable.

Quickstart

1

Install Bun + Grind

sudo apt install -y unzip
curl -fsSL https://bun.com/install | bash
source ~/.bashrc
bun install -g grindxp
2

Initialize your vault

grindxp init
Creates ~/.grind/, generates an encryption key, and sets up your profile. Back up the key immediately — see Step 2 below.
3

Start services

# Web app (foreground, headless)
grindxp web serve --no-open

# Gateway (second terminal)
grindxp gateway serve
4

Verify locally

curl http://127.0.0.1:3000/        # web app
curl http://127.0.0.1:5174/health  # gateway → {"status":"ok"}
For a production setup with autostart and TLS, follow the full steps below.

Step 1: Install Grind

See Installation for all options. On a Linux server:
curl -fsSL https://grindxp.app/install.sh | bash -s -- --no-init
Verify:
grindxp --version

Step 2: Initialize

grindxp init
The setup wizard runs interactively. On a headless server, run it in any terminal (SSH session is fine). Back up your encryption key immediately. Without it the vault is unrecoverable:
python3 -c "import json; print(json.load(open('$HOME/.grind/config.json'))['encryptionKey'])"
Store the printed key in a password manager or secrets store.

Step 3: Configure environment

Set production values in an env file. Grind reads process.env, so any mechanism works (systemd EnvironmentFile, Docker env_file, shell export). /etc/grind.env (or ~/.grind/.env):
GRIND_GATEWAY_HOST=127.0.0.1
GRIND_GATEWAY_PORT=5174
GRIND_GATEWAY_TOKEN=<strong-random-secret>

# AI provider — pick one
ANTHROPIC_API_KEY=sk-...
# OPENAI_API_KEY=sk-...
# OLLAMA_BASE_URL=http://localhost:11434/v1
Generate a secure gateway token:
openssl rand -hex 32
See Environment Reference for the full variable list.

Step 4: Keep services running (systemd)

Enable user lingering

Grind’s gateway start installs a systemd user unit automatically. On a headless VPS, enable linger so user services survive logout and start on boot:
loginctl enable-linger $USER
Then start the gateway (autostart unit is installed automatically):
grindxp gateway start
Verify:
grindxp gateway status
systemctl --user status grindxp-gateway

Web app unit

The web app has no built-in autostart. Create a user service:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/grindxp-web.service << 'EOF'
[Unit]
Description=Grind Web App
After=network.target

[Service]
ExecStart=grindxp web serve --no-open
Restart=always
RestartSec=5
EnvironmentFile=/etc/grind.env

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now grindxp-web.service
Check:
systemctl --user status grindxp-web.service
journalctl --user -u grindxp-web.service -f

Alternative: system-level units

If you prefer system-level services (no linger required):
# Web app
sudo tee /etc/systemd/system/grindxp-web.service > /dev/null << EOF
[Unit]
Description=Grind Web App
After=network.target

[Service]
User=$USER
ExecStart=$(which grindxp) web serve --no-open
Restart=always
RestartSec=5
EnvironmentFile=/etc/grind.env

[Install]
WantedBy=multi-user.target
EOF

# Gateway
sudo tee /etc/systemd/system/grindxp-gateway.service > /dev/null << EOF
[Unit]
Description=Grind Gateway
After=network.target

[Service]
User=$USER
ExecStart=$(which grindxp) gateway serve
Restart=always
RestartSec=5
EnvironmentFile=/etc/grind.env

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now grindxp-web.service grindxp-gateway.service
If you use system-level units, run grindxp gateway disable first to remove the user-level unit that gateway start installed — otherwise both will compete to run.

Alternative: PM2

npm install -g pm2
pm2 start "grindxp web serve --no-open" --name grindxp-web
pm2 start "grindxp gateway serve" --name grindxp-gateway
pm2 save
pm2 startup   # follow the printed instructions

Step 5: Reverse proxy + TLS

The reverse proxy terminates TLS and routes traffic to local services. Recommended routing:
  • hooks.example.com/hooks/*http://127.0.0.1:5174 — webhook endpoints (public, signature-verified by each integration)
  • app.example.comhttp://127.0.0.1:3000 — web app (add an auth gate before exposing publicly)
The web app has no built-in authentication. Do not expose it on a public hostname without an auth gate. Use Cloudflare Access, HTTP basic auth, or a VPN/Tailscale tunnel for personal access instead.
Caddy handles TLS automatically via Let’s Encrypt — no certbot needed.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
For other distros see the official Caddy install docs./etc/caddy/Caddyfile:
# Webhook endpoints only (public, HTTPS auto-managed)
hooks.example.com {
  reverse_proxy /hooks/* 127.0.0.1:5174
  respond "Not found" 404
}

# Web app — private by default
# Add Cloudflare Access, basicauth {}, or a VPN before making this public
app.example.com {
  # basicauth {
  #   alice <bcrypt-hash>   # htpasswd -nbB alice mypassword
  # }
  reverse_proxy 127.0.0.1:3000
}
sudo systemctl enable --now caddy
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Step 6: Firewall

Allow only SSH and the reverse proxy. Everything else stays closed.
# See wiki.ubuntu.com/BasicSecurity/Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
The web app (:3000) and gateway (:5174) are not opened — only the reverse proxy on the same host can reach them via localhost.

Step 7: Verify

# Are services running?
grindxp gateway status
grindxp web status

# Health checks (local)
curl http://127.0.0.1:5174/health   # → {"status":"ok"}
curl http://127.0.0.1:3000/         # → web app HTML

# Health check via reverse proxy (after DNS propagates)
curl https://hooks.example.com/health

# Service logs
journalctl --user -u grindxp-gateway -f
journalctl --user -u grindxp-web -f

Webhooks

If you use Telegram, Discord, or WhatsApp integrations, the gateway needs a public HTTPS URL. The reverse proxy above handles TLS termination. Register these webhook URLs with each provider:
IntegrationWebhook URL
Telegramhttps://hooks.example.com/hooks/telegram
Discordhttps://hooks.example.com/hooks/discord
WhatsApphttps://hooks.example.com/hooks/whatsapp
Generichttps://hooks.example.com/hooks/inbound
Run grindxp integrations to connect credentials. Set the corresponding verification secrets in your env file — requests without valid signatures are rejected:
GRIND_TELEGRAM_WEBHOOK_SECRET=...   # verifies Telegram deliveries
GRIND_DISCORD_PUBLIC_KEY=...        # verifies Discord Ed25519 signatures
GRIND_WHATSAPP_APP_SECRET=...       # verifies WhatsApp HMAC-SHA256
GRIND_GATEWAY_TOKEN=...             # protects /hooks/inbound
See Environment Reference and Integrations for details.

Docker

Initialize Grind on your local machine first (grindxp init), then copy ~/.grind/ to the server before running containers. docker-compose.yml:
services:
  grindxp-web:
    image: oven/bun:1
    restart: unless-stopped
    working_dir: /app
    command: sh -c "bun install -g grindxp 2>/dev/null; grindxp web serve --no-open"
    environment:
      - PORT=3000
    env_file:
      - .env
    volumes:
      - grind-data:/root/.grind
    ports:
      - "127.0.0.1:3000:3000"

  grindxp-gateway:
    image: oven/bun:1
    restart: unless-stopped
    working_dir: /app
    command: sh -c "bun install -g grindxp 2>/dev/null; grindxp gateway serve"
    env_file:
      - .env
    volumes:
      - grind-data:/root/.grind
    ports:
      - "127.0.0.1:5174:5174"

volumes:
  grind-data:
    driver: local
.env:
GRIND_GATEWAY_HOST=0.0.0.0
GRIND_GATEWAY_PORT=5174
GRIND_GATEWAY_TOKEN=<strong-random-secret>
ANTHROPIC_API_KEY=...
Copy your initialized vault into the named volume:
docker run --rm \
  -v grind_grind-data:/root/.grind \
  -v "$HOME/.grind":/src \
  alpine sh -c "cp -a /src/. /root/.grind/"
Start:
docker compose up -d
For production, build a custom image with grindxp pre-installed so startup is fast and reproducible. Install it in the Dockerfile with RUN bun install -g grindxp rather than at container startup.

Platform examples

The CX22 (2 vCPU, 4 GB RAM, ~€4/month) is the recommended starting point. CX11 (2 GB) works for personal use.
  1. Open the Hetzner Cloud Console and create a server. Choose any supported Linux distro (Ubuntu 24.04 recommended) and add your SSH key during provisioning.
  2. SSH in as root and create a non-root user:
    adduser grind
    usermod -aG sudo grind
    su - grind
    
  3. Follow the Quickstart then the full steps above.
  4. Point DNS A records at the VPS IP:
    • hooks.example.com → <vps-ip> (public, for webhooks)
    • app.example.com → <vps-ip> (optional — keep private unless you add auth)
  5. Use the Caddy config for automatic TLS with zero extra configuration.
  6. In the Cloud Console, add a Cloud Firewall that allows TCP 22, 80, 443 inbound — all other ports blocked.
A Basic Droplet ($6/month, 1 vCPU, 1 GB RAM) covers personal Grind usage.
  1. Create a Droplet (Ubuntu 24.04 recommended; any supported Linux distro works). In Networking → Firewalls, allow TCP 22, 80, 443 inbound.
  2. SSH in and follow the Quickstart.
  3. Set DNS A records in Networking → Domains.
  4. Use Caddy or Nginx — both work well.
DigitalOcean’s managed firewall and a host-level firewall (UFW, firewalld) are independent layers. Configure one or the other — not both — to avoid hard-to-debug conflicts. The managed firewall is easier to audit from the cloud console.
The Oracle Cloud Free Tier includes Ampere A1 ARM compute (up to 4 OCPUs + 24 GB RAM total) and 200 GB block storage — permanently free, no expiry.
  1. Sign up and create a Compute instance. Choose Ubuntu 22.04 or later on Ampere A1 (ARM / aarch64). Grind and Bun both have full arm64 support.
  2. Open ports 80 and 443 in the OCI Security List (Networking → Virtual Cloud Networks → your VCN → Security Lists). The OS firewall alone is not sufficient on OCI — the Security List sits at the hypervisor level and must also allow the ports.
  3. Follow the Quickstart.
  4. Use Caddy — it’s the easiest path on ARM.
Fly.io’s shared-cpu-1x with 512 MB RAM works for Grind. See the fly.toml reference for all config options.fly.toml:
app = "grind-yourusername"
primary_region = "iad"

[http_service]
  internal_port = 5174
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true

  [[http_service.checks]]
    interval = "15s"
    path = "/health"
    timeout = "2s"
Create a persistent volume for ~/.grind/:
fly volumes create grind_data --size 2
fly secrets set GRIND_GATEWAY_TOKEN=$(openssl rand -hex 32)
fly deploy
Fly handles TLS automatically. The web app is not deployed here by default — expose it separately or access via SSH tunnel with fly ssh console.
The guide above works on any Linux VPS. Key points:
  • OS: Any modern Linux distro with systemd (Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL 9+, Arch).
  • Bun: curl -fsSL https://bun.com/install | bash — see Bun install docs for other methods.
  • Grind: bun install -g grindxp
  • Autostart: loginctl enable-linger $USER then grindxp gateway start, plus a user systemd unit for the web app.
  • Firewall: use your provider’s managed firewall panel if one exists — don’t run both it and a host-level firewall.
  • TLS: Caddy is the simplest option on any provider — it auto-provisions Let’s Encrypt certificates.

Backup

Back up these two files regularly. Without config.json the vault is permanently unrecoverable:
FileContains
~/.grind/config.jsonEncryption key + all settings
~/.grind/vault.dbQuest data, XP, streaks, conversations
Simple daily cron (adjust paths for your backup target):
# crontab -e
0 3 * * * rsync -az ~/.grind/ backup-user@backup-host:/backups/grind/
Or with rclone to S3-compatible storage:
rclone copy ~/.grind/ remote:my-bucket/grind/ --exclude "*.log"
See Security for the full key management guide.

Troubleshooting

First 60 seconds if something is broken

grindxp gateway status             # is the gateway running and healthy?
grindxp web status                 # is the web app running?
curl http://127.0.0.1:5174/health  # gateway health probe
curl http://127.0.0.1:3000/        # web app reachable?
journalctl --user -u grindxp-gateway -n 50
journalctl --user -u grindxp-web -n 50

systemctl --user fails: “Failed to connect to bus”

On a headless VPS without a login session, the D-Bus session bus is unavailable. Fix:
loginctl enable-linger $USER

# Reconnect (new SSH session or):
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user daemon-reload

Gateway port already in use

lsof -i :5174         # find what's using the port
grindxp gateway stop  # stop any existing managed process

# Or run on a different port:
GRIND_GATEWAY_PORT=5175 grindxp gateway serve

Reverse proxy returns 502

The service is not running or is bound to the wrong address:
ss -tlnp | grep -E '3000|5174'
# Should show 127.0.0.1:3000 and 127.0.0.1:5174
Both services must be bound to 127.0.0.1 (or 0.0.0.0) and running before the proxy can reach them.

Webhook 401 / signature errors

The verification secret doesn’t match what was registered with the provider. Re-run grindxp integrations to update credentials, and ensure the matching env var is set:
GRIND_TELEGRAM_WEBHOOK_SECRET=...
GRIND_DISCORD_PUBLIC_KEY=...
GRIND_WHATSAPP_APP_SECRET=...
Restart the gateway after changing env vars.

Encryption key missing after reinstall

The key is in ~/.grind/config.json. If you deleted it without backing it up, the vault is unrecoverable. This is why backing up config.json before anything else is critical.