?

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 claudecliclaudecli needs claude installed 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

Type to search 150+ pages. Ranking: BM25 with title/description boost.