Skip to content

AI-agent deployment runbook

You are an AI coding assistant (Claude, or similar). A user wants chatalot running at their domain. This document is the exact sequence of commands you should run, the outputs you should expect, and the failure modes to handle.

This runbook is deliberately command-first. Prose is minimal. If you're a human reading this for the first time, start with production.md instead — the walkthrough there is the same flow with more explanation.

The ask

The user will typically tell you something like "deploy chatalot to my VPS at chat.example.com". To execute, you need eight pieces of information. Ask for any that are missing before touching the VPS.

Input Required? Notes
VPS SSH address Yes user@host, must have sudo or root.
Public domain Yes chat.example.com. A-record must already point at the VPS.
Admin username Yes Becomes the pre-seeded admin on first-run. ASCII, no spaces.
Registration mode No — default invite_only open, invite_only, closed.
Voice/video (TURN)? No — default off Needs the VPS's public IPv4 and ports 3478/5349 open.
Managed updates? No — default on Lets the user accept signed release updates from the admin panel.
Registry credentials No Only needed if pulling from a private registry. User gives you username and token.
OIDC/SSO No Only if the user wants single-sign-on. Needs issuer URL + client ID + client secret.

Confirm the full set back to the user before running anything.

Pre-flight

Ssh to the VPS and run these probes. Fix anything that fails before moving on.

# 1. OS + architecture
uname -a
cat /etc/os-release | grep -E '^(NAME|VERSION_ID)='

# 2. Docker + compose v2 (install if missing)
docker --version
docker compose version

# 3. openssl + git (install if missing)
openssl version
git --version

# 4. DNS: does the domain resolve to THIS host?
#    Substitute the user's domain and the VPS public IP.
DOMAIN=chat.example.com
dig +short "$DOMAIN" A
#    Expected: matches the VPS public IP. If it's empty or different,
#    either DNS isn't set up yet (stop, ask the user to create the A
#    record) or the VPS has a different public IP than they told you
#    (stop, ask to clarify).

# 5. Ports 80/443 not already bound
ss -tlnp 2>/dev/null | awk '$4 ~ /:(80|443)$/ {print}'
#    Expected: empty. If something else is listening, the user needs
#    to stop it or you need to use a different port/proxy strategy.

# 6. For TURN deploys: UDP ports open at the cloud firewall level
#    (this is VPS-provider-specific; Hetzner = Cloud Firewall panel,
#    AWS = security group, etc.). You cannot verify from the VPS alone;
#    confirm with the user that UDP/TCP 3478 + 5349 are open.

If any of steps 1-3 fail, install missing packages per the distro:

# Debian / Ubuntu
sudo apt update
sudo apt install -y docker.io docker-compose-v2 openssl git curl

# Fedora / RHEL
sudo dnf install -y docker docker-compose openssl git curl
sudo systemctl enable --now docker

# Arch
sudo pacman -S --noconfirm docker docker-compose openssl git curl
sudo systemctl enable --now docker

# Then add your SSH user to the docker group so you don't need sudo
# for every docker command:
sudo usermod -aG docker "$USER"
# Log out + back in for the group to take effect (or run: newgrp docker).

Install

1. Clone the repo

sudo mkdir -p /srv/chatalot
sudo chown "$USER:$USER" /srv/chatalot
git clone https://forgejo.seglamater.app/seglamater/chatalot.git /srv/chatalot
cd /srv/chatalot

2. Compose the flag string

Build the invocation piece by piece from the user's inputs. Minimum viable command:

./scripts/install.sh --non-interactive \
    --domain "$DOMAIN" \
    --admin-username "$ADMIN_USER" \
    --registration invite_only \
    --use-prebuilt

Add, in order:

  • --enable-turn --turn-external-ip "$VPS_PUBLIC_IP" — if the user wants voice/video. Required together; the installer refuses one without the other.
  • --enable-updater — always include this unless the user explicitly opts out. Clients who turn it off now can't easily get back to a signed-update flow later.
  • --registry-user "$REG_USER" --registry-token "$REG_TOKEN" — only if the user has private-registry credentials. Required together.
  • --oidc-issuer "$OIDC_URL" --oidc-client-id "$OIDC_ID" --oidc-client-secret "$OIDC_SECRET" — only if the user wants SSO. All three together. Add --oidc-disable-password-login to force SSO-only.

Full example with every feature (replace placeholders):

./scripts/install.sh --non-interactive \
    --domain chat.example.com \
    --admin-username alice \
    --registration invite_only \
    --enable-turn --turn-external-ip 203.0.113.42 \
    --enable-updater \
    --registry-user deploy-alice --registry-token abc123... \
    --oidc-issuer https://auth.example.com/application/o/chatalot/ \
    --oidc-client-id chatalot-prod \
    --oidc-client-secret s3cret \
    --use-prebuilt

3. Run it

Stream the output. The installer prints each step to stdout; if it exits non-zero, the last block is almost always the reason.

./scripts/install.sh --non-interactive ...  2>&1 | tee /tmp/chatalot-install.log

Expected final output:

Install complete.
public_url=https://chat.example.com
admin_username=alice
registration_mode=invite_only
turn_enabled=1
updater_enabled=1
compose_profiles=turn updater

