?

Reference posture

The reference deployment is a rootless Podman container managed by a systemd Quadlet unit — one-node, no Kubernetes dependency, survives reboots, no root privileges required.

Source of truth: docker/rousseau-agent.container in the rousseau-agent repo.

Pick a topology

The reference deployment. Rootless, hardened, survives reboots, no orchestrator required.

podman build -t rousseau-agent:local -f docker/Dockerfile .
mkdir -p ~/.config/containers/systemd
cp docker/rousseau-agent.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user enable --now rousseau-agent.service

See the full Quadlet unit and its rationale further down this page.

Secrets management

Never check API keys or tokens into config.yaml. Load them at runtime from a secret backend:

Use vault agent to render env vars into a file rousseau reads. Sample template:

{{- with secret "kv/rousseau/anthropic" }}
ANTHROPIC_API_KEY={{ .Data.data.api_key }}
{{- end }}

Systemd:

[Service]
EnvironmentFile=/run/rousseau/env
ExecStartPre=/usr/local/bin/vault-agent -config=/etc/vault/agent.hcl

Build the image

podman build -t rousseau-agent:local -f docker/Dockerfile .

Multi-stage build. Stage 1: golang:1.26-alpine compiles the static binary (CGO_ENABLED=0). Stage 2: node:22-alpine supplies the claude CLI subprocess. The runtime image is ~550 MB; the Node layer only exists so the optional claudecli provider has a home.

If you use a different provider (Anthropic direct, Bedrock, Vertex, OpenAI-compatible), you can drop the Node runtime and shrink the image.

Install the Quadlet unit

mkdir -p ~/.config/containers/systemd
cp docker/rousseau-agent.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start rousseau-agent.service
journalctl --user -u rousseau-agent.service -f

Enable on boot with systemctl --user enable rousseau-agent.service after confirming lingering is on (loginctl enable-linger $USER).

Runtime posture — every Quadlet setting

Setting Value Rationale
Network=pasta Rootless network stack slirp4netns was removed from recent Podman; pasta is faster on modern kernels and blocks inbound from the host by default.
UserNS=keep-id Container UID 1000 → host UID 1000 Bind-mounted files retain host ownership; the container process can write to host-owned files.
ReadOnly=true Root filesystem read-only The daemon should never mutate the image at runtime. Anything writable lives on a bind mount or the tmpfs.
Tmpfs=/tmp:rw,size=64m,mode=1777 Writable scratch For anything that needs a scratch file at runtime (rare).
DropCapability=all Every capability dropped The Go binary needs no elevated capabilities — outbound TCP does not require CAP_NET_BIND_SERVICE or similar.
NoNewPrivileges=true no_new_privs bit set Blocks setuid escalation inside the container.
SeccompProfile=/usr/share/containers/seccomp.json Default seccomp filter Kernel-level syscall gating on top of dropped capabilities.
Volume=%h/.local/share/rousseau:/home/rousseau/.local/share/rousseau:rw,Z State bind mount Sessions, WhatsApp pairing, cron jobs, JID map, FTS5 index. :Z sets the SELinux label.
Volume=%h/.claude:/home/rousseau/.claude:rw,Z claude CLI auth Only relevant when the claudecli provider is active. claude refreshes cached OAuth in place.
Volume=%h/team-rousseau-workspace:/workspace:rw,Z Workspace Only the workspace is visible from inside the container. Nothing else on the host is mounted.
Environment=HOME=/home/rousseau Sets $HOME Consumed by Viper, the claude CLI, and the state directory resolver.
AutoUpdate=disabled Podman does not auto-update Updates are cut by the operator on a release cadence, not silently.

Exec= line

The Quadlet ships with:

Exec=whatsapp --allow 447906009073@s.whatsapp.net

Replace with your transport of choice and your allowlist. Multiple transports typically run in separate Quadlet units — one image, one binary, several units — so that a failure in one transport does not take the others down.

Kubernetes / OpenShift

