What you build
A fresh Ubuntu 24.04 VPS running the rousseau-agent WhatsApp daemon under a rootless Podman container, driven by the systemd Quadlet unit at docker/rousseau-agent.container. Read-only root filesystem, all capabilities dropped, NoNewPrivileges=true, seccomp on. Zero inbound network ports.
Estimated time: 45 minutes.
Prerequisites
- A VPS with Ubuntu 24.04 (or Debian 12+ / Fedora 40+). 1 GB RAM, 20 GB disk is plenty.
- SSH key access to a non-root user with sudo.
- Your Anthropic API key or willingness to run
claudecli—claudeclineedsclaudeinstalled on the VPS with an active OAuth session, which is awkward on a headless server. Anthropic direct or Bedrock is the practical choice.
Step 1: base OS setup
ssh admin@vps
sudo apt update && sudo apt -y upgrade
sudo apt -y install podman uidmap fuse-overlayfs slirp4netns curl git
# rootless podman needs subuid/subgid ranges for the user
grep rousseau /etc/subuid || sudo usermod --add-subuids 200000-265535 rousseau
grep rousseau /etc/subgid || sudo usermod --add-subgids 200000-265535 rousseau
Create the service user and its systemd user session:
sudo useradd -m -s /bin/bash rousseau
sudo loginctl enable-linger rousseau # keeps user services running when nobody is logged in
Step 2: transfer the source
The Quadlet unit at docker/rousseau-agent.container builds a local image. On the VPS:
sudo -iu rousseau
git clone https://github.com/sebastienrousseau/rousseau-agent
cd rousseau-agent
podman build -t rousseau-agent:local -f docker/Dockerfile .
podman image inspect localhost/rousseau-agent:local | head
The Dockerfile produces a static Go binary (CGO_ENABLED=0), copies it into a minimal base, and runs as UID 1000. See Deployment for the base-image discussion.
Step 3: seed configuration
Rousseau reads ~/.config/rousseau/config.yaml. Create it on the host — the Quadlet unit bind-mounts the container's $HOME back to the host.
mkdir -p /home/rousseau/.config/rousseau
cat > /home/rousseau/.config/rousseau/config.yaml <<'YAML'
provider: anthropic
anthropic:
model: claude-sonnet-4-6
max_tokens: 4096
whatsapp:
reply_header: "*rousseau*\n\n"
agent:
approver:
mode: pattern
default: deny
allow:
- {tool: read, match: ".*"}
- {tool: grep, match: ".*"}
log:
level: info
format: json
YAML
chown -R rousseau:rousseau /home/rousseau/.config
Store the Anthropic API key in a systemd environment file — never in config.yaml:
mkdir -p /home/rousseau/.config/rousseau
cat > /home/rousseau/.config/rousseau/env <<'ENV'
ANTHROPIC_API_KEY=sk-ant-…
ENV
chmod 0600 /home/rousseau/.config/rousseau/env
Reference it from the Quadlet unit — see the next step.
Step 4: install the Quadlet unit
mkdir -p /home/rousseau/.config/containers/systemd
cp docker/rousseau-agent.container /home/rousseau/.config/containers/systemd/
Edit for your JID and secret file:
sed -i 's|Exec=whatsapp --allow.*|Exec=whatsapp --allow YOUR_JID@s.whatsapp.net|' \
/home/rousseau/.config/containers/systemd/rousseau-agent.container
cat >> /home/rousseau/.config/containers/systemd/rousseau-agent.container <<'EOF'
EnvironmentFile=%h/.config/rousseau/env
EOF
Reload and start:
systemctl --user daemon-reload
systemctl --user enable --now rousseau-agent
systemctl --user status rousseau-agent
Step 5: first pairing
The WhatsApp bridge needs to print a QR code the first time. Attach:
podman logs -f rousseau-agent
# scan the QR from your phone: WhatsApp > Settings > Linked devices
Expected log sequence (from internal/transport/whatsapp/client.go):
INFO whatsapp.starting store=… allowlist=1
INFO whatsapp.qr_ready
INFO whatsapp.paired
INFO whatsapp.connected
Device credentials persist to /home/rousseau/.local/share/rousseau/whatsapp.db. Subsequent restarts skip the QR.
Step 6: verify
podman exec rousseau-agent rousseau status
Exit code 0 means the daemon is healthy. Any non-zero is a red flag — see Reference: Exit codes.
Send yourself a test message from the allowlisted phone. Structured logs show:
INFO whatsapp.incoming from=447900123456@s.whatsapp.net
INFO tool.execute name=read id=t_1
INFO whatsapp.handler_ok elapsed=…
Step 7: hardening review
The Quadlet unit already enforces:
ReadOnly=true+Tmpfs=/tmp— no image mutation at runtime.DropCapability=all— the Go binary needs no elevated caps.NoNewPrivileges=true— child processes cannot gain privileges.SeccompProfile=/usr/share/containers/seccomp.json— kernel-level syscall filter.Network=pasta— rootless network stack, blocks inbound by default.UserNS=keep-id— bind-mounted files owned as expected on both sides.
If you want the tightest posture, wrap the daemon in an outbound-only firewall (nftables or Cloudflare Zero-Trust) that only allows the CDN ranges Anthropic + Meta actually resolve to. See Guides: Enterprise Onboarding for the checklist.
Step 8: backup
The whole persistent state is one directory: /home/rousseau/.local/share/rousseau/. restic or borg it nightly.
sudo -iu rousseau -- restic backup /home/rousseau/.local/share/rousseau
The SQLite databases are safe to snapshot live because WAL journaling is enabled by Open() in internal/state/sqlite/store.go.
Related
- Deployment — full Quadlet unit reference.
- Guides: Production Deployment — log shipping, rolling restarts.
- Guides: Enterprise Onboarding — SBOM verification, seccomp audit.
- Security — trust boundaries.