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.
Docker Compose is a familiar shape but does not enforce the security posture Quadlet does — you must set every hardening flag by hand:
services:
rousseau:
image: rousseau-agent:local
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
- seccomp:default
user: "1000:1000"
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ${HOME}/.local/share/rousseau:/home/rousseau/.local/share/rousseau:rw,Z
- ${HOME}/.claude:/home/rousseau/.claude:rw,Z
- ${HOME}/team-rousseau-workspace:/workspace:rw,Z
environment:
HOME: /home/rousseau
restart: unless-stopped
command: ["whatsapp", "--allow", "447900123456@s.whatsapp.net"]
Kubernetes needs a Deployment + PVC. See the manifest below, plus Guides: Kubernetes Deployment for a full Helm chart example.
apiVersion: apps/v1
kind: Deployment
metadata: { name: rousseau-agent, namespace: agents }
spec:
replicas: 1
strategy: { type: Recreate }
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: rousseau
image: ghcr.io/sebastienrousseau/rousseau-agent:v0.6.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 }
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
Use aws secretsmanager to fetch the key into an env file at boot:
aws secretsmanager get-secret-value \
--secret-id rousseau/anthropic \
--query SecretString --output text | \
jq -r '"ANTHROPIC_API_KEY=\(.api_key)"' > /run/rousseau/env
Systemd:
[Service]
EnvironmentFile=/run/rousseau/env
ExecStartPre=/usr/local/bin/fetch-secrets.sh
Combine with IRSA on EKS so the SDK resolves credentials transparently — no static AWS keys needed on the host.
Use gcloud secrets versions access:
gcloud secrets versions access latest --secret=rousseau-anthropic > /run/rousseau/api_key
Or, in Kubernetes, use the Secret Manager CSI driver to mount secrets as files.
Systemd credentials (available on systemd 250+) load secrets into memory at unit start:
[Service]
LoadCredential=anthropic_key:/etc/rousseau/anthropic.key
ExecStart=/usr/local/bin/rousseau chat
The daemon reads $CREDENTIALS_DIRECTORY/anthropic_key at start. No writes to disk beyond the (encrypted) credential store.
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
- Guides: Kubernetes Deployment — full Helm chart and NetworkPolicy example.
- Guides: Production Deployment — the production checklist.
- Guides: Observability — logs and metrics.
- Security — trust boundaries, seccomp, egress.
- Configuration — every knob.
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.