Overview
This guide moves a working go install-based rousseau setup into the reference production posture: a rootless Podman container managed by a systemd Quadlet unit, with a hardened runtime posture (read-only rootfs, dropped capabilities, seccomp, NoNewPrivileges, keep-id user namespace).
What changes
- Binary invocation moves from
rousseau whatsappon the host topodman rununder a Quadlet-managed systemd service. - File paths move from
~/.local/share/rousseau/*.dbon the host to bind-mounted volumes inside the container. - The Claude CLI (
~/.claude) is bind-mounted into the container so theclaudecliprovider keeps its OAuth token. - Workspace access is scoped to a single bind mount — the container sees nothing else on the host.
Config diff
The rousseau config.yaml does not change semantically. Only the file's location moves (from ~/.config/rousseau/config.yaml on the host to a bind mount inside the container).
Migration steps
1. Back up host state
tar -czf rousseau-state.tar.gz \
-C "$HOME/.local/share" rousseau \
-C "$HOME/.config" rousseau \
-C "$HOME" .claude
2. Stop the host-side daemon
# If you started it manually, Ctrl+C. If under systemd (user unit):
systemctl --user stop rousseau-agent.service || true
3. Build the image
git clone https://github.com/sebastienrousseau/rousseau-agent
cd rousseau-agent
podman build -t rousseau-agent:local -f docker/Dockerfile .
4. Install the Quadlet unit
mkdir -p ~/.config/containers/systemd
cp docker/rousseau-agent.container ~/.config/containers/systemd/
systemctl --user daemon-reload
The unit ships with:
| Setting | Value | Rationale |
|---|---|---|
Network=pasta |
Rootless network stack | slirp4netns removed from recent Podman |
UserNS=keep-id |
Container UID 1000 → host UID 1000 | Bind-mounted files retain host ownership |
ReadOnly=true |
Root filesystem read-only | The binary can't mutate the image |
Tmpfs=/tmp:rw,size=64m,mode=1777 |
Writable scratch | Anything the daemon writes lives on a bind mount |
DropCapability=all + NoNewPrivileges=true |
Least privilege | Outbound sockets need no elevated caps |
Volume=%h/.local/share/rousseau:…rw,Z |
State survives | Uses your existing sessions.db |
Volume=%h/.claude:…rw,Z |
Claude CLI auth | Reads / refreshes cached OAuth tokens |
Volume=%h/team-rousseau-workspace:/workspace:rw,Z |
Workspace | Nothing else on the host is mounted |
5. Start the container
systemctl --user start rousseau-agent.service
journalctl --user -u rousseau-agent.service -f
The first launch inside the container will see the existing pairing — no QR should print. If it does, the bind mount is not resolving to the host's whatsapp.db. Verify with podman exec (if privileges allow) or check the Quadlet mount paths.
Downgrade path
Stop the container, restart the host binary:
systemctl --user stop rousseau-agent.service
rousseau whatsapp --allow '447900123456@s.whatsapp.net'
Because the container was reading/writing the same sessions.db and whatsapp.db on the host, the host binary picks up exactly where the container left off. No data migration required.
If you built up state exclusively inside the container:
# Ensure the container is stopped, then let the host binary use the same paths.
Verification checklist
-
systemctl --user status rousseau-agent.serviceisactive (running). -
journalctl --user -u rousseau-agent.service | grep whatsapp.connectedfires within 10 s. - Send yourself a WhatsApp message from an allowlisted JID; the reply lands.
-
rousseau session list --limit 5(run on the host against the same sessions.db) shows the new session. -
rousseau doctor(host) reports green.
Common failure modes
- Container starts, exits with
permission deniedon sessions.db — missing:Zon the volume, or the host directory's owner does not matchkeep-id.chown -R $(id -u):$(id -g) ~/.local/share/rousseauand reload. - QR printed inside the container even though the host had a pairing — the
whatsapp.dbbind mount is wrong; check the Quadlet unit'sVolume=line. claudecli: exec: "claude": executable file not found— the runtime image ships the Node layer with the claude CLI. If a local override omits it, install claude inside the image or switch toprovider: anthropic.