Next:
  1. Configure DNS for chat.example.com -> this host.
  2. Front with a reverse proxy (Caddy/traefik/nginx) terminating TLS at :443
     and forwarding to 127.0.0.1:8080. See docs/self-hosting/production.md.
  3. Open https://chat.example.com and register the admin user 'alice'.
  4. Open TURN ports on your firewall: UDP/TCP 3478 + 5349 to TURN_EXTERNAL_IP.

Those key=value lines are intentionally parseable — you can extract them with grep ^public_url= /tmp/chatalot-install.log | cut -d= -f2- if you need to feed them into the next step.

Reverse proxy

The installer does NOT set up TLS or the reverse proxy. Pick one of the patterns below and deploy it. Do this on the same VPS, listening on 80/443.

Installs and runs with zero extra config beyond the domain block. Auto-ACME.

# Install
sudo apt install -y caddy
# Configure
sudo tee /etc/caddy/Caddyfile <<EOF
${DOMAIN} {
    encode zstd gzip
    reverse_proxy 127.0.0.1:8080
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        -Server
    }
}
EOF
# Reload
sudo systemctl reload caddy

Traefik (if one is already running)

Add labels to the chatalot service in /srv/chatalot/docker-compose.override.yml:

services:
  chatalot:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.chatalot.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.chatalot.tls=true"
      - "traefik.http.routers.chatalot.tls.certresolver=letsencrypt"
      - "traefik.http.services.chatalot.loadbalancer.server.port=8080"

Then docker compose up -d chatalot again.

nginx + certbot

For operators already committed to nginx. Full snippet at tls-and-reverse-proxy.md. Don't freehand it in this step — the WebSocket upgrade block is fiddly.

Cloudflare Tunnel (no public IP needed)

The user passes a named-tunnel token; add --expose named-tunnel --cloudflare-token <token> to the installer flags instead of setting up a proxy. Covered in cloudflare-tunnel.md.

Verify

# Local health (reaches the backend directly, skips the proxy)
curl -sf http://127.0.0.1:8080/api/health | jq .
# Expect: {"status":"ok","version":"0.24.X","uptime_secs":N,"db_healthy":true}

# Public health (reaches via DNS + proxy + TLS)
curl -sf "https://${DOMAIN}/api/health" | jq .
# Expect: same response.

# Confirm all expected containers are up
docker compose ps
# Expect: chatalot, chatalot-db, postgres at minimum.
# Plus: chatalot-turn (if --enable-turn), chatalot-updater + chatalot-socket-proxy
# (if --enable-updater).

If the public health check fails but the local one works, the proxy is misconfigured. Fix the proxy config before telling the user it's ready.

Handover to the user

Once both health checks return 200, tell the user:

Chatalot is running at https://<DOMAIN>. Register the user <ADMIN_USER> first — that becomes the admin account. Save the recovery code shown on the registration screen in a password manager; it's the only way to recover if you lose your password AND your TOTP device.

If managed updates are enabled (the normal case), check the admin panel's "Updates" section once every few weeks. The client pulls signed releases from the official channel; you approve the apply.

Also tell them where the important files live:

/srv/chatalot/.env           — server config (DB password, secrets). NEVER commit.
/srv/chatalot/secrets/       — cryptographic material (JWT keys, TOTP key, TURN
                                secret, updater token, cosign pubkey). BACK UP OFFLINE.
/var/lib/docker/volumes/     — postgres data + file uploads + updater snapshots.

Back up /srv/chatalot/secrets/ immediately — losing it invalidates every existing login session, every TOTP enrollment, every managed-update HMAC. An encrypted copy in a password manager or offline disk is enough.

Failure modes

Symptom Likely cause Fix
Installer exits 2 with "missing more flags" You forgot --domain or --admin-username Re-run with the missing flag. The error message lists all missing flags.
Installer exits at "Docker daemon is not running" docker was installed but not started sudo systemctl enable --now docker
curl http://127.0.0.1:8080/api/health returns 000 / connection refused chatalot container isn't up docker compose logs chatalot — look for the last ERROR line.
No such file or directory (os error 2) in chatalot logs Missing secret file under secrets/ Run the installer again with --overwrite-env (or manually create the missing file — cross-reference against secrets/*.example).
TURN works on LAN but fails from remote clients Firewall blocks UDP 3478 / 5349, OR TURN_EXTERNAL_IP is a private address Open the ports at the VPS firewall (cloud provider panel), double-check --turn-external-ip was the PUBLIC address.
HTTPS returns 502 via the proxy, but local health is fine Proxy isn't forwarding WebSocket upgrades at /ws, or is misconfigured Inspect the proxy's access log + config. WS is non-negotiable for real-time features.
Updater apply fails at the pull phase with 401 secrets/registry_creds is empty or wrong The user needs to supply a read-only registry token. Populate secrets/registry_creds with {"username":"...","password":"...","serveraddress":"..."} and docker compose restart chatalot-updater.

After the install

Durable state the user owns and must maintain:

  • DNS: A/AAAA records at the registrar.
  • Firewall: proxy ports (80/443) always, TURN ports (3478/5349) if enabled.
  • Secrets backup: offline copy of /srv/chatalot/secrets/.
  • Database backups: either via the built-in chatalot-snapshot CLI cron or an external pg_dump on a schedule. See backup-and-restore.md.
  • Updates: open the admin panel's "Updates" tab to review + approve published releases. The apply itself is one click; the sidecar handles pull, snapshot, migrate, swap, and rollback-on-health-fail.

If any of these aren't in place, say so explicitly to the user. Don't leave them to figure out.

References