rousseau is a single-binary daemon; a minimal Deployment + PersistentVolumeClaim for the state directory is sufficient:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: rousseau-agent
spec:
  replicas: 1
  strategy:
    type: Recreate           # do not run two daemons against one state DB
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        seccompProfile: {type: RuntimeDefault}
      containers:
        - name: rousseau
          image: registry.example.com/rousseau-agent:v1.0.0
          args: ["whatsapp", "--allow", "447900123456@s.whatsapp.net"]
          securityContext:
            allowPrivilegeEscalation: false
            capabilities: {drop: [ALL]}
            readOnlyRootFilesystem: true
          volumeMounts:
            - {name: state, mountPath: /home/rousseau/.local/share/rousseau}
            - {name: tmp,   mountPath: /tmp}
      volumes:
        - name: state
          persistentVolumeClaim: {claimName: rousseau-state}
        - name: tmp
          emptyDir: {medium: Memory, sizeLimit: 64Mi}

Because there is no inbound HTTP surface, no Service or Ingress is required for outbound-WebSocket transports (Slack, Discord, WhatsApp, Matrix). Only a webhook-style transport would need a Service, and rousseau ships none by default.

Recreate strategy is deliberate — the SQLite state file is not designed for two concurrent writers. If you need HA, run one daemon per transport and rely on the transport's own state (Slack Socket Mode, Discord Gateway) for reconnect semantics.

systemd log destination

The Quadlet inherits systemd's journal configuration. journalctl --user -u rousseau-agent.service reads the logs. For log aggregation, use a journal-to-Loki / journal-to-Fluent-Bit sidecar; do not pipe rousseau's log format directly to disk (it is not log-rotated by rousseau).

Configure rousseau to emit JSON so aggregators can parse it:

log:
  level: info
  format: json

Nftables egress lockdown (optional)

docker/nftables.rules.example in the source tree ships a template for kernel-level egress hardening — drop everything except Meta's WhatsApp Web ranges, Anthropic (behind CloudFront so use domain-based filter), and Signal. Layer this on top of the container namespace for the tightest posture. See security for the reasoning.

Helm chart (roadmap)

A first-party Helm chart is on the roadmap. Until it ships, the manifests above are sufficient for a minimal deployment. Track docs/GAP_ANALYSIS_2026.md for progress.

Draft values.yaml outline (for prospective users to review):

image:
  repository: ghcr.io/sebastienrousseau/rousseau-agent
  tag: v0.6.0
transport:
  name: whatsapp
  args: ["--allow", "447900123456@s.whatsapp.net"]
provider:
  name: anthropic
  # api_key sourced from a Secret
persistence:
  size: 4Gi
  storageClass: fast-ssd
resources:
  requests: { cpu: "100m", memory: "128Mi" }
  limits:   { cpu: "1",    memory: "512Mi" }
networkPolicy:
  enabled: true
  # egress: allowed CIDRs list

Troubleshooting

podman play kube fails with permission denied on a bind mount

SELinux label missing. Every volume must end with :Z (or :z for shared). See Troubleshooting: Container fails to bind mount.

Kubernetes pod CrashLoopBackOff on first start

The state volume was not pre-created, or its ownership does not match UID 1000. Add an initContainer to chown the volume:

initContainers:
  - name: chown-state
    image: busybox
    command: ["sh", "-c", "chown -R 1000:1000 /state"]
    volumeMounts: [{ name: state, mountPath: /state }]
    securityContext: { runAsUser: 0 }

systemctl --user cannot find the Quadlet unit

daemon-reload was not run, or the unit file has a typo. Confirm with systemctl --user cat rousseau-agent.service — Quadlet generates the unit on the fly, so cat is the fastest debugging tool.

After reboot, the daemon does not start

Enable lingering: loginctl enable-linger $USER. Without lingering, systemd's user manager exits on logout and does not respawn until the next login.

Two daemons stepped on each other and the state DB is corrupt

Never run two daemons against the same state.path. If corruption occurs, back up the file, rm sessions.db{,-wal,-shm}, restart. Session history is lost; pairing survives if whatsapp.db is separate (it is by default).

Related pages

Further reading

  • docker/Dockerfile — the multi-stage build.
  • docker/rousseau-agent.container — the Quadlet unit.
  • docker/example-nftables.rules — sample egress ruleset.
  • Makefile — build automation.
  • systemd docs: Quadlet.